🚀 Start and resume a workflow run from the CLI (#366 PR 2) - #428
Conversation
| // current-run diagnostics only; existing traces are never loaded. | ||
| // Every CLI invocation starts from an empty stream unless the caller owns | ||
| // one. --journal writes current-run diagnostics only; existing traces are | ||
| // never loaded. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // never loaded. |
| // A workflow definition is one immutable object. A component | ||
| // search path would read the mutable checkout beside it, so a | ||
| // repository component fails to resolve rather than resolving | ||
| // to content the definition does not describe. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // to content the definition does not describe. |
| cancelled: 3, | ||
| interrupted: 130, | ||
| // A run this process is still holding has not reported an outcome. Reaching | ||
| // here with one is a defect, and zero would call it success. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // here with one is a defect, and zero would call it success. |
|
|
||
| // Interruption is the outcome nothing else publishes. Registered before the | ||
| // execution starts, so a scope torn down by Ctrl-C settles the run rather | ||
| // than leaving a record with no end and a status of `running`. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // than leaving a record with no end and a status of `running`. |
| around<T>(operation: Operation<T>): Operation<T> { | ||
| // A completed run replays its retained output and result. Attaching a | ||
| // Workspace for it would open a transaction and capture a root for | ||
| // work that is not going to happen. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // work that is not going to happen. |
|
Warning This PR changes release configuration (.github/workflows/publish-packages.yml) without touching PR #428: 🚀 Start and resume a workflow run from the CLI (#366 PR 2)28 files, +2309 / -57 Scope🔴 PR has 2366 lines changed. Split into focused PRs. 🟡 2366 lines changed. PRs under 400 receive more thorough review. 🟡 28 files changed. Are all changes related? 🟡 Changes span 11 directories. 🟡 PR mixes config and source changes. StructuralOxlint structural signals:
Slop
Oxlint slop signals:
Static AnalysisOxlint: 10 diagnostics across 2 files (6 rules) no-console (3): packages/cli/src/cli.ts CorrectnessNo extraneous code patterns detected. |
|
CI does not run on this PR. The local evidence in their place, run on f5b701a with a quiesced tree: The three new suites were also run explicitly, and
Full CI will run once #426 merges and this retargets |
f5b701a to
220bf0b
Compare
|
Rebased onto #426's revised head New head: The defectWFH1 failed under Node and Bun. I had named this ordering as a limitation in the PR body and judged it harmless because Git resolution creates nothing. That was wrong: the user-visible consequence is the wrong diagnostic, and it took a run under all three runtimes to show it. My Deno-only battery could not — WFH1 takes the Deno branch there and only asserts the message is absent. Credit to the peer session that ran the full The fixHost resolution is hoisted ahead of the props phase in Verification on
|
| // current-run diagnostics only; existing traces are never loaded. | ||
| // Every CLI invocation starts from an empty stream unless the caller owns | ||
| // one. --journal writes current-run diagnostics only; existing traces are | ||
| // never loaded. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // never loaded. |
| // On a host without workflow support the first thing a caller would otherwise | ||
| // see is whatever Git said about their directory, which is not the reason the | ||
| // command is not going to run. Help is exempt: the grammar is the same | ||
| // everywhere, and describing it costs nothing. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // everywhere, and describing it costs nothing. |
| // A workflow definition is one immutable object. A component | ||
| // search path would read the mutable checkout beside it, so a | ||
| // repository component fails to resolve rather than resolving | ||
| // to content the definition does not describe. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // to content the definition does not describe. |
| cancelled: 3, | ||
| interrupted: 130, | ||
| // A run this process is still holding has not reported an outcome. Reaching | ||
| // here with one is a defect, and zero would call it success. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // here with one is a defect, and zero would call it success. |
| around<T>(operation: Operation<T>): Operation<T> { | ||
| // A completed run replays its retained output and result. Attaching a | ||
| // Workspace for it would open a transaction and capture a root for | ||
| // work that is not going to happen. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // work that is not going to happen. |
|
Independently reproduced on a clean checkout. Because this PR gets no CI, a second session verified WFH1 is in that corpus and passes under both runtimes, so the unsupported-host diagnostic is confirmed on all three rather than only where the fix was written. The stack was also checked to be intact — #426 at That independent run is the closest thing to CI evidence available until this retargets |
220bf0b to
856fa25
Compare
| // current-run diagnostics only; existing traces are never loaded. | ||
| // Every CLI invocation starts from an empty stream unless the caller owns | ||
| // one. --journal writes current-run diagnostics only; existing traces are | ||
| // never loaded. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // never loaded. |
| // A workflow definition is one immutable object. A component | ||
| // search path would read the mutable checkout beside it, so a | ||
| // repository component fails to resolve rather than resolving | ||
| // to content the definition does not describe. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // to content the definition does not describe. |
| cancelled: 3, | ||
| interrupted: 130, | ||
| // A run this process is still holding has not reported an outcome. Reaching | ||
| // here with one is a defect, and zero would call it success. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // here with one is a defect, and zero would call it success. |
|
|
||
| // Interruption is the outcome nothing else publishes. Registered before the | ||
| // execution starts, so a scope torn down by Ctrl-C settles the run rather | ||
| // than leaving a record with no end and a status of `running`. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // than leaving a record with no end and a status of `running`. |
| around<T>(operation: Operation<T>): Operation<T> { | ||
| // A completed run replays its retained output and result. Attaching a | ||
| // Workspace for it would open a transaction and capture a root for | ||
| // work that is not going to happen. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // work that is not going to happen. |
|
Restacked onto #426's That round moved workflow-run identity out of Nothing in this PR imports the surfaces that changed shape: no Verification on
|
A workflow run's `<File>` and `<Glob>` now reach the run's logical Workspace instead of the caller's filesystem. Each read, write and search is one durable Workspace effect, so the mutation, the immutable root it produces and the filtered journal result commit together, and a replay restores the recorded outcome without performing the mutation or asking what the file is now. An authored path is resolved by arithmetic on POSIX segments rooted at `/`, so no host path exists for a namespace race to replace. A documented DOFS refusal rolls its mutation savepoint back before the sanitized result is published and crosses the boundary as a `FilesReason` and nothing else; everything that is not a documented refusal stays an infrastructure failure. A temporary directory is refused rather than emulated. `useRetainedWorkflow(run)` is the other half: a host that has already created the run's storage record installs the exact frozen value, so the execution allocates no identifier and resolves no base, and every journal state requires the record to agree in run id, base and pinned commit. The CLI cannot reach any of this yet; `xmd run` and `xmd test` keep the host provider untouched.
A search answers with regular files, on the contract the host provider already answers on: a symbolic link is neither a result nor a way into the tree it names. A recorded outcome is parsed rather than believed. A record must carry its variant's members and no others, and a refusal's phase and reason must both be words the operation's vocabulary holds; anything else is the one fixed cause-free provider invariant, carrying nothing the record happened to hold. A retained installation requires the retained history as a whole to hold exactly one successful workflow_run record that reads as a run and agrees with the retained one. Reading a record can only refuse a record the journal holds, so a completed journal recording a terminal result and no run at all had nothing to refuse. The document filesystem is installed through withWorkflowWorkspace() and nowhere else. The Files provider alone would resolve a document's paths against the surrounding host's working directory, and retain it. DOFS stops nowhere between creating a write's parents and writing the file, so the savepoint's rollback is observed through an adapter-private interposition on the filesystem a Workspace transaction hands its body.
A ReplayGuard is composable policy: a handler installed further out may answer without delegating. Identity decided there depended on middleware ordering, and a completed journal reached under a suppressed guard handed its recorded root result to whichever run asked. The comparison is now a step inside the journal's own readAll, on the terms core holds a resumed run to its recorded root selection: reachable through no context, replaceable by nothing, ahead of every guard phase, of terminal reuse, of live execution and of any append. It carries the witness its source stream already had and establishes none. A record identifies a run only as the root coroutine's successfully settled Yield under the canonical type and the canonical name, holding a closed value of exactly the three members a run has. Any history with events must carry exactly one, so a same-typed Yield written elsewhere cannot stand in for the record that was removed. The filesystem a Workspace transaction hands its body is injected where the provider is installed and kept in its closure. A stable Api name is composition, and a component that reconstructs one reached the authoritative filesystem through the seam this replaces. Refusals retain a description holding the effect's type and name alone, so nothing about the run stays reachable on the error object.
856fa25 to
ab57b0e
Compare
|
Restacked onto #426's Two defects CI caught that my local battery did not1. The generated publish workflow was stale. 2. Restack notes
#426's other change this round narrows the strict one-canonical-record rule to Verification on
|
| // current-run diagnostics only; existing traces are never loaded. | ||
| // Every CLI invocation starts from an empty stream unless the caller owns | ||
| // one. --journal writes current-run diagnostics only; existing traces are | ||
| // never loaded. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // never loaded. |
| // Defaults to the host that refuses. A caller driving this without naming a | ||
| // workflow host has no run store, and inheriting one by omission is the | ||
| // failure mode the whole boundary exists to prevent — so the default is the | ||
| // one that creates and executes nothing. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // one that creates and executes nothing. |
| // On a host without workflow support the first thing a caller would otherwise | ||
| // see is whatever Git said about their directory, which is not the reason the | ||
| // command is not going to run. Help is exempt: the grammar is the same | ||
| // everywhere, and describing it costs nothing. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // everywhere, and describing it costs nothing. |
| cancelled: 3, | ||
| interrupted: 130, | ||
| // A run this process is still holding has not reported an outcome. Reaching | ||
| // here with one is a defect, and zero would call it success. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // here with one is a defect, and zero would call it success. |
|
|
||
| // Interruption is the outcome nothing else publishes. Registered before the | ||
| // execution starts, so a scope torn down by Ctrl-C settles the run rather | ||
| // than leaving a record with no end and a status of `running`. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // than leaving a record with no end and a status of `running`. |
| around<T>(operation: Operation<T>): Operation<T> { | ||
| // A completed run replays its retained output and result. Attaching a | ||
| // Workspace for it would open a transaction and capture a root for | ||
| // work that is not going to happen. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // work that is not going to happen. |
| *readObject(commit: string, path: string): Operation<string> { | ||
| // `cat-file blob` rather than `show`: it refuses a tree or a commit instead | ||
| // of rendering one, so a root document path that names a directory fails | ||
| // here rather than executing as whatever `show` chose to print. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // here rather than executing as whatever `show` chose to print. |
|
CI fully green on That is the same battery #426 passes, run against this stack's head, and it includes the two steps that caught the last round's defects — the publish-workflow regeneration check and the full tri-runtime corpus. Both PRs in the stack are now green and draft: #426 at |
`xmd workflow start [--id] [--props-*] <definition>` and `xmd workflow resume <run-id>` run a document as a retained workflow run: one implicit logical Workspace and one journal in a database that outlives the process, so an interrupted procedure continues from its journal frontier instead of from the beginning. `start` names a document and `resume` names a run, and that asymmetry is the lifecycle rule — a path locates a definition and never selects a previous run, so two starts without `--id` are two runs. What executes is the *committed* document: `start` resolves HEAD once and stores that commit as the run's identity, so uncommitted edits do not change what a run is a run of, and a resume loads the same object through retained, credential-free retrieval metadata rather than the current HEAD or a same-named working-tree file. Identity and outcome go to standard error as two stable lines, leaving stdout to the document. Only a completed run exits zero; failed, suspended, cancelled and interrupted are distinguishable so automation cannot mistake an incomplete workflow for a finished one. The capability lives on one host and the grammar on all of them: the Deno entrypoints own the local run store, and Node and Bun refuse before creating or executing anything. Resolving the host happens before the props phase, because that phase establishes the definition from Git in order to read what the pinned document declares — a host without the capability would otherwise answer with whatever Git said about the caller's directory instead of the reason the command is not going to run. The shared CLI module imports no SQLite, no DOFS and no runtime detection: it asks a host adapter to open storage and attach the run's Workspace. Until #367 supplies durable ownership, a run left running because its host disappeared is closed as an orphaned interrupted execution by the next resume. Nothing here claims concurrent resume is safe.
ab57b0e to
31f7d3a
Compare
| // Defaults to the host that refuses. A caller driving this without naming a | ||
| // workflow host has no run store, and inheriting one by omission is the | ||
| // failure mode the whole boundary exists to prevent — so the default is the | ||
| // one that creates and executes nothing. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // one that creates and executes nothing. |
| around<T>(operation: Operation<T>): Operation<T> { | ||
| // A completed run replays its retained output and result. Attaching a | ||
| // Workspace for it would open a transaction and capture a root for | ||
| // work that is not going to happen. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // work that is not going to happen. |
| *readObject(commit: string, path: string): Operation<string> { | ||
| // `cat-file blob` rather than `show`: it refuses a tree or a commit instead | ||
| // of rendering one, so a root document path that names a directory fails | ||
| // here rather than executing as whatever `show` chose to print. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // here rather than executing as whatever `show` chose to print. |
Why
xmd runexecutes against the caller's filesystem and promises nothingafterwards. A workflow needs the opposite: an interrupted procedure has to
continue from where it stopped. Everything that makes that possible was already
built — run storage (#291), atomic Workspace effects (#365), the transaction-
bound Files provider (PR 1) — with nothing able to reach it. This is the command.
PR 2 of 2 for #366, stacked on #426. It is based on
agent/issue-366-workflow-cli,not on
main; review #426 first. This PR closes #366.What changes
Before:
xmd workflowdid not exist. Specifications described it; no CLI branchexecuted it.
After:
Both stream the document to stdout and report identity and outcome on stderr:
completedexits 0,failed1,suspended2,cancelled3,interrupted130. A refused request — bad grammar, missing run, incompatible reuse, damaged
storage, unsupported host — exits 1 and publishes no status line.
How it works
resumeskips the establishment and loads the retained object through therun's retrieval metadata instead.
Review guide
Start with:
packages/cli/src/workflow.ts— the whole lifecycle, and theWorkflowHostinterface that is the only thing it cannot do itself.Then review:
packages/cli/src/workflow-definition.ts— why the bytes that execute are thecommit's, and why the checkout path is metadata rather than identity.
packages/cli/src/cli.ts— theworkflowcommand config,prepareWorkflowProps,and the two new seams in
runDocument(config.stream,mode.install).packages/cli/src/deno-workflow.tsand the four entrypoints — where thecapability is, and where it is refused.
specs/workflow-workspace-spec.md§3.7 and §3.9 — the exit codes and exactlywhat is shipped.
Look carefully at:
--props-*arguments have tocome from the document that is going to execute. Inspecting the working tree
and running the commit would let help describe one document and the run be
another.
runWorkflow. It is registered before theexecution begins and after storage is installed, so LIFO teardown reaches it
while the database handle is still open. A scope torn down by Ctrl-C settles
the run rather than leaving a record with no end.
componentDir: []for a workflow execution. A definition is one immutableobject; a search path would read the mutable checkout beside it. An
unresolvable component fails explicitly instead (asserted by WFC6).
retainedSource()is branded (retained: true). WideningRootDocumentSourceto accept any{path, source}would have broken IR3'scontract that supplied text cannot travel under an identity nobody vouched
for. The brand keeps IR3 true while letting a pinned document report its real
path.
What must stay true
resumetaking only anid, refusing
--id, every props form, and a third argument; checked by WFC3,WFC5 and WFC8.
readObject(commit, path);checked by WFC4, which edits the working tree and asserts the edit does not
appear.
WorkflowHostinterface;packages/cli/src/workflow.tsimports no SQLite, no DOFS and no runtimedetection, and
deno-workflow.tsis the only module that names the run store.before storage; checked by WFH1/WFH3, which assert no run id, no status and no
run store directory.
overwrites a run file and asserts it is byte-identical afterwards.
How to verify it
xmd workflow startover 60file effects, waits — via
when()on what a second connection can see, nevera sleep — until at least three have committed, then
SIGKILLs the child. Itasserts the signal killed it, the run is still
running(nothing ran after thesignal), and then that the resume replays the committed events by their own
event ids and in the same order, ends with exactly 60 effects, one per authored
element, and advances the current root. A duplicate from an interrupted
transaction that had already published, or a gap from one that had not, both
fail it. Green on three consecutive runs, ~5s each.
empty component search path is real.
xmd runstill writes into the caller's own filesystem.Commands run on this commit:
deno task verifywas not run: it includes the site pair and the full tri-runtimecorpus, which CI runs per job. The battery above covers every suite this change
selects plus the three new ones explicitly.
Scope
Included
xmd workflow start/resume, exit codes, and the two stderr metadata lines.GitApigainingrepositoryRoot(),objectFormat()andreadObject().refuse.
retainedSource()in core, so a pinned document reports its own path.useQuietProcessOutput()in the runtime, so the Git capability can keep itsanswers out of the caller's output without importing a host process module.
Intentionally unchanged
xmd runandxmd testkeepuseHostFiles()and their host service adapters.status,list,history,cancel,forkordelete.--prop,--journal, inline--eval, agent flags or host selectoron
workflow.workflow starttakes a plain path, exactly asxmd rundoes today.New dependencies
@executablemd/workflow(workspace:*) added to@executablemd/cli. The CLIneeds
useRetainedWorkflow,useWorkflowServiceDenial,WorkflowRunStorageand the Git capability; only
deno-workflow.tsreaches the/denosubpath.deno.lock,pnpm-lock.yamlandbun.lockare regenerated for it.Risks and limitations
ownership. Until then a run left
runningby a vanished host is treated as anorphaned interrupted execution by the next resume, which closes that unfinished
record before beginning its own. Two resumes at once are not defended against.
point and asserts the invariant that holds either way. The stricter case — a
child stopped at a point it announced from inside an open transaction — is
Tier WAC's (
workspace-crash-recovery.test.ts), and this PR does not restate it.XMD_WORKFLOW_RUNSis how tests get an isolated store. It is a real user-facingvariable, documented in the README, not a test hook.
for, so Node and Bun run
git rev-parsebefore reporting that the command isunavailable. Nothing is created or executed, but the ordering is worth naming.
Scope confirmation