Skip to content

✨ Let a workflow definition name one section of its document - #431

Draft
taras wants to merge 2 commits into
mainfrom
agent/issue-412-workflow-target-definition
Draft

✨ Let a workflow definition name one section of its document#431
taras wants to merge 2 commits into
mainfrom
agent/issue-412-workflow-target-definition

Conversation

@taras

@taras taras commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Why

Issue #412 makes a root document's sections individually addressable. #421 built the core model, and #427 taught xmd targets and xmd run to use it. Neither could be persisted: a workflow definition identified a whole root document, so a workflow could not be a run of one section.

This is the last layer. It amends the existing GitWorkflowDefinitionV1 in place — no version 2, no version union, no migration, no compatibility-only machinery.

What changes

Before:

interface GitWorkflowDefinitionV1 {
  version: 1;
  kind: "git";
  objectFormat: "sha1" | "sha256";
  objectId: string;
  rootDocumentPath: string;
}

After — one optional member:

  targetPath?: string;
  • Absent identifies the complete root document.
  • Present identifies exactly one canonical document target, with no leading #.
  • It is the resolved exact target, never an authored selector or glob.
  • Whole-document and targeted definitions are incompatible; different exact targets are incompatible; the same exact target stays compatible.
  • version remains 1 as the schema tag.

The five-member untargeted shape stays valid because it is the representation of a whole-document workflow, not a legacy format being preserved.

How it works

host descriptor → parseWorkflowDefinition → isCanonicalDocumentTarget → stored JSON column
                                                                      → conflictingFields

Canonical-target authority is not duplicated. Core's isCanonicalTarget() — the predicate document references already use — is exported as isCanonicalDocumentTarget and called by the workflow parser. Identity that two packages define separately is identity they can disagree about, and this member is compared against targets the document layer produced.

Presence is the member being written, not its value. A descriptor that wrote targetPath and gave it undefined or null asked for a target and failed to say which — refused, rather than read as the whole document. definitionToJson() writes the member only when there is one, so an untargeted definition round-trips to five members.

Diagnostics say nothing about the target. A canonical target encodes heading text, and heading text is document content, so a refusal is fixed wording at $.targetPath.

Storage is unchanged. The definition already lives in a JSON column; there is no schema migration and no new table or column.

Review guide

Start with: packages/workflow/tests/workflow-definition.test.ts (Tier WD, WD18–WD24)

Then review:

  1. packages/workflow/src/storage/definition.ts — the interface, parseTargetPath(), and serialization
  2. packages/workflow/src/storage/compatibility.ts — one added comparison
  3. packages/core/mod.ts — the renamed public export
  4. Specifications and the architecture inventory

Look carefully at: presence-vs-value in parseTargetPath(). members.has() rather than get() !== undefined is what separates "no target" from "a target I failed to name".

What must stay true

  • The stored target is the resolved exact one; a glob is never identity — enforced by validating through core's canonical predicate, which rejects *, **, and embedded wildcards. Checked by WD21 and WD23.
  • A run of one section is not a run of the whole document — enforced by sameDefinition() comparing targetPath, where absent equals only absent. Checked by WD24, WS30, WS32.
  • Stored identity is never normalized or repaired — the parser validates and rebuilds from the value as given. Checked by WD20, which round-trips nine canonical forms byte for byte.
  • A diagnostic never echoes a target — checked by WD21.

How to verify it

  • WD18 proves an untargeted descriptor writes exactly five members and carries no targetPath key.
  • WD19/WD20 prove a targeted descriptor round-trips, including nested targets and the canonical escapes %2F, %2A, %23, %25, %20, and non-ASCII.
  • WD21 proves fifteen non-canonical forms are refused at $.targetPath — empty, leading #, *, **, embedded wildcard, malformed and lowercase escapes, leading/trailing/uncollapsed whitespace, empty levels, and an NFD spelling — with no echo.
  • WD22 proves a present non-string is refused, absence excepted.
  • WD23 proves the public core predicate accepts and rejects exactly the forms definition parsing does.
  • WD24 proves same-target compatibility and both conflict directions.
  • WS28–WS32 prove persistence: an exact target survives write, lookup, and a second scope's reopen unchanged; an untargeted run reopens with no member; and one run id cannot be reused for another section or for the whole document.

Mutation checks

Mutation Result
targetPath removed from sameDefinition() WD24, WS30, WS32 red
targetPath omitted from definitionToJson() WD19, WD20, WS28 red
Canonical validation weakened to a nonempty-string check WD21 red

Scope

Included

  • targetPath on GitWorkflowDefinitionV1: parsing, serialization, comparison.
  • isCanonicalDocumentTarget exported from @executablemd/core.
  • Specification and architecture updates.
  • One unrelated fix, carried at the maintainer's request and kept as its own commit (9940870): Vite's transient vite.config.ts.timestamp-*.mjs is excluded from the site's checks and ignored by Git.

Intentionally unchanged

  • No GitWorkflowDefinitionV2, version union, version dispatch, migration, or legacy conversion.
  • Run IDs, bases, props, retrieval metadata, journal identity, Workspace behavior, database layout.
  • CLI runtime entrypoints, workflow coordination, Files providers, DOFS, journals.
  • xmd workflow start README.md#Target is not shipped here. 🚀 Start and resume a workflow run from the CLI (#366 PR 2) #428 is the consumer.

Risks and limitations

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded — with the one disclosed exception above, isolated in its own commit.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

Base and verification

Command Result
deno task lint 0 errors
deno task check no errors
deno task check:jsr Success Dry run complete
git diff --check clean
deno task test packages/workflow/tests/workflow-definition.test.ts packages/workflow/tests/workflow-run-storage.test.ts 11 passed (77 steps), 0 failed
deno task test --changed=origin/main 465 passed (3246 steps), 0 failed, 6m9s
pnpm exec tsx --tsconfig tsconfig.node.json --test packages/workflow/tests/workflow-definition.test.ts 24 pass, 0 fail
bun test packages/workflow/tests/workflow-definition.test.ts 24 pass, 0 fail
(cd site && deno task check) with a shim present exit 0

workflow-run-storage.test.ts stays excluded from Node and Bun by the existing node:sqlite entry in scripts/runtime-test-exclusions.ts; no exclusion was added.

The site fix carries a fail-first proof. With neither the ignore nor the exclusion, deno lint . in site/ reproduces the CI failure exactly (error[no-var], Found 1 problem); either mechanism alone silences it.

taras added 2 commits August 10, 2026 14:11
`site:check` and `site:build` run concurrently under the verifier, and
Vite writes a transient `vite.config.ts.timestamp-*.mjs` beside the
config while it loads it. A `deno lint .` that walked one reported
`no-var` on generated code nobody wrote, failing the battery for a file
that no longer existed by the time anyone looked.

The shim is now excluded from the site's own checks and ignored by Git.
Either alone silences it; both are kept because the exclusion states the
checker's scope and the ignore keeps the file from being committed.
A workflow definition identified a whole root document. It now optionally
carries the exact canonical document target the run is a run of, so a
workflow can be a run of one section.

`targetPath` is the resolved exact target, never the selector a caller
wrote: a glob describes what somebody asked for, and re-resolving one
against a different checkout can name a different section. Absent, the
definition means the complete document — which is what a whole-document
workflow is, not a legacy spelling.

What counts as canonical is not restated in the workflow package. Core's
predicate is exported as `isCanonicalDocumentTarget` and used directly,
because identity two packages define separately is identity they can
disagree about.

The member is closed like every other: writing it at all makes it
present, so an explicit `undefined` or `null` is a descriptor that asked
for a target and failed to name one. A refusal reports `$.targetPath` in
fixed wording that never echoes what it read, since a canonical target
encodes heading text.

Compatible reuse compares it. A run of one section, a run of another, and
a run of the whole document are three different runs, so reusing one run
id for another reports a definition conflict; the same exact target is
the same run and is found.

`version` stays 1 and there is no second version, union, or migration.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 redundant comments. Inline suggestions to remove them below.

throw fail(`expected a string, found ${describe(value)}`, path);
}
// Deliberately says nothing about the target it read: a canonical target
// encodes heading text, and heading text is document content.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// encodes heading text, and heading text is document content.

rootDocumentPath: definition.rootDocumentPath,
// Written only when there is one. An untargeted definition that stored an
// explicit absence would parse back as a descriptor that asked for a target
// and failed to name it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// and failed to name it.

@github-actions

Copy link
Copy Markdown

PR #431: ✨ Let a workflow definition name one section of its document

11 files, +401 / -6

Scope

🟡 407 lines changed. PRs under 400 receive more thorough review.

🟡 Changes span 7 directories.

🟡 PR mixes config and source changes.

Structural

✅ No structural bloat detected.

Slop

  • packages/workflow/src/storage/definition.ts:141// encodes heading text, and heading text is document content.
  • packages/workflow/src/storage/definition.ts:164// and failed to name it.

Static Analysis

✅ Oxlint found no issues.

Correctness

No extraneous code patterns detected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant