Skip to content

Commit f5b701a

Browse files
committed
🚀 Start and resume a workflow run from the CLI
`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. 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.
1 parent 20b7034 commit f5b701a

27 files changed

Lines changed: 2267 additions & 48 deletions

‎README.md‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,95 @@ Useful flags:
113113
- `--verbose`, `-V` - print durable journal entries to stderr while running.
114114
- `--component-dir` - add component search directories. Defaults to `components` and `.`.
115115

116+
## Run a document as a workflow
117+
118+
`xmd run` executes against your own filesystem and promises nothing afterwards.
119+
`xmd workflow` executes against a **run**: one retained Workspace and one
120+
journal, in a database that outlives the process, so an interrupted procedure
121+
continues from where it stopped rather than from the beginning.
122+
123+
```bash
124+
xmd workflow start flows/prepare-release.md
125+
xmd workflow start --id=release-1.4 --props-channel=stable flows/prepare-release.md
126+
xmd workflow resume release-1.4
127+
```
128+
129+
`start` names a document; `resume` names a run. A document path locates a
130+
definition and never selects a previous run, so starting the same document twice
131+
makes two runs. `resume` takes no document and no properties: it uses the ones
132+
its run retained.
133+
134+
What the run executes is the **committed** document. `start` resolves `HEAD`
135+
once and stores that commit as the run's identity, so uncommitted edits in your
136+
working tree do not change what a run is a run of, and a resume months later
137+
loads the same object rather than whatever the file says now.
138+
139+
Inside a run, `<File>` and `<Glob>` name entries in the run's own logical
140+
filesystem rather than yours. Each read, write and search is one durable effect:
141+
the change, the Workspace version it produces and the journal entry commit
142+
together, so a crash leaves all three or none, and a resume restores what
143+
already happened instead of doing it again. Operations a run does not have —
144+
a temporary directory, a native service — fail explicitly rather than reaching
145+
your machine.
146+
147+
Identity and outcome go to standard error, so piping stdout still gives you the
148+
document:
149+
150+
```text
151+
workflow run: release-1.4
152+
workflow status: completed
153+
```
154+
155+
Only a completed run exits `0`. Failed exits `1`, suspended `2`, cancelled `3`
156+
and interrupted `130`, so a script cannot mistake an incomplete workflow for a
157+
finished one.
158+
159+
Runs live under `~/.xmd/runs`; set `XMD_WORKFLOW_RUNS` to an absolute path to
160+
keep them somewhere else. The command is available through the Deno entrypoint
161+
and the compiled binary; under Node and Bun it reports that and does nothing.
162+
163+
Status, list, history, cancel and fork are designed but not yet shipped.
164+
165+
## Run a workflow
166+
167+
`xmd run` executes against the directory you are in and promises nothing
168+
afterwards. `xmd workflow` executes against a *run*: one retained Workspace and
169+
one filtered journal, in a database that outlives the process, so an interrupted
170+
procedure resumes from where it stopped instead of starting again.
171+
172+
```bash
173+
xmd workflow start flows/prepare-release.md
174+
xmd workflow start --id=release-1.4 --props-channel=stable flows/prepare-release.md
175+
xmd workflow resume release-1.4
176+
```
177+
178+
`start` names a document; `resume` names a run. A document path locates a
179+
definition and never selects a previous run, so starting the same document twice
180+
without `--id` creates two runs. Reusing an `--id` addresses the same run when
181+
the definition, base and normalized properties all agree, and is refused when
182+
any of them differ.
183+
184+
What a run is, is a Git object: the repository containing the document, the
185+
commit `HEAD` resolves to, and the document's path inside it. **The committed
186+
document runs**, so uncommitted edits in your working tree do not change what a
187+
run is a run of, and a resume months later means the same thing it did.
188+
189+
Inside a run, `<File>` and `<Glob>` name entries in the run's own logical
190+
filesystem rather than yours. Each read, write and search is one durable effect:
191+
the mutation, the Workspace root it produces and the journal result commit
192+
together, and a resume restores what was recorded instead of doing it again.
193+
Operations a run does not have — a temporary directory, a native service — fail
194+
explicitly rather than reaching your machine.
195+
196+
Runs live under `~/.xmd/runs`; set `XMD_WORKFLOW_RUNS` to an absolute directory
197+
to keep them somewhere else. Only a completed run exits `0` — failed exits `1`,
198+
suspended `2`, cancelled `3` and interrupted `130` — and the run id and final
199+
status are written to standard error as `workflow run: <id>` and
200+
`workflow status: <status>`, so standard output stays the document's own.
201+
202+
`xmd workflow` is available through the Deno entrypoint and the compiled binary.
203+
Under Node and Bun the command exists and refuses before creating anything.
204+
116205
## Coding agents
117206

118207
Run ACP-compatible coding agents directly from a document with `<Agent>`,

‎architecture.md‎

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,8 @@ The `@executablemd/workflow` package owns `WorkflowRun`, `useWorkflow()`,
149149
`getWorkflowRun()` and the Git capability. It depends on `@executablemd/core`,
150150
`@executablemd/durable-streams` and `@executablemd/runtime`, whose contextual
151151
`exec()` and `cwd()` the Git provider invokes; core never imports workflow or
152-
Git. The future CLI lifecycle is `xmd workflow start` and `xmd workflow resume`;
153-
there is no workflow CLI execution branch yet. The durable lookup that resume
154-
will require is the run storage below. Ordinary `xmd run` remains unchanged.
152+
Git. `xmd workflow start` and `xmd workflow resume` are the CLI lifecycle, and
153+
they resume through the run storage below. Ordinary `xmd run` remains unchanged.
155154

156155
## Workflow run storage
157156

@@ -347,6 +346,68 @@ describe what failed without repeating retained props or journal payloads —
347346
including their member *names*, which can carry a credential as readily as a
348347
member value can.
349348

349+
## The workflow lifecycle
350+
351+
`xmd workflow start [--id=<run-id>] [--props-*=…] <definition>` and
352+
`xmd workflow resume <run-id>` are the two commands. Both run in the foreground,
353+
stream the document's own output to standard output, and report identity and
354+
outcome on standard error as two stable lines — `workflow run: <run-id>` once
355+
the run has been created or found, and `workflow status: <status>` once the
356+
execution settles. Only a completed run exits zero: failed exits 1, suspended 2,
357+
cancelled 3 and interrupted 130, so shell automation cannot mistake an
358+
incomplete workflow for a finished one. A request the command refuses — bad
359+
grammar, a missing run, an incompatible reuse, damaged storage, an unsupported
360+
host — exits 1.
361+
362+
`start` establishes an immutable definition from Git rather than identifying a
363+
working-tree file. It locates the repository containing the supplied path,
364+
resolves `HEAD^{commit}` once because the command has no base option, reads the
365+
repository's object format, and stores version 1 of the descriptor with that
366+
format, the lowercase commit ID and the normalized repository-relative POSIX
367+
path. **The bytes that execute are the ones that commit holds**, so a working
368+
tree with uncommitted edits runs the committed document. Where the repository is
369+
checked out is retrieval metadata: replaceable, credential-free, excluded from
370+
identity, and reauthorized before it is used again. `resume` loads exactly the
371+
retained object through that locator and never substitutes the current `HEAD` or
372+
a same-named working-tree file. A missing object, missing or unreadable
373+
retrieval metadata, a path outside the repository, or a root that is not
374+
Markdown fails explicitly; none of them creates a replacement run or an empty
375+
definition. A workflow definition is one immutable object, so the component
376+
search path is empty and a repository component fails to resolve rather than
377+
resolving to content beside the definition in a mutable checkout.
378+
379+
Every actual execution opens or creates the run's database, begins a
380+
document-execution record, installs the exact retained WorkflowRun, installs the
381+
service denial before the root is imported, and — for a live or partial
382+
execution — installs the logical working directory `/`, the transaction-bound
383+
Files provider and that database's Workspace effect coordinator. It executes
384+
against the database's own journal with the retained props and secret detection,
385+
then finishes the execution record and publishes the run's status. A completed
386+
run still replays, so its retained output and result are emitted, but it
387+
attaches no Workspace provider or coordinator and performs no filesystem
388+
mutation.
389+
390+
A failure retains `failed` and uses a journal stop reason when a retained event
391+
identifies it, and a categorical host code otherwise; no exception text is
392+
retained beside the journal that filtered it. Graceful foreground interruption
393+
finishes the execution `interrupted` and exits 130. `suspended` is resumable;
394+
`failed` and `cancelled` are refused by `resume`, and `completed` replays under
395+
either command.
396+
397+
The initial host is Deno-local. The Deno entrypoints — source and the compiled
398+
binary — install the local run store, beneath `~/.xmd/runs` unless
399+
`XMD_WORKFLOW_RUNS` names another absolute directory. Node and Bun expose the
400+
same grammar and refuse before creating or executing anything. The shared CLI
401+
module imports no SQLite, no DOFS and no runtime detection: it asks a host
402+
adapter to open storage and to attach a run's Workspace, and the entrypoints
403+
decide which adapter exists.
404+
405+
Durable ownership and concurrent-executor enforcement belong to #367. Until it
406+
lands, a run left `running` because its host disappeared is treated as an
407+
orphaned interrupted execution by the next resume, which closes that unfinished
408+
execution record as `interrupted` before beginning its own. Nothing here claims
409+
concurrent resume is safe.
410+
350411
## Workflow Workspace
351412

352413
The command selects the environment; the document describes the procedure.
@@ -475,8 +536,8 @@ commit form one boundary. The Workspace filesystem uses the pinned synchronous D
475536
entry points for its string and byte-array surface, so cancellation leaves no
476537
eager promise or stream pull able to reach the connection after transaction
477538
authority ends. The transaction-bound Files provider selects that operation for
478-
every document filesystem read, write and search; workflow lifecycle commands do
479-
not select it yet.
539+
every document filesystem read, write and search, and the workflow lifecycle
540+
commands install that provider.
480541

481542
An external provider cannot join that transaction. Prompt, Git push and pull
482543
request effects derive a stable identity from the run and expansion, ask the
@@ -552,8 +613,8 @@ collection is not in the production closure and is never invoked. The provider
552613
exposes no public history selection or fork operation at this layer. Its
553614
coordinator combines one mutation, immutable-root publication and one filtered
554615
journal result atomically, and the transaction-bound Files provider is what
555-
routes a document's `<File>` and `<Glob>` to it. Workflow start and resume do
556-
not reach it yet.
616+
routes a document's `<File>` and `<Glob>` to it. `xmd workflow start` and
617+
`xmd workflow resume` install that provider around each execution.
557618

558619
The coordinator treats only errors produced through its private filesystem
559620
adapter's documented path and mutation refusals as journalable operation
@@ -1017,12 +1078,12 @@ consumed. If collision handling terminates immediately,
10171078
receives no terminal event; restoring the compatible definition can still
10181079
replay it.
10191080

1020-
#390 provides and tests the non-delegating `useWorkflowServiceDenial()` provider.
1021-
#366 will install it in the future `xmd workflow start` and `xmd workflow resume`
1022-
scopes. No workflow CLI execution branch exists yet. The provider prevents a
1023-
workflow from reaching an inherited host adapter, because a run-owned durable
1024-
service requires stable identity and reconciliation rather than an
1025-
execution-owned live process.
1081+
`xmd workflow start` and `xmd workflow resume` install the non-delegating
1082+
`useWorkflowServiceDenial()` provider inside each execution scope, before the
1083+
root document is imported — in the same place `xmd run` installs its host
1084+
service adapter. The provider prevents a workflow from reaching an inherited
1085+
host adapter, because a run-owned durable service requires stable identity and
1086+
reconciliation rather than an execution-owned live process.
10261087

10271088
## State ownership
10281089

@@ -1115,24 +1176,24 @@ Status is measured against main.
11151176
| `useWorkflow()` / `getWorkflowRun()` | associates one document execution with a workflow run | built on main |
11161177
| `useRetainedWorkflow()` | associates one document execution with a run storage already created, requiring exact journal agreement | built on the #366 stack |
11171178
| `Git.revParse()` | verifies and resolves one Git revision expression contextually | built on main |
1118-
| workflow run storage | creates or compatibly finds one run by public run ID, retains its identity, state, document executions and filtered journal, and validates immutable Workspace roots through one provider-owned connection entry | built on the #365 stack; public workflow execution is unbuilt |
1179+
| workflow run storage | creates or compatibly finds one run by public run ID, retains its identity, state, document executions and filtered journal, and validates immutable Workspace roots through one provider-owned connection entry | built on the #365 stack; the CLI lifecycle reaches it on the #366 stack |
11191180
| caller-owned storage transaction | publishes several changes, including journal events, in one transaction nothing else enlists in | built on main |
11201181
| live durable-operation coordinator | explicitly coordinates structured live execution with existing Yield publication while leaving replay and callback effects unchanged | built on the #365 stack |
11211182
| Workspace coordination API | fails closed by default; replaceable context routes only a one-use provider selection, while the selected provider directly invokes an execution-owned credentialed capability for execution, publication and failure activation | built on the #365 stack; the Deno provider installs an adapter-private atomic handler |
11221183
| explicit WorkflowRun journal route | binds one already-filtered publication to one exact active transaction and otherwise uses ordinary serialized journal storage | built on the #365 stack |
11231184
| `API.Service` / `startService()` | creates an authenticated, supervised loopback service attachment through a provider-neutral operation | built on main |
11241185
| `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data | built on the #227 stack |
11251186
| host Files provider / `useHostFiles()` | resolves document paths in the caller's filesystem, containing them while the host namespace is stable; installed by all four CLI entrypoints | built on the #227 stack |
1126-
| transaction-bound Files provider | resolves document paths in the run-owned logical Workspace inside the caller-owned transaction | built on the #366 stack; CLI reachability remains unbuilt |
1187+
| transaction-bound Files provider | resolves document paths in the run-owned logical Workspace inside the caller-owned transaction | built on the #366 stack |
11271188
| `service=<binding>` | publishes the attachment's endpoint into the live binding overlay for its invocation | built on main |
11281189
| `ephemeral eval` | reconstructs live middleware and bindings without a journal entry | built on main |
1129-
| `useWorkflowServiceDenial()` | provides and tests a non-delegating workflow service denial provider; #366 will install it in future start and resume scopes | built on main; no workflow CLI execution branch exists yet |
1130-
| `xmd workflow start` / `xmd workflow resume` | starts or resumes a workflow run from the CLI | defined in `specs/workflow-workspace-spec.md`, unbuilt; the lookup it resumes through is built |
1131-
| implicit workflow Workspace | retains provider-neutral filesystem, repository and attachment state by run ID | defined in `specs/workflow-workspace-spec.md`, unbuilt (#218) |
1190+
| `useWorkflowServiceDenial()` | provides a non-delegating workflow service denial provider, installed inside every start and resume execution scope | built on the #366 stack |
1191+
| `xmd workflow start` / `xmd workflow resume` | starts or resumes a workflow run from the CLI, under the Deno entrypoints only | built on the #366 stack; status, list, history, cancel, fork and delete are unbuilt |
1192+
| implicit workflow Workspace | retains provider-neutral filesystem, repository and attachment state by run ID | document filesystem built on the #366 stack; repository, process and attachment capabilities unbuilt (#218) |
11321193
| Repository / Worktree / transactional Git effects | compose named checkouts and publish local mutations with their journal result | defined in `specs/workflow-workspace-spec.md`, unbuilt |
11331194
| workflow inspection and history fork | reads status/history without advancing a run and creates a new run from a checkpoint | defined in `specs/workflow-workspace-spec.md`, unbuilt |
11341195
| read-only workflow Agent / generated XMD | lets an Agent inspect a derived view and propose constrained executable changes | defined in `specs/workflow-workspace-spec.md`, unbuilt |
1135-
| Deno-local DOFS provider | owns one authoritative SQLite/DOFS connection per run path, captures arbitrary canonical retained roots, privately restores them, and atomically coordinates one Workspace mutation with its filtered Yield | built on the #365 stack; public document filesystem effects route to it on the #366 stack, and workflow lifecycle reachability is unbuilt |
1196+
| Deno-local DOFS provider | owns one authoritative SQLite/DOFS connection per run path, captures arbitrary canonical retained roots, privately restores them, and atomically coordinates one Workspace mutation with its filtered Yield | built on the #365 stack; public document filesystem effects and the CLI lifecycle route to it on the #366 stack |
11361197
| scoped Worker Shell | executes `just-bash` through the Workspace adapter inside a Deno Worker | containment and effect-transaction POCs complete (#351, #357); production integration unbuilt |
11371198
| `<Retry max timeout>` | retry a region until it completes | defined, unbuilt |
11381199
| suspension effect | suspend durably | defined, unbuilt |

‎bun.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/cli/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@executablemd/test-agent": "workspace:*",
2020
"@executablemd/testing": "workspace:*",
2121
"@executablemd/web": "workspace:*",
22+
"@executablemd/workflow": "workspace:*",
2223
"@standard-schema/spec": "^1.0.0",
2324
"configliere": "^0.4.0",
2425
"effection": "4.1.0",

‎packages/cli/src/bun.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import process from "node:process";
1111
import { API, useHostFiles } from "@executablemd/runtime";
1212
import { compileDataUri } from "@executablemd/core";
1313
import { runXmd } from "./cli.ts";
14+
import { unsupportedWorkflowHost } from "./workflow.ts";
1415
import { useBunService } from "./bun-service.ts";
1516

1617
const ENTRYPOINT = fileURLToPath(import.meta.url);
@@ -35,5 +36,5 @@ await main(function* (args) {
3536
// no host default: a run with no provider must fail rather than reach the
3637
// host by accident.
3738
yield* useHostFiles();
38-
yield* runXmd(args, useBunService);
39+
yield* runXmd(args, useBunService, unsupportedWorkflowHost);
3940
});

0 commit comments

Comments
 (0)