Skip to content

✨ Execute Markdown workflows without Git (#443) - #826

Merged
taras merged 8 commits into
mainfrom
agent/issue-443-source-bundle
Sep 17, 2026
Merged

taras merged 8 commits into
mainfrom
agent/issue-443-source-bundle

Conversation

@taras

@taras taras commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Closes #443.

Why

xmd workflow start located a repository, resolved HEAD and read the document through repositoryRoot(), revParse() and readGitObject(). A file outside Git could not start, an untracked file could not start, and a modified tracked file executed its older committed bytes. That dependency also blocks #822: Workflow needs Git to load its own definition, while the Git Plugin needs Workflow's durable execution boundaries.

What changes

Before:

$ xmd workflow start ./prepare-release.md
error: not a git repository

A tracked file with unstaged edits started, but executed the bytes HEAD held rather than the ones on disk.

After:

$ xmd workflow start ./prepare-release.md
workflow run: prepare-release-8f2a

The command snapshots the file's exact current bytes before the run becomes durable. The first execution and every resume use that retained snapshot even if the original file is edited, moved or deleted. Existing version-1 runs keep executing from their pinned commit, byte for byte, through a reader the host supplies.

How it works

xmd workflow start ./doc.md#Target
  → establishDefinition() parses the reference, reads the bytes once, resolves
    the selector through Core against that retained text
  → buildSourceBundle() hashes each source and the manifest over them
  → begin() writes definition, manifest and BLOBs in one transaction
  → the authenticated retained closure it returns is what executes

A resume never reads the filesystem again. Under the executor lock, before any lifecycle mutation, it recomputes every retained length and content hash from the BLOBs it holds, and the bundle hash over the resulting manifest and mapping.

Two hashes, domain-separated. A source's content hash is SHA-256(field("executablemd.workflow.source.v2") || u64(len) || bytes). The bundle hash commits to the entrypoint, the ordered manifest and the component mapping under its own domain. field(v) = u32(utf8(v).length) || utf8(v), so no length-extension or boundary ambiguity between adjacent fields.

Logical paths are NFC Unicode scalar values, /-separated, with no leading or trailing /, no NUL, C0, DEL, backslash or #, and no empty, . or .. segment. They are compared and ordered by UTF-8 bytes — not by JavaScript <, which orders UTF-16 code units and would place U+FB01 after U+1F600.

Review guide

Start with: packages/workflow/src/storage/source-bundle.ts

Then review:

  1. packages/workflow/src/storage/definition.ts — parseWorkflowDefinition dispatches on kind, not version, which is what preserves every existing WD1–WD35 diagnostic including WD4's $.version.
  2. packages/workflow/src/lifecycle/source.ts and packages/workflow/src/deno/definition-source.ts — the retained-source union and its authentication.
  3. packages/workflow/src/deno/transitions.ts and packages/workflow/src/deno/schema.ts — source verification before lifecycle mutation, and live schema version 2.
  4. packages/cli/src/workflow-definition.ts — how a document reference becomes a bundle.

Look carefully at:

  • settleSources() in transitions.ts: source verification happens under the executor lock and before stale recovery, attachment, replay or any lifecycle write. There is no rollback path, because nothing is written to roll back.
  • validateLegacySources() in definition-source.ts: Workflow recomputes every returned blob identity from the bytes that came back. A reader that answers with different content than the descriptor names is refused, not trusted.

What must stay true

  • A v1 run executes the same bytes it always did. Enforced by keeping schema version 1 and artifact format 1 byte-identical; checked by the released fixtures in workflow-run-storage.test.ts and xmd-artifact.test.ts.
  • After begin succeeds, only its authenticated closure reaches import. Enforced by begin returning the closure and the CLI having no second path to a candidate; checked by WFK42, which reads the live staged workflow_definition_blob.content and asserts it equals the candidate bytes.
  • packages/workflow/src/run.ts is the only Workflow module importing Git. Enforced by the import-boundary test, which pins that one existing import and its sole revParse() use and also catches dynamic and bare imports.
  • Two descriptors of different versions are never one run. Enforced by compatibility.ts comparing kind before anything else; checked by the cross-version reuse case in workflow-run-storage.test.ts.
  • Mutating the caller's buffer after transition entry is inert. Enforced by copying before validation and persisting only owned bytes; checked by WD49.

How to verify it

  • scripts/tests/plugin-compiled.test.ts — "starts and resumes a document outside any repository" proves the compiled binary needs no repository at all, and fails if any Git lookup remains on the start or resume path.
  • WD36–WD52 in workflow-definition.test.ts prove the hash vectors against fixed independent values, and fail if domain separation, field framing or UTF-8 ordering changes. The U+FB01 / U+1F600 pair fails specifically if ordering regresses to JavaScript <.
  • WFC9b in workflow-cli.test.ts starts with #Pub* and expects the reported target #Publish, proving the selector is canonicalized through Core before storage rather than retained as the caller wrote it.
  • WFD47 proves a file named od#d.md is addressable inside a bundle as od%23d.md but refused as an entrypoint, because # cannot be a logical path character.
  • WS44 proves a start executes the closure begin returned rather than the candidate, which is unobservable at the storage layer because compatible reuse compares the whole manifest.
  • WFF3 proves a fork's source is independent of its parent's.
  • XE40 proves a v2 record is read at its own schema version, and fails if readRunRow reads every record as v1.
  • WFK42 proves staged bytes are the candidate's, reading them live from SQLite rather than inferring them from a replay.

Manual check:

$ cd "$(mktemp -d)" && printf '# Hello\n' > doc.md
$ xmd workflow start ./doc.md      # no repository anywhere above this directory

Scope

Included

  • Source-bundle definition v2, its closed parser, framing and hashing.
  • Live schema version 2 and artifact format 2, alongside unchanged 1.
  • Version-aware records, compatibility, lifecycle transitions, journals, public WorkflowRun, forks and artifacts.
  • LegacyWorkflowSourceReader, the direct non-contextual closure a trusted host supplies for v1.
  • CLI start, resume and fork from retained source, including target resolution.
  • The five authoritative documents reconciled to the delivered contract.

Intentionally unchanged

New abstractions

  • SourceBundleWorkflowDefinitionV2 and its parser exist because identity must come from retained bytes rather than a Git object; consumed by storage, lifecycle, journals, artifacts and the CLI.

  • LegacyWorkflowSourceReader exists because a v1 run's bytes live in a repository Workflow must not reach into. It is a direct closure rather than a context so that Make Git and GitHub available in XMD runs and workflows #822 can move it without leaving a contextual seam behind.

  • RetainedDefinitionSources exists as the closed v1/v2 union every executable path consumes, so a candidate cannot remain a second route to execution.

  • Each new abstraction has multiple concrete uses or a clear justification.

  • No speculative functionality is included.

New dependencies

None. No manifest or lockfile changed.

Generated or mechanical changes

  • The last two commits are test-host corrections with no production behavior:
    • 🐛 Narrow v1-only test hosts replaces union member reads in five v1-only test hosts with the already-exported isGitWorkflowDefinition / isGitWorkflowRunRecord predicates. No casts.
    • 🐛 Install the legacy source reader in v1 CLI test hosts passes legacySource: readLegacyDefinitionSource at twelve installation sites in two suites whose helpers call themselves "the production host" while omitting what production installs.

Risks and limitations

  • V1 compatibility is byte-sensitive. The v1 schema and artifact declarations are immutable and must not be "shared" with v2 by widening an inventory or a serializer.
  • The legacy reader is a real trust boundary. It returns content from outside Workflow's durable state. Workflow recomputes every blob identity it returns; a change that trusts the descriptor instead would silently accept substituted content.
  • Recovery or rollback: revert the branch. Version-1 runs are untouched by it, and no version-2 run exists before it merges.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

A workflow definition can now be the source itself. The v2 descriptor
addresses exact bytes by logical path, so a file outside a repository, an
untracked file and a modified tracked file all have one immutable identity
before anything retains them.

Identity is two domain-separated, length-framed SHA-256 computations over the
entrypoint, the canonical source manifest and the component mapping, through
the platform's own `crypto`. The exact target stays outside the bundle hash
and inside the definition: selecting a section does not change the bytes in
the bundle. Host paths, props and retrieval metadata are not members of the
shape at all.

Parsing is closed and canonical. Logical paths are NFC scalar values without
NUL, C0, DEL, backslash or `#`; manifests arrive in UTF-8 byte order with no
duplicates, and a parser refuses a malformed order rather than sorting it.
Snapshot verification copies the caller's buffers before checking them, so a
later mutation of a caller-owned array cannot change what a run would retain.

`WorkflowDefinition` stays v1-only here, so existing storage and runtime code
remains coherent; storage, lifecycle, journals and artifacts become
version-aware in the next commit.

Refs #443
A workflow run can now be a run of the bytes it retains. Storage, lifecycle,
journals and artifacts each became version-aware by their own identity rather
than by widening version 1's, so a Git run keeps exactly the schema, record,
descriptor, journal value and artifact bytes it always had.

Live storage gains a second immutable inventory. Version 2 keeps every
version-1 object byte for byte, replaces `workflow_run` with one that has no
base column, and adds the two definition-source tables. Recognition reads the
application id, then dispatches on version 1 or 2 and holds the whole inventory
to that version's declaration: a hybrid, an extra object or version 0 is the
file disagreeing with itself, never a migration candidate.

Creating a version-2 run crosses only the trusted lifecycle transition. Public
`WorkflowRunStorage.create()` stays version-1 and refuses a source-bundle
descriptor, because that request carries a descriptor and no bytes. The
transition copies the caller's buffers and holds them to the descriptor before
the transaction opens, then writes schema, run, manifest, de-duplicated content,
Workspace, lifecycle and the first execution together or not at all.

Source is proved before anything moves. A version-2 run's content is re-derived
from its own store — every length, every source hash and the bundle hash — and a
missing entry, damaged content or a disagreeing hash refuses with the run's
lifecycle and journal untouched. A version-1 run's content crosses the
host-captured `LegacyWorkflowSourceReader`, which is a direct closure reachable
through no Context, Api, component or Plugin; Workflow recomputes the returned
root and component blob identities from the bytes themselves before any of it
counts as this run's. A creation is held to its own descriptor before
persistence, so a host that cannot obtain a Git definition's Markdown never
reaches the transaction that would make the run exist.

The public and durable `WorkflowRun` is one closed union. Version 1 keeps its
exact three members; version 2 records `definitionVersion`, `bundleHash` and its
exact target and invents no base or pinned commit. Readers accept either exact
member set in any key order and nothing between them.

Artifacts keep the physical container at version 1 and dispatch the semantic
format from the header. Format 1 keeps its manifest, identity domain and Git
closure byte for byte; format 2 has manifest version 2, the `xmd-artifact\0v2\0`
domain, its own closed inventory, and one entry/content pair per logical source.
The writer selects the format from the run's version and the reader verifies
only the format its header declares.

Refs #443
`xmd workflow start ./notes.md` now runs the file's current bytes. A file
outside a repository starts, an untracked file starts, and a modified tracked
file runs what it says rather than what its last commit said — because the run
retains those bytes before it becomes durable, and a record naming a commit
while executing something else was the thing that could not be true.

Establishment reads the supplied root once. The logical entrypoint is the
file's own final segment, normalized; the containing directory, the absolute
path and the invocation working directory never reach the descriptor. Declared
components are resolved against the root's own directory, read as bytes,
decoded strictly and parsed — so a declaration this command cannot read refuses
the start before any storage exists, rather than surfacing the first time a
document writes the name. Every source's identity is the hash of the bytes that
were actually read, and the descriptor is parsed back through storage's own
closed parser before it is offered to anything.

Git is provenance now, not retrieval. A start records where it happened when
that is cheaply available, as replaceable metadata; a directory that is not a
working tree simply has none, and the start is an ordinary start. Nothing reads
it back to find a source.

After admission the CLI executes the closure the lifecycle authenticated and
nothing it read itself. The run id is reported only once the creation
transaction has committed, and the post-admission reloads are gone: a candidate
that remained a second path to execution would be a second answer to what the
run is a run of.

`workflow-source.ts` is now only the legacy version-1 Git reader, captured by
the Deno host on both the run-host and lifecycle installations and reachable
through no Context, Api, component or Plugin. It maps an unreadable object into
the categorical failure Workflow declares for it and judges nothing else:
Workflow recomputes the returned blob identities itself.

Status and history render a source-bundle run by its entrypoint and bundle
hash, with no base line and no fabricated commit.

Refs #443
A workflow definition is two versions now, and the five authoritative documents
say so. Version 2 is a source bundle: the exact bytes themselves, addressed by
logical paths and retained with the run, so a file outside a repository, an
untracked file and a file edited since its last commit all start and all stay
runnable after the original is edited, moved or deleted. Version 1 is unchanged
in shape, identity, behavior and bytes, and nothing migrates between them.

`specs/workflow-spec.md` states the closed descriptor union and its dispatch by
kind, the logical-path grammar, the domain-separated source and bundle hashes
and what each commits to, the target's exclusion from the bundle hash and its
place in identity, per-version compatible reuse, the two immutable live schema
versions and their recognition, what a retained source has to prove, the
creation union with its owned copies and one atomic transaction, the order that
puts every source check before recovery and admission, the legacy source reader
and its validation, and the five source refusals.

`specs/xmd-artifact-spec.md` keeps the physical container at schema version 1
and splits the semantic format: format 1 retains its manifest, identity domain
and Git closure byte for byte, and format 2 declares its own manifest version,
`xmd-artifact\0v2\0` domain, closed inventory and one entry/content pair per
logical source. The reader selects the verifier from the header and never reads
one version's closure as the other's.

`specs/workflow-workspace-spec.md` states the start ordering, the boundary that
proves source before recovery, execution records, Workspace attachment or
replay, the reference grammar `start` accepts, the fork candidate's independence
from the source run and from every file it was read from, and the status shapes
that carry no fabricated Git fields.

`architecture.md` records the terminology, the two live schema versions, the CLI
candidate against the authoritative retained source, and the inventory rows for
the definition, the reader and both artifact formats.

Two things are stated as undelivered rather than described as done: the
artifact-backed fork, which this revision supplies the authenticated source
input for, and #822's extraction of `workflowInstallation({ base })` — still the
one place this package reaches Git to establish a run.

Refs #443
@taras
taras force-pushed the agent/issue-443-source-bundle branch from 08fc6ac to 897cef3 Compare September 17, 2026 13:47
@taras
taras enabled auto-merge (squash) September 17, 2026 15:24
@taras
taras disabled auto-merge September 17, 2026 15:24
@taras
taras merged commit c00fa82 into main Sep 17, 2026
38 of 39 checks passed
@taras
taras deleted the agent/issue-443-source-bundle branch September 17, 2026 15:24
@taras taras mentioned this pull request Sep 21, 2026
4 tasks
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.

Execute Markdown workflows without Git

1 participant