diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ecc2c8a40..2aea93d7d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -640,6 +640,34 @@ jobs:
# on a `ci-main-red-fix` pull request, and may be skipped on an ordinary one.
# Every other job must succeed outright — an unexpected skip is an unproven
# job, which is exactly what this check exists to catch.
+ # The workerd suite. It runs where the runtime is real: acquisition lifetime,
+ # owner eviction and transaction atomicity are properties of a Durable Object
+ # rather than of any model of one, so none of them is provable in the Deno,
+ # Node or Bun corpora — which is also why these files carry a `.vitest.ts`
+ # suffix those corpora never discover.
+ #
+ # `pnpm install` comes last on purpose: `deno install` prunes the links pnpm
+ # placed (scripts/deps.ts says so in its own header), and the plugin only
+ # takes over the pool when it and the CLI hold the same vitest.
+ test-cloudflare:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: "22"
+
+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
+
+ - run: pnpm install
+
+ - name: Typecheck the Cloudflare owner
+ run: pnpm check:cloudflare
+
+ - name: Cloudflare Workers suite
+ run: pnpm test:cloudflare
+
green:
needs:
[
@@ -653,6 +681,7 @@ jobs:
site,
test-node,
test-bun,
+ test-cloudflare,
]
if: always()
runs-on: ubuntu-latest
diff --git a/architecture.md b/architecture.md
index 879b0aec6..beadceeaa 100644
--- a/architecture.md
+++ b/architecture.md
@@ -104,6 +104,17 @@ Existing documents and code get aligned to this section retroactively.
| recovery tombstone | an ownership record left active because its owner never proved it stopped. A crash releases the kernel lock and not this, and no pid, elapsed time, released lock or empty transcript clears it |
| provider partition | one complete, independently owned agent-provider state — runtime, store, managed sessions, queues, coordinator, teardown — selected by the one installed factory at each dispatch. Production is the single-partition case of the same path; holding a partition grants work, never permission |
| `JournalProvenance` | a non-operational, equality-only witness that a live publication stream descends from the exact journal backend a provider selected for one workflow run; it grants no append, read, execution, publication or reconciliation capability, and is meaningful only because the provider retains the witness it established and later requires exact equality |
+| factory run identity | the run ID a software-factory run is addressed by: the lowercase unpadded RFC 4648 Base32 encoding of the full SHA-256 digest of the UTF-8 bytes `github-issue-v1`, NUL, the canonical GitHub authority, NUL and the exact GitHub issue GraphQL node ID. The canonical GitHub authority and the node ID are the ones `specs/github-actions-software-factory-spec.md` §1.1 defines, byte for byte; there is no more general Issue-provider spelling of this hash. It is one host-selected public run ID, derived once from immutable provider identity, and it is distinct from the workflow definition SHA, the implementation revision, the Workspace root, the expansion identity and every delivery identity |
+| authenticated intake | one bounded record a trusted host retains for an externally delivered request — a verified webhook, or an authenticated human form submission — keyed by the provider's own delivery or submission identity and holding only typed bounded fields. It is what a later execution reads; it is never a stage, an outcome, a transition or a credential, and receiving one authorizes nothing beyond finding the run it names |
+| Project provider | an external service that owns project boards and the status of the items on them. GitHub Projects V2 is one adapter. It is a separate boundary from a Git host and from an Issue provider, because a project board need own neither a repository nor an issue collection |
+| Project projection | the human-facing status a Project provider holds for one item, published from the journaled lifecycle rather than read as it. A projection ahead of the journal is drift to reconcile; it is never evidence that a lifecycle transition happened |
+| executor connection | a remote host's form of executor acquisition: one authenticated connection whose lifetime *is* the acquisition. Like the local executor lock it is not a time lease — no duration, expiry, renewal, heartbeat, PID or liveness poll — and closing it releases executor ownership without rolling back what already committed |
+| delivery-plane transaction | one authenticated transaction that retains an externally supplied value for an exact retained subject without executing the run: intake retention, typed answer delivery and terminal-decision delivery are the three. It generalizes delivery to subjects other than a suspension request, on the same terms: no executor acquisition, no document execution, no journal append, no run-status change |
+| implementation revision | the evolving pair `{ headSha, baseSha }` one factory run is currently producing or reviewing: the exact head commit of the implementation branch and the exact target-branch commit it is evaluated against. It changes many times within one run and never takes part in run identity |
+| exact-review subject | the implementation revision a review conclusion names. A conclusion authorizes only the pair it names, so a later revision inherits nothing and a moved half of the pair invalidates it |
+| machine wait | a durable wait that asks nobody anything: it ends because a later execution observed a provider again, not because a value was delivered. It shares the atomic suspension boundary — its retained event and the `suspended` status commit together, and the executor acquisition is released only after that commit — but it is a distinct event kind identified by a `waitId`, and it has no response schema, no answer route, no form and no bound value. It is a second kind of wait inside the lifecycle, never a second lifecycle controller |
+| wake notification | a bounded record correlated to one exact machine wait, retained by an authenticated intake as an ordinary delivery-plane transaction. It carries no answer, verdict, stage, transition or observation result; a later executor consumes one inside the run's transaction and appends the wake event that permits exactly one further observation |
+| terminal settlement | the last transition of a run whose outcome required external projections: the retained terminal decision is published as run state only after every required projection has completed, so a completed replay never needs a provider to repair one |
## Three axes
@@ -397,6 +408,45 @@ describe what failed without repeating retained props or journal payloads —
including their member *names*, which can carry a credential as readily as a
member value can.
+### One remote owner for one run
+
+A remote host owns the same run the local host owns, through the same
+provider-neutral surfaces. The Cloudflare topology is one SQLite-backed Durable
+Object per run, selected from the public run ID by the same arithmetic local
+discovery uses, so a remote run has exactly one durable owner and no second
+registry can disagree with it. That object holds the WorkflowRun record and its
+filtered journal, the immutable Workspace roots and their content-addressed
+bytes, the Agent-session mappings and checkpoints, the retained delivery state,
+the authenticated intake records, and executor ownership.
+
+The runtime-named Cloudflare entrypoint is the only place that topology appears.
+Shared modules reach it through the contextual storage and lifecycle APIs they
+already use, detect no runtime, and import nothing Cloudflare-specific — the
+same boundary the Deno entrypoint sits behind.
+
+Native execution stays off that object. Native Git, evidence processes and Agent
+clients run on an ephemeral runner against bounded materialized state; the
+Durable Object runs none of them. The runner materializes one selected retained
+root, works in it, and submits content-addressed changes; the owner validates
+the executor acquisition, the expected root and the submitted content, then
+atomically publishes the new root together with the filtered journal result.
+That is the same effect transaction local Workspace mutation uses, with the
+mutation performed where the tools are and the publication performed where the
+authority is.
+
+A runner that dies mid-flight therefore exposes only a prior or a new complete
+transaction, never a partial one. The next acquisition performs the ordinary
+stale-execution recovery and resumes from the exact committed WorkflowRun and
+Workspace frontier. A completed run replays as it does anywhere, and reading its own history is what it does: it may reach the durable owner holding its retained result — an ephemeral client has nothing else to replay from — while attaching no Workspace, Agent, process, Git, Git-host, Issue, Project, credential or other external-effect provider, performing no effect again and starting no native operation. Lifecycle storage access is not external-effect replay, and only the second is what a completed run must never do.
+
+What that owner implements, it implements behind the four-method `WorkflowHost` boundary and nothing wider: create and lookup, coherent reads, lifecycle transitions and terminal settlement, stale recovery, fork and its staging, Workspace publication, typed delivery and consumption, read-only inspection and history, and canonical completed replay.
+
+A runner reaches it over three requests, and the path says which plane. One run id selects its owner arithmetically, a gateway forwards on that id alone, and the executor plane is a real upgrade whose admission order — release, then token, then the run — is the owner's own. One configured client carries all three: an already-selected run id, one credential-free endpoint parsed at construction, the release identity, a token minted per request, and the I/O its host performs. It is bound to that one run, refuses another before a token exists, and keeps endpoint, release and token in closure state that reaches no record, event, error or document-visible value. Trusted code assembles that client into the same four methods, and an attachment is bound to the exact storage handle the begin transition produced — compared by identity to the link this runner's own acquisition opened, because two clients on two owners can hold handles that agree about everything except where they came from.
+
+A remote attachment installs what a local one installs, because the document rules are written once. Filesystem, Repository, Worktree, `
`, Git, composition components, pull-request and Issue middleware, elicitation and any configured Agent profile all come from the same modules; what each host contributes is four things — the effect a mutation becomes, the savepoint that undoes an unfinishable part of one, the read an ephemeral checkout attachment needs, and the transaction an Agent-session mapping is retained by. The exact storage handle decides which host answers all four, so an authored `` on a remote run becomes a durable Workspace effect against the invocation's materialized tree and never falls through to the runner's own filesystem, a reattachment reads one owner snapshot into a tree the invocation owns and closes it before Git runs, an Agent-session mapping is retained by the run's own owner in one transaction the provider is never called from inside, and a handle no attachment registered is refused rather than performed.
+
+What no code chooses is which host a run belongs to. There is no runtime or CLI selector, no ambient endpoint, release-identity or OIDC source, and no deployment: the shipped entrypoints install the local host, and the explicit installer is the seam a later trusted assembly configures.
+
## The workflow lifecycle
`xmd workflow start [--id=] [--props-*=…] ` and
@@ -625,7 +675,7 @@ the requested lifecycle action and inserts one execution record while
publishing `running`. A replay of an already completed or failed root instead
inserts its execution record while preserving that terminal run state: the
replay observes an outcome that already won and does not make the run mutable
-again. The settlement transition finishes the record while publishing its
+again. The run row is left exactly what it was, `updatedAt` included, and its own settlement closes only the execution envelope it opened — the one durable change a coherent replay makes. The settlement transition finishes the record while publishing its
terminal or resumable status. Begin and settlement are separate transactions
enclosing the document execution, and each is atomic within itself.
@@ -674,6 +724,12 @@ without entering replay. A `cancelled` run reports its retained state without
entering replay under either command. None can be advanced under the same run
ID.
+A retained terminal is read before it is trusted, and one shared judgment does the reading. Both layers of the root `Close` are load-bearing: the outer coroutine settlement says the root returned, was raised out of, or was cancelled, and a returned value's own `status` decides whether the run completed or failed — so reading the outer layer alone would call every finished document a completed one. The history around that terminal has to agree with it. Exactly one final root `Close` may exist, because a second one, or work recorded after the one that is there, is a history no single execution produced and choosing between them would be this build deciding which execution the run was. A run that failed before importing anything carries its root binding and no import; a run that produced an ordinary document result carries exactly one root import, recorded by the root coroutine, and canonical core's own root-selection parser is what reads it — the same function that admits a partial history, so a retained selection the executor would refuse cannot publish an outcome here instead. That parser proves the selection rather than recognizing its shape: the retained document parses, an exact target resolves against it to the exact recorded target, and a recorded selection failure re-derives from the same selector to the same kind, matches and available catalog. A selection that named no target is raised out of the root import, so a successful document result beside one is two histories rather than one.
+
+Anything those readings refuse is damaged history rather than an outcome, and damage outranks the stored row. A run whose row says `completed` or `failed` over a terminal this build cannot read is not advanced, not re-settled and not published: `start`, `resume` and `cancel` each refuse before an execution record exists, before an acquisition performs anything and before Git, a Workspace or any provider is reached, and the run keeps its row, its journal, its Workspace frontier and whatever unfinished execution the previous executor left open. Stale recovery reads the same judgment first and publishes nothing over damaged history. Both hosts reach these conclusions through the one provider-neutral lifecycle policy; `specs/workflow-spec.md` §9.9 is the normative statement of them.
+
+A coherent terminal is also where a completed replay gets its inputs. Nothing is fetched for it: the root document is the one the run's own root import retained, the bundle admission is rebuilt from the definition rather than from a repository, and each retained component's bytes are authenticated by naming them the way Git names a blob and comparing that to the object id the definition already holds. So the replay reaches no repository, no working tree and no live component import, and a history whose retained root does not agree with the run's own definition path refuses before anything is replayed from it.
+
The executor lock remains in the trusted host's outer lifecycle scope; it is not
installed as a document provider. A completed replay may hold
the lock while recording its document-execution envelope, while the run remains
@@ -700,6 +756,29 @@ while the deleting workflow executor still holds its lock. An empty lock file
is not retained run state and is not reported as history or provider-session
data.
+### Remote executor connection
+
+A remote host acquires the same authority over one authenticated connection.
+The acquisition is that connection's lifetime: the run's owner registers the
+exact acquisition when the connection is admitted and invalidates it when the
+connection closes, which is the staleness proof a remote host has in place of a
+released kernel lock. It is not a time lease either — no duration, expiry,
+renewal, heartbeat, generation record or liveness poll — and closing it releases
+executor ownership without rolling back anything already committed. A second
+healthy executor follows the active one or is refused; it cannot advance the
+run.
+
+What the acquisition gates is the same list the lock gates locally, plus what a
+split host adds: start and resume, stale-execution recovery, document execution,
+Workspace mutation, Agent attachment, native Git and evidence execution,
+lifecycle transition, accepted-outcome publication and terminal settlement. Each
+of them validates the exact live acquisition *and* the expected Workspace root
+inside its own mutating transaction, so a stale connection and a stale frontier
+are refused at the same boundary rather than at two.
+
+What it does not gate is delivery and inspection. Those are described below and
+below that, and neither becomes transition authority by being remote.
+
### Read-only lifecycle inspection
Inspection has its own provider-neutral immutable snapshot surface. It returns
@@ -725,6 +804,8 @@ ordered rows — authored source, cumulative forkability, inherited provenance
because two mappings are how history quietly starts meaning different things
depending on where it was read.
+A durable owner satisfies that surface from one committed reading of its own state: it takes no acquisition, attaches nothing, materializes nothing, appends nothing, and answers status, list and history from the same projection the local provider runs, so a malformed or incomplete page fails the request whole rather than returning part of one. Everything in the rest of this section is the local provider's, because a hot rollback journal and the coordination that recovers one are facts about a file.
+
The Deno provider first reads each retained database through its ordinary
read-only connection. A host crash may leave a hot rollback journal: that is a
healthy database one SQLite rollback away from the last committed state, but
@@ -965,6 +1046,17 @@ installed nearer — may answer the question or refuse it, and in both cases the
run it affected never suspended; continuation of a suspended run is retained
delivery state and nothing else.
+A response schema is judged by one implementation wherever it is judged. The
+document runtime judges what a provider returns, a local host judges a delivered
+answer, and a run whose storage lives somewhere else judges it there — inside
+the transaction that writes it, because a boundary that accepts somebody's
+report of a judgment has not made one. That is possible only because the
+judgment generates no code: a runtime that refuses code generation during a
+request cannot compile a schema it learns from a retained wait, so a validator
+that compiles could never be the one every boundary runs. It is draft-07, the
+schema is admitted against the draft-07 meta-schema before anything is asked,
+and `format` annotates without constraining.
+
The retained request at the execution's exact current durable position is what
authorizes entry. The controller derives the identifier for the position
immediately behind the caller's own, requires the presented one to equal it,
@@ -1027,6 +1119,24 @@ transaction that does not commit publishes nothing. Replay after that
transaction commits restores the recorded answer event without reaching the live
controller and without consuming or publishing again.
+That separation is a property of delivery rather than of the local CLI, so it
+survives a remote host and generalizes past a suspension request. A **delivery-
+plane transaction** is any authenticated transaction that retains an externally
+supplied value for an exact retained subject: intake retention, typed answer
+delivery, and the terminal-decision delivery the software-factory specification
+describes. Each validates its own delivery identity and
+its own retained subject, retains only the typed bounded value that subject
+describes, and does exactly what answer delivery does otherwise — takes no
+executor acquisition, begins no document execution, attaches no provider,
+appends no lifecycle outcome and changes no run status. A duplicate delivery of
+one identity finds the retained record and writes nothing.
+
+Consumption stays where it already is. A later executor reads the retained value
+inside the run's own transaction, appends the accepted durable event or outcome
+exactly once, and only then may authored control flow decide what follows. A
+delivery that could advance a lifecycle would be a second state machine beside
+the journal, which is the thing this split exists to prevent.
+
Scheduling — automatic resume, watchers, unattended iteration and remote host
selection — is #300's and is not part of this behavior. Nothing here waits on
it: a suspended run continues through `xmd workflow answer` followed by an
@@ -1406,6 +1516,75 @@ afterwards, and nothing catches its refusal to try somebody else, because a
search is how a document that named one service quietly reaches another. A
destination every provider delegated reaches the operation's own base error.
+### A project board is a third external boundary
+
+A **Project provider** owns project boards and the status of the items on them,
+and it is a boundary of its own for the reason the Issue boundary is one: a
+project board need own neither a Git repository nor an issue collection, so a
+Project status cannot truthfully execute or persist as a Git-host effect or an
+Issue effect. `Project.Status` reaches its own contextual operation and journals
+its own durable effect. Its natural key is the exact Project item plus the exact
+field; its compatible pre-state is the option that item currently holds; and the
+configured Project, item, field and option identities are a host ceiling rather
+than something an authored prop can widen.
+
+The status a board shows is a **Project projection** of the journaled lifecycle,
+never a reading of it. A board ahead of the journal is drift the next execution
+reconciles, and it is not evidence that a stage was passed.
+
+Which option means which stage is configuration, not inference. A host that projects lifecycle onto a board holds a total bijection between its stages and the board's exact status option IDs, validated against a complete reread before it is used and refusing when it is missing, partial, duplicated, names an option the board does not offer or one under another field, or names an option whose display name is not the settled string for its stage. Admission maps a reread option ID to a stage through that table and projection maps a stage back through its inverse; neither direction parses a display string, because a name is what a person reads and an option ID is what the host compares.
+
+### Comments, readiness and closure
+
+Four more reconciled effects join the same boundary, each keyed by its own subject. `Issue.Comment` is an Issue-provider effect keyed by the canonical issue URL plus the engine-derived effect identity; `PullRequest.Comment` is a Git-host effect keyed by the canonical pull-request URL plus that identity. The body is presentation in both: keying a comment by its text would make an edited sentence a different comment. Creating an object is not by itself what makes an effect attempt-stateful: an Issue upsert and a pull-request upsert each reconcile on a key or an identity the provider gives them, and keep their existing complete-observation contracts. A comment has neither. A Git host issues no client-supplied idempotency key for one, so a comment provider has to support one stable opaque correlation marker it can write, preserve and completely query, and a provider that cannot refuses before its first mutation. The marker is provider transport metadata rather than authored prose: the authored logical body stays byte for byte what the document rendered, the correlation representation rides outside it, and the binding and every replay expose the body and the provider's comment identity rather than the encoding. What a complete observation means depends on the attempt state the effect retains — before an attempt, no marker is proven absence and permits one creation; after an attempt with no committed completion, no marker is permanent ambiguity, because it equally describes somebody having removed one. That is what stops an interrupted creation from becoming a duplicate without pretending a person cannot edit a comment. `PullRequest.Ready` and `PullRequest.Close` are Git-host effects keyed by their exact pull-request subject, and `Issue.Close` is an Issue-provider effect keyed by its exact issue subject and carrying a closed `reason` enum that has to match the retained terminal intent. Each observes before it mutates, adopts a compatible completion, performs once from proven absence or an exact compatible pre-state, and refuses conflict, permanent ambiguity, incomplete observation and temporary unavailability.
+
+`PullRequest.Merged` is the fourth, and it is the one that never mutates. A Git host records a pull request as merged when it notices its own ref move, which is not the same event as a target publication succeeding, so the fact has to be observed as its own retained step rather than inferred from the step before it. Adoption is its only completion: the host reporting the pull request merged at the exact published commit is the fact, and a merge at another commit is a conflict. A pull request still open is temporary unavailability — the host has not caught up, which is a different thing from refusing — while one closed unmerged is a conflict, because a person having intervened is not a state that resolves itself by waiting. A run that keeps observing the open case does so through a bounded host-configured retry and then a machine wait whose subject is the pull request and the expected commit. That wait is not a typed-answer suspension: it publishes no response schema, accepts no delivered value, and ends because an execution looked again rather than because somebody answered. An authenticated intake retains a bounded wake notification correlated to that exact wait and nothing more; a later executor consumes it and appends the wake event in one transaction, and only a compatible observation advances anything.
+
+### Ordered merge, and publishing a target
+
+`Git.Merge` is Workspace-local. It observes and fixes its first parent, second
+parent and merge base before it mutates, and its two closed results are
+distinct: a clean merge publishes the new commit, the new Workspace root and the
+filtered result in one effect transaction, while a conflicted merge restores the
+pre-merge root and publishes normalized conflict evidence against it. The parent
+order is the caller's and is part of the request, because the two merges a
+factory performs mean opposite things — synchronizing a target into an
+implementation, and publishing an implementation onto a target.
+
+`Git.PublishTarget` is a Git-host effect and is not a spelling of `Git.Push`.
+Push advances a branch this run published, from an ancestry relation proved
+inside the authenticated object source. Target publication is a compare-and-swap
+against a protected ref: it updates only after observing the target equal to the
+expected commit, adopts a target already equal to the exact source commit with
+nothing performed, and refuses everything else without mutating. The remote, the
+ref, the credential and the non-force policy are host-owned; the reviewed head
+and expected commit travel in the request so the record says what the
+publication was authorized against.
+
+### Trusted evidence execution
+
+`Evidence.Run` executes an authored structured argv list natively, on the trusted runner, against one exact retained Workspace root, under host-owned executable, environment, working-root, per-command duration, whole-run duration, output and process-tree ceilings. It is not Worker Shell and not a document's own process capability: it runs where the tools are, and it is absent from the workflow Agent's capabilities and from every generated-XMD write table.
+
+It is a fail-fast pipeline. Commands run in authored order and the first one that does not exit with status `0` is the last one that runs, because a plan's evidence list is usually a pipeline and rows produced after a failed build are evaluated against prerequisites that are missing or stale. What the effect binds is therefore the executed prefix, stated as such: how it completed, how many commands were authored, and one row per command that ran, each naming its argv, how it ended, and its stdout and stderr as separate bounded channels that state their own truncation. Breadth belongs inside a command whose own contract runs a corpus to the end, or in separate elements the plan says are independent.
+
+Two host-owned ceilings bound it — one per command, one for the whole list — and a row that timed out says which fired. A timeout is an ordinary unsuccessful outcome: it records that the host enforced its ceiling, reaped the tree and captured its channels. A non-zero status is likewise evidence rather than an infrastructure failure, since it is the answer the effect exists to obtain.
+
+Being unable to say what happened is the failure. A launch the ceiling refused, a channel the host could not read, and a child it could not reap each fail the effect and bind no result, so no prefix is ever mistaken for an answer. Cancellation wins over everything and commits neither completion nor failure; otherwise the first infrastructure failure is authoritative and a teardown failure after it is retained as secondary evidence, while a teardown failure with nothing before it is authoritative on its own — a host that cannot prove its process ownership settled cannot publish a success, on the same terms lifecycle settlement applies. A failed effect still retains bounded diagnostic evidence on its error: the safely collected prefix, the bounded channels, and the primary and secondary failure categories. No successful binding is not the same as no retained evidence, and replaying a failed effect starts no process.
+
+### Terminal settlement follows its projections
+
+An outcome whose completion requires external projections retains the decision first and settles last: the accepted decision is journaled before any effect is attempted, the required projections are separate reconciled steps, and only after all of them complete is the terminal run state published.
+
+There are two terminal paths, and they do not share a step list. Reading one general sequence for both would require merge effects during an abandonment, or pull-request closure during a merge.
+
+**The merged path** retains the authenticated exact-revision merge decision, then constructs the trusted merge commit, publishes the target, retains the merged pull-request observation, closes the issue as completed, projects the Project item to its closed option, and publishes terminal kind `merged` carrying the actor, the exact revision, the merge commit, the resulting provider identities and the retained history.
+
+**The abandoned path** retains the authenticated exact-revision abandonment decision together with its required reason, then closes the pull request unmerged, closes the issue as not planned, projects the Project item to its closed option, and publishes terminal kind `abandoned` carrying the actor, the exact revision, the reason, the resulting provider identities and the retained history. It constructs no merge and publishes no target — there is nothing it reviewed that it is publishing.
+
+A third decision is not terminal at all. A change decision names the earliest stage it invalidates and a reason, and returns the run to that stage; it settles nothing and projects no closure.
+
+Every step on either path is a separate reconciled external effect or a separate retained transition, and no distributed transaction is claimed across the run's storage, native Git and processes, and the external services. An interruption resumes at the first uncommitted or unreconciled step. Terminal settlement is last on both paths for one reason: a completed run replays without contacting a provider, so a terminal state published before its projections would leave the repair to exactly the replay that is forbidden to reach a provider.
+
Every committed journal event references the current logical Workspace root.
Only committed event boundaries are checkpoints. A history fork copies the
selected root and the roots the inherited prefix names into the new run, replays
@@ -3116,6 +3295,51 @@ or partial continuation they run and record through the ordinary durable
protocol — an effect an earlier preparation already completed is restored from
its retained record rather than performed again.
+### A split trusted host
+
+A factory run has one trusted host in two pieces, and which piece owns what is
+the whole of its security boundary.
+
+The **provider host** is the Cloudflare runtime-named entrypoint. It owns
+persistence and transactions, the authenticated intake receiver, the
+authorization gates, token minting, and executor admission. On GitHub that
+receiver is one dedicated GitHub App: it verifies a webhook signature before it
+parses the payload as anything but bytes,
+rereads the complete provider objects through the API rather than trusting the
+payload's copy of them, authenticates the installation and the human actor, and
+only then retains one bounded intake keyed by the provider's delivery or
+submission identity, and mints the short-lived installation token every external
+effect is performed with. It admits a runner session — the OIDC client the
+Actions job authenticates as — only after validating that session's claims:
+issuer, configured audience, repository ID, repository-owner ID, event name,
+workflow ref and SHA, and the configured immutable workflow identity. Names are
+mutable and IDs are not, which is why the check is on IDs.
+
+The **runner host** is the ephemeral Actions job. It owns the native clients:
+Git, the plan-evidence processes, and the Agent. It holds no durable authority
+at all; what it holds is one authenticated executor connection and one
+materialized Workspace root, and every mutation it proposes is validated and
+published by the provider host.
+
+Credentials stay with the piece that mints them. The application private key,
+the webhook secret, the OIDC verification configuration, every issued
+installation token, the provider endpoints, the raw payloads, the pagination
+cursors and the host paths are provider-host secrets and closure state. None of
+them reaches props, context composition data, a durable request or result, a
+comment, document output, or a diagnostic. A short-lived installation token
+performs the external effects; the journal retains the human actor separately
+from the token that acted, so the record says who decided as well as what was
+done.
+
+Ceilings narrow in one direction. The installation is limited to configured
+repositories, and the host narrows further per operation to the exact
+repository, branch, target ref, project, field, option, subject, reviewed
+revision, parent pair and non-force operation. A path the granted permission
+could reach but the contract excludes — a workflow definition under
+`.github/workflows/**` is the one that matters, since rewriting it would rewrite
+the run's own authorization — is refused by the host rather than left to the
+permission model.
+
### The weak journal-provenance association
Journal provenance is the one further exception, and it is deliberately narrow.
@@ -3259,6 +3483,7 @@ Status is measured against main.
| workflow component bundle | a workflow root declares a closed set of authored Markdown components; the V1 workflow definition optionally carries them as one array sorted by component name, each entry holding the name, its canonical repository-relative path inside the pinned commit and that blob's object ID, and an absent member identifies a run closed over no components — so a definition retained before the member existed reads unchanged. `start` and `resume` read every component from the definition's own pinned commit; the array takes part in definition identity and is compared as part of the same V1 descriptor in compatible reuse; and canonical core resolves those names and holds both live import and retained history to that exact bundle | built on the #301 stack; the full adversarial implementation loop and its scheduling remain unbuilt (#300), and generated XMD admits no bundled Markdown component (#369) |
| `workflowInstallation()` / `getWorkflowRun()` | associates one document execution with a workflow run, through an `ExecutionInstallation` the trusted host passes to `executeInstalled()` | built on the #366 stack |
| `retainedWorkflowInstallation()` | associates one document execution with a run storage already created, requiring exact journal agreement | built on the #366 stack |
+| `retainedReplay()` | assembles what a completed run replays on from the owner's committed state alone — the root document its own root import retained, and the admissions that hold the retained history to the run's immutable definition and its component bundle, each component's bytes authenticated by Git blob identity against the object id the definition holds. It reaches no repository, working tree, provider or live import, and a history it cannot read refuses before anything is replayed from it | built on the #698 stack, both providers |
| `Git.revParse()` | verifies and resolves one Git revision expression contextually | built on main |
| 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 |
| caller-owned storage transaction | publishes several changes, including journal events, in one transaction nothing else enlists in | built on main |
@@ -3287,11 +3512,11 @@ Status is measured against main.
| `Git.Push` | publishes the selected checkout's exact current named branch and commit to the same branch on the retained Repository's canonical `origin`, reconciled through the shared Git-host state machine rather than through a Workspace transaction: no props and no component result, no force, no upstream mutation and no implicit staging or committing; the durable request and record carry the Repository's filtered identity without its checkout path, and the transport runs in a provider-owned isolated control repository reading the checkout's objects through an object-source attachment whose alternates chain and object tree are proven contained before the first remote observation, aimed at the exact private retained locator. A destination proven absent is published to once and one already naming this exact commit is adopted; one naming a distinct commit that same authenticated source proves is in this commit's ancestry is a performable pre-state, published over by the same exact non-force refspec and retained as the predecessor with the attested relation, while a divergent commit and one the source cannot read are both conflicts and nothing is fetched to decide either; a completed Push is reconstructed from the Workspace root its own journal event was appended against, read without publishing it or moving the run's frontier, so a branch published more than once resumes | built on the #370 stack, Deno provider only |
| `` | upserts one pull request of the selected checkout's current named branch, reconciled through the shared Git-host state machine: a required `title`, an optional positive-integer `number`, an optional `base` defaulting to the Repository's retained initial branch, an optional `draft`, and the rendered content as the body; it renders nothing and returns stable evidence through `as` — the filtered Repository identity, the provider's own stable pull-request identity, number, URL, open state, and the head and base SHAs of the snapshot it finished at. Without a number it creates one pull request for the head/base pair or adopts the compatible one an interrupted attempt left; with a number it brings that exact pull request's title, body, draft state and base to what the request says, records a no-op when they already match, and refuses a number belonging to another repository, opened from another head, or no longer open. It never pushes, never rewrites a head, and never reopens, merges or comments. The run must already hold its own successful `Git.Push` result for that exact Repository identity, head branch, destination ref and commit — proven by a scan of the whole successful history that requires each relevant record's natural key, inputs and result to describe one publication; a branch is published repeatedly, so the whole history is read in order and the run's last publication of that branch decides — an earlier one behind it is history rather than disagreement, while a last one naming another commit is the branch having moved on; that is conflicting, no relevant record at all is missing, and a relevant record that cannot be read whole is unreadable, each failing locally before the Git host is observed; the first adapter works over `github.com` on REST plus the two GraphQL draft transitions, selected from the private retained locator, credentialed from `GH_TOKEN`, then `GITHUB_TOKEN`, then the machine's own `gh` login, issuing each required mutation at most once per attempt and deciding the outcome by one observation, with the locator, endpoint, credential and payload confined to the per-invocation provider closure | built on the #295 stack, Deno provider only |
| `` | asks one of two questions, decided by its own shape, through a boundary of its own rather than the Git host's. Self-closing with `url` reads that issue and binds `{ url, title, description, tags, assignee }`; paired with `title` upserts and binds exactly `{ url }`, its rendered content being the description. There is no `description` prop. Props are exactly `url`, `title`, optional `tags`, optional `assignee` and — on a read only — optional `provider`; no repository/token/label/milestone/project/comment/close or approval prop. Both forms render nothing. The form is decided before the tracker is read, before any provider is asked and before an `issue_effect` record exists, and that is where a mixed `url`+`title`, a read carrying content or `tags`/`assignee`, an upsert with no content, an upsert naming a `provider`, and an element that is neither are all refused. A read needs no tracker — its URL is the identity; an upsert requires the nearest lexical `` and takes its discriminator only from there. The tracker carries a credential-free `url` and an optional `provider`; the URL is canonicalized — a credential, a query and a fragment are refused rather than stripped — and a nested tracker replaces the whole value for its descendants, never merging members, with the enclosing one restored on leaving. It is composition data, not authority: the provider holds an adapter-private ceiling beside its credentials, admitted before it connects, so a target outside it sends nothing. One stable contextual operation, `executablemd.workflow.issue`, with `read(url, options)` and `upsert(issue, options)`; a provider is ordinary middleware around it, matching its own URLs without a discriminator and only its own name with one, independently per member, with no host-side resolution. Once middleware matches it owns the answer — it never delegates afterwards, and nothing catches its refusal to try somebody else — and a request everyone delegated reaches `NoIssueProvider` unchanged. `issue_effect` records an operation discriminator with the normalized request and result; both forms replay without reaching `IssueApi` and therefore without network access; only an upsert derives an idempotency key, from the operation, the canonical target and the run's own effect identity. Retention excludes credentials, endpoints, payloads, provider identities, origin markers and host paths. Observing, adopting, creating once and recovering an interrupted creation are the provider's, because they are knowledge about what a service can prove; title is never identity, and tags are a code-point-sorted set. The Deno workflow host installs configured GitHub middleware and installs none otherwise, so absence of configuration is fail-closed | built on the #296 stack; GitHub middleware, Deno host |
-| workflow lifecycle inspection and control | reads status/list/history without advancing a run, recovering a private copy when a crashed source needs rollback; enforces the executor lock, refuses live cancellation, cancels non-live runs under that lock and deletes retained state | direct read-only inspection and control built on the #367 stack; coordinated recovered inspection built on the #513 stack, Deno provider only |
+| workflow lifecycle inspection and control | reads status/list/history without advancing a run, recovering a private copy when a crashed source needs rollback; enforces the executor lock, refuses live cancellation, cancels non-live runs under that lock and deletes retained state | direct read-only inspection and control built on the #367 stack; coordinated recovered inspection built on the #513 stack, Deno provider only; the durable owner answers the same status, list and history questions from one committed reading, taking no acquisition and appending nothing |
| XMD artifact export, inspection and fork source | seals one run's committed retained state, Workspace roots and workflow definition source closure into one immutable `.xmd` evidence file; opens that file read-only for status/history and admits continuation only by creating a new history fork whose lineage names the artifact identity | specified by `specs/xmd-artifact-spec.md`; the version-1 sealed container, its total read-only verifier, `xmd workflow export` and artifact `status`/`history` are built, Deno provider only — the artifact-source fork remains unbuilt. Inspection is two sibling lifecycle operations, `inspectArtifact()` and `historyArtifact()`, taking a path rather than a run id: a run id names live lifecycle authority and a path names immutable evidence, so neither is a mode of the other. They reach no run store, lock, Workspace, definition reader or external provider, and the artifact path never enters the structural answer |
| Agent session portability evidence in an XMD artifact | classifies every logical Agent session that contributed a retained Prompt as portable — with ordered provider checkpoint tokens and an opaque Agent session bundle — or as explicitly unavailable, as two content kinds inside the existing version-1 manifest and identity | specified by `specs/xmd-artifact-spec.md` §2.5; the closed union, both content kinds and the complete post-identity profile verifier are built on the #621 stack, Deno provider only. Provider bundle capture, Agent-aware export, intrinsic Agent-aware inspection and artifact-backed fork are unbuilt |
| historical authored source | retains an authored durable operation's normalized `SourcePosition` beside its identity, and history parses it or refuses the entry | built on the #367 stack |
-| history fork | creates a new run from one compatible checkpoint and retained Workspace root, under a new immutable definition and normalized props | built on the #368 stack, Deno provider only |
+| history fork | creates a new run from one compatible checkpoint and retained Workspace root, under a new immutable definition and normalized props | built on the #368 stack; both providers — the durable owner copies the selected prefix and roots into a destination that commits whole or not at all, and neither side is mutated when the fork is refused |
| workflow Agent session | a workflow document's `` runs under a profile the host attaches only for a live or partial run: an empty host-owned working directory instead of any Workspace, checkout or caller path, no MCP servers, an empty requested native tool set, and `deny-all` with a permission path that denies every native request and fails the turn that asked without reaching the public permission chain. Within a run a session is identified by the Agent/Session expansion identity the engine derived — the authored name is descriptive, so two sibling `` elements are two sessions — routed inside a placement bound to its element and good for one use, so a kept placement cannot be substituted for the next. The conversation is retained as a row in the run's own database with the provider, resolved agent command and policy fingerprint beside it as compatibility attributes. The order is provider creation, the provider's canonical tagged assertion, the mapping commit, then the first Prompt; occupancy of a provider key is not an assertion, the pre-commit window reconciles only from exactly one, and a missing, conflicting, replaced or ambiguous assertion is one explicit refusal that starts no replacement. Deleting a run removes the row with the run and the provider-session directory beside it, and reports the categories. The profile selects ACP-only capability explicitly — no native-launch advertisement and no client-native attachment advertisement — rather than inheriting the provider package's ordinary-run sets by omission, and it supplies no machine session coordinator, construction-route store or executable observer: a workflow session belongs to a run, and the machine-wide account describes a different thing entirely | built on the #302 stack, with the explicit ACP-only selection from #561; the portable proof that an adapter honours an empty tool set is tracked by #496 and does not widen the ceiling |
| generated-XMD admission | admits one Agent-generated fragment through the trusted-host seam: host policy is a `read` table and a `write` table of exact pinned identities, each carrying the authored forms it is admitted for, and an authored `allow` selects a canonical non-empty subset of the closed classes — omitted means `read`. The complete source is preflighted inside one `generated_xmd` durable effect before its first generated effect; only the pinned identity the selected classes hold for that name **and** that form executes; and the admitted source, class selection, selected root, every selected entry with its forms, the identity and form of each element named, and the normalized request policy are retained in that effect's own result — so a continuation restores the decision without reading the current candidate and expands only the retained source. The roots are an as-of-admission retained basis checked by membership — the run's own later root publications and an advanced retained current root pass, while a lost admission root or lost selected root refuses — and every non-root term is checked exactly, refusing a run whose classes, identities, forms or requests have moved. The admission and every nested generated effect are offered inline by the owning expansion in authored order, so a partial continuation restores each completed one without another live execution. Each admitted effect is retained by its own ordinary record, and a read's value is collected while a mutation's is not | built on the #369 stack, continuation basis amended by #589; core owns the mechanics and the workflow policy wrapper is internal |
| `` | the workflow host's component an authored document writes where an observation should happen. The host does not register it: it **declares** it to the execution through `ExecutionInstallation.components`, captured before any installation runs, and canonical execution calls its factory once for that attachment with the claimant it minted and registers what comes back. Registration provides availability only — a name a trusted document may write — and carries none of the authority. Its schema is closed on one required `source` string and one optional `allow` array selecting a non-empty duplicate-free subset of the closed effect classes `read` and `write` — omitted means `read` — and paired content is refused. It declares no `returns` and answers with a detached value — `{ observations: [{ name, value }], output }`, each admitted read's own returned value under the name the fragment invoked it by, in invocation order, with whatever the fragment rendered under `output` rather than instead of them, and the pinned identity that produced one left in the retained admission rather than copied here — so an admitted ``, which renders nothing at all, still reaches the document. An admitted mutation contributes no entry and no receipt, so a write-only fragment binds `{ observations: [], output: "" }`; `as` is valid for every selection and binds that same shape. An ordinary `as` captures that value by reference, and an authored `` renders it into the next ``: deciding how a value becomes text is the document's. Every ceiling comes from values the host captured at installation — the run's retained roots and its authoritative current root read from the run's own storage at invocation, as-of-admission provenance a continuation holds by membership so the run's own later publications and an advanced retained current root invalidate nothing, core's pinned self-closing `` read, the write table of core's paired ``, this package's lexical `` built from the definition the ordinary registration owns, and core's self-closing ``, and `` only when the captured request ceiling is non-empty — and no prop, binding, context or middleware return value supplies or widens one. `allow` selects among those tables and adds nothing to them; approval, when a workflow needs one, is authored control flow before the element. Its durable operation is named through that claimant, on the exact invocation the engine handed it and in that invocation's own frame — not from a context a document could rebind, a contextual Api answer, a definition, or a registry answer. Generated source never resolves through the registration: the evaluator consults only its own closed table of pinned identities. It is deliberately not wrapped in `printErrors`, so a refused fragment stops the authored loop rather than becoming text the next turn could read as a read that happened | built on the #302 stack, extended by #369 |
@@ -3303,6 +3528,19 @@ Status is measured against main.
| `xmd workflow answer ` | retains one schema-validated value for one retained wait, taking no executor lock and changing no run state | built by #300 |
| `suspension_answer` durable effect | ends a wait from retained delivery state, publishing the answer and consuming that state in one transaction | built by #300 |
| `` · `` · `` | read the reviews, comments and checks a Git host already holds for one numbered pull request, completely or not at all | built by #576 |
+| `` | adds one comment to the issue a canonical URL names, from the paired content it renders, reconciled as an Issue-provider effect whose natural key is that URL plus the engine-derived effect identity — the body is presentation, so an edited sentence is the same comment | specified by #710; implementation unbuilt |
+| `` | adds one comment to the pull request a canonical URL names, from the paired content it renders, reconciled as a Git-host effect keyed by that URL plus the engine-derived effect identity | specified by #710; implementation unbuilt |
+| `` | publishes one item's status to a Project provider — a boundary of its own, because a board owns neither a repository nor an issue collection — keyed by the exact item and field, against the option the item currently holds, inside the host's configured project, field and option ceiling | specified by #710; implementation unbuilt |
+| `` · `` | take one pull request out of draft, and close one unmerged, as Git-host effects keyed by their exact subject; readiness is authorized by an accepted review outcome rather than by observing the pull request | specified by #710; implementation unbuilt |
+| `` | closes one issue as `completed` or `not_planned`, as an Issue-provider effect keyed by its exact subject, with the reason a closed enum that must match the retained terminal intent | specified by #710; implementation unbuilt |
+| `` | merges two exact commits inside the retained Workspace, observing and fixing both parents and the merge base first; a clean result publishes commit, root and filtered result in one effect transaction, and a conflicted one restores the pre-merge root and publishes normalized conflict evidence. The parent order is the caller's — synchronizing a target into an implementation and publishing an implementation onto a target are opposite operations — and `purpose` authorizes it rather than merely recording it: the provider-authenticated merge ceiling supplies the pair each purpose may carry, and a swapped, stale or cross-purpose parent refuses before any Git mutation | specified by #710; implementation unbuilt |
+| `` | updates a protected target ref by compare-and-swap: one non-force update after observing the target equal to the expected commit, adoption of a target already equal to the exact source commit, and refusal of everything else without mutation; remote, ref, credential and non-force policy are host-owned. Not a spelling of `Git.Push`, which advances a branch this run published from a proved ancestry relation | specified by #710; implementation unbuilt |
+| `` | runs an authored structured argv list natively on the trusted runner against one exact retained Workspace root, under host-owned executable, environment, working-root, per-command duration, whole-run duration, output and process-tree ceilings; a fail-fast pipeline that stops at the first command not exiting `0` and binds the executed prefix — how it completed, how many commands were authored, and one row per command that ran carrying argv, how it ended, which ceiling fired on a timeout, and separately bounded stdout and stderr that state their own truncation; a launch, output-pump or teardown failure binds no result while retaining bounded error evidence, and cancellation commits nothing; absent from the workflow Agent's capabilities and from every generated-XMD write table, and a completed replay runs nothing | specified by #710; implementation unbuilt |
+| `` | observes that a Git host now records one pull request as merged, at the exact commit a target publication published; a reconciled Git-host observation that mutates nothing, whose only completion is adoption, keyed by the canonical pull-request URL — a merge at another commit conflicts, a pull request still open is temporary unavailability the run waits out under a bounded retry and then a durable machine wait, one closed unmerged conflicts, and publication does not imply any of it | specified by #710; implementation unbuilt |
+| remote `WorkflowHost` implementation | keeps the existing four-method host boundary — `useRunHost()`, `useLifecycle()`, `useDelivery()`, `attach()` — and adds a Cloudflare runtime-named implementation of it beside the Deno one; start, lookup, execute, deliver and inspect stay lifecycle operations reached through those four rather than becoming method names, and a remote host receives no transitions type of its own. One SQLite-backed Durable Object per run is selected from the public run ID, and executor acquisition is an authenticated connection lifetime | built: one SQLite-backed Durable Object per run owns create/lookup, coherent reads, lifecycle transitions and settlement, stale recovery, fork and staging, Workspace publication, delivery and consumption, inspection and history, and completed replay, reached over three request planes and one gateway that routes by run id; one configured client — run id, credential-free endpoint, release identity, per-request token, host I/O — is bound to one owner, and trusted code assembles it into the same four methods with an attachment bound by identity to the handle its own lifecycle opened. Unbuilt: any selector or ambient source that would choose this host, and any deployment |
+| provider-neutral lifecycle transition types | `WorkflowExecutionTransitions`, `WorkflowBeginRequest`, `WorkflowExecutionBegun`, `WorkflowForkRequest`, `WorkflowForkSelection` and `WorkflowRunCreation` describe what any host's lifecycle does rather than what one adapter retains, and become package-root public types; the Deno entrypoint may re-export them for source compatibility without owning their meaning, while runtime-specific implementations and retained encodings stay behind their runtime-named entrypoints | built: neutrality settled by #710, and the types are package-root public types re-exported by the Deno entrypoint for source compatibility |
+| same-release runner transport | the messages between an ephemeral runner client and its durable owner are private to one software-factory release: not journaled, exported, authored or supported across independently versioned builds. Connection admission validates an exact immutable build or protocol fingerprint from trusted deployment configuration and refuses a mismatch closed, before parsing, acquisition or state access; there is no cross-version adaptation or downgrade. Privacy of the transport is not privacy of the authority — the acquisition, expected-root validation, owner-side parsing and transactions, content validation, separate no-acquisition delivery and inspection paths, and provider-free completed replay stay public and exact | built: admission validates the exact release identity and refuses a mismatch closed before parsing, acquisition or state access, and the private paths, header names, commands and refusal spellings are named in no public type, journal record, export or documented protocol — one refusal category crosses, the one that says another live executor holds the run. Which release identity a deployment supplies is deployment configuration, and no deployment exists |
+| factory protocol records | the closed versioned schemas `specs/github-actions-software-factory-spec.md` §11.2 defines — and normatively owns, every other document linking to it rather than restating it — for the subject, stage, implementation revision, handoff, actor, role outcome, invalidation, evidence reference, Planner and Architect verdicts, conflict suspension, Stage 7 decision, merged-observation wait, stage-to-option table, active frontier and the two terminal settlements one issue-driven run retains. Each carries a schema discriminant and a version, and an unknown schema, version, member or enum value refuses rather than being ignored — provider-neutral durable protocol, neither an XMD component nor a TypeScript lifecycle controller | specified by #710; implementation unbuilt |
| workflow scheduling (watchers, unattended iteration, remote host selection) | — | #300 |
| `` | binds `{ok: true, value}` or `{ok: false, error}`; a failure becomes a bound value, not a raise | defined, unbuilt |
| error middleware (JS api) | retry · suspend · decline | defined, unbuilt |
diff --git a/deno.json b/deno.json
index 97508df8b..70563ebed 100644
--- a/deno.json
+++ b/deno.json
@@ -1,5 +1,8 @@
{
- "workspace": ["packages/*", "site"],
+ "workspace": [
+ "packages/*",
+ "site"
+ ],
"exclude": [
"scripts/tests/fixtures",
".xmd-eval",
@@ -7,7 +10,12 @@
"packages/workflow/vendor/cloudflare-computer-dofs/upstream",
"packages/workflow/vendor/cloudflare-computer-dofs/generated/**/*.d.ts",
"packages/acp/vendor/acpx/upstream",
- "packages/acp/vendor/acpx/generated/**/*.d.ts"
+ "packages/acp/vendor/acpx/generated/**/*.d.ts",
+ "packages/workflow/src/cloudflare",
+ "packages/workflow/tests/cloudflare",
+ "vitest.config.ts",
+ "packages/workflow/tsconfig.cloudflare.json",
+ "packages/workflow/cloudflare.ts"
],
"nodeModulesDir": "auto",
"lock": {
@@ -71,6 +79,8 @@
"review:local": "deno run --allow-all packages/cli/src/deno.ts run .reviews/ReviewPR.local.md --component-dir .reviews/components --component-dir .reviews/policies --component-dir packages/core/components -j .reviews/journal.local.jsonl",
"analyze": "deno run --allow-all packages/cli/src/deno.ts run .reviews/AnalyzeRepo.md --component-dir .reviews/components --component-dir .reviews/policies --component-dir packages/core/components -j .reviews/journal.analyze.jsonl",
"analyze:ci": "deno run --allow-all packages/cli/src/deno.ts run .reviews/AnalyzeRepoCI.md --component-dir .reviews/components --component-dir .reviews/policies --component-dir packages/core/components -j .reviews/journal.analyze.ci.jsonl",
- "analyze:dispatch": "deno run --allow-all packages/cli/src/deno.ts run .reviews/DispatchRepoAnalysis.md --component-dir .reviews/components --component-dir .reviews/policies --component-dir packages/core/components -j .reviews/journal.dispatch.jsonl"
+ "analyze:dispatch": "deno run --allow-all packages/cli/src/deno.ts run .reviews/DispatchRepoAnalysis.md --component-dir .reviews/components --component-dir .reviews/policies --component-dir packages/core/components -j .reviews/journal.dispatch.jsonl",
+ "test:cloudflare": "pnpm test:cloudflare",
+ "check:cloudflare": "pnpm check:cloudflare"
}
}
diff --git a/deno.lock b/deno.lock
index b255615bd..12113f719 100644
--- a/deno.lock
+++ b/deno.lock
@@ -41,6 +41,9 @@
"npm:@agentclientprotocol/sdk@1.3.0": "1.3.0_zod@4.4.3",
"npm:@babel/core@^7.28.0": "7.29.7",
"npm:@babel/preset-react@^7.27.1": "7.29.7_@babel+core@7.29.7",
+ "npm:@cfworker/json-schema@^4.1.1": "4.1.1",
+ "npm:@cloudflare/vitest-plugin@1.1.3": "1.1.3_@vitest+runner@4.1.11_@vitest+snapshot@4.1.11_vitest@4.1.11__@opentelemetry+api@1.9.1__@types+node@24.13.3__vite@7.3.6___@types+node@24.13.3___tsx@4.23.1_@cloudflare+workers-types@5.20260901.1",
+ "npm:@cloudflare/workers-types@^5.20260831.1": "5.20260901.1",
"npm:@durable-streams/client@~0.2.2": "0.2.6",
"npm:@durable-streams/server@~0.3.8": "0.3.8",
"npm:@effectionx/context-api@0.6.0": "0.6.0_effection@4.1.0",
@@ -74,6 +77,8 @@
"npm:@types/babel__core@^7.20.5": "7.20.5",
"npm:@types/node@22": "22.19.15",
"npm:@types/node@^24.5.2": "24.13.3",
+ "npm:@vitest/runner@4.1.11": "4.1.11",
+ "npm:@vitest/snapshot@4.1.11": "4.1.11",
"npm:acorn@^8.16.0": "8.16.0",
"npm:acpx@0.12.0": "0.12.0",
"npm:ajv@8.20.0": "8.20.0",
@@ -111,6 +116,7 @@
"npm:unist-util-select@5": "5.1.0",
"npm:vite@^7.1.3": "7.3.6_@types+node@24.13.3_tsx@4.23.1",
"npm:vite@^7.1.4": "7.3.6_@types+node@24.13.3_tsx@4.23.1",
+ "npm:vitest@4.1.11": "4.1.11_@opentelemetry+api@1.9.1_@types+node@24.13.3_vite@7.3.6__@types+node@24.13.3__tsx@4.23.1",
"npm:zod@4": "4.4.3",
"npm:zod@^4.3.6": "4.4.3"
},
@@ -312,7 +318,7 @@
"debug",
"gensync",
"json5",
- "semver"
+ "semver@6.3.1"
]
},
"@babel/generator@7.29.7": {
@@ -321,7 +327,7 @@
"@babel/parser",
"@babel/types",
"@jridgewell/gen-mapping",
- "@jridgewell/trace-mapping",
+ "@jridgewell/trace-mapping@0.3.31",
"jsesc"
]
},
@@ -338,7 +344,7 @@
"@babel/helper-validator-option",
"browserslist",
"lru-cache",
- "semver"
+ "semver@6.3.1"
]
},
"@babel/helper-globals@7.29.7": {
@@ -465,6 +471,9 @@
"@babel/helper-validator-identifier"
]
},
+ "@cfworker/json-schema@4.1.1": {
+ "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="
+ },
"@clack/core@1.4.3": {
"integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==",
"dependencies": [
@@ -481,9 +490,69 @@
"sisteransi"
]
},
+ "@cloudflare/kv-asset-handler@0.5.0": {
+ "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="
+ },
+ "@cloudflare/unenv-preset@2.16.1_unenv@2.0.0-rc.24_workerd@1.20260831.1": {
+ "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
+ "dependencies": [
+ "unenv",
+ "workerd"
+ ],
+ "optionalPeers": [
+ "workerd"
+ ]
+ },
+ "@cloudflare/vitest-plugin@1.1.3_@vitest+runner@4.1.11_@vitest+snapshot@4.1.11_vitest@4.1.11__@opentelemetry+api@1.9.1__@types+node@24.13.3__vite@7.3.6___@types+node@24.13.3___tsx@4.23.1_@cloudflare+workers-types@5.20260901.1": {
+ "integrity": "sha512-ED1Rkaq5Wr5rCeHXpLoDyV4WGJzD0Ju0clM8jS7Hj+wjj/CwaMHeb8DXzUfUQPiHW9rTgwcttPPQnzau2kp6Jg==",
+ "dependencies": [
+ "@vitest/runner",
+ "@vitest/snapshot",
+ "cjs-module-lexer",
+ "esbuild@0.28.1",
+ "miniflare",
+ "vitest",
+ "wrangler",
+ "zod"
+ ]
+ },
+ "@cloudflare/workerd-darwin-64@1.20260831.1": {
+ "integrity": "sha512-oyZ8xhu+gYTvoxV/sn6NRmTHK95RhEO1Dk54/6oPb0Uu70w7ZeRoCjkJ5aNmfS8Vrkdu6+oL0HNg6EcC61uQ2Q==",
+ "os": ["darwin"],
+ "cpu": ["x64"]
+ },
+ "@cloudflare/workerd-darwin-arm64@1.20260831.1": {
+ "integrity": "sha512-s6Go53KPnoXZ1sTGBZ3en3otfHDuMPJhiwXMYWU21JkJQkpoeRt6HFUwM0GPhK3YhXWm+8baGMvCGZYS/KA9eA==",
+ "os": ["darwin"],
+ "cpu": ["arm64"]
+ },
+ "@cloudflare/workerd-linux-64@1.20260831.1": {
+ "integrity": "sha512-WxNKBgjKgeYTolW3yl1Lt3Lu67UlxdeyzWYi9MIqrKBdyQcz+UNG36RevSBf8rv1sTWapRW234VX2keZ+wXapA==",
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@cloudflare/workerd-linux-arm64@1.20260831.1": {
+ "integrity": "sha512-JTF9+9clUT3gaCq7Xnmd+Q/wEMaitpngSTOec/Ffb/r3xexA9XwNJVFSOKfk6q61flHGjAYJ4H9B7Mu5Qur49w==",
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@cloudflare/workerd-windows-64@1.20260831.1": {
+ "integrity": "sha512-do+KDYw0PABwsrKUQIccWBZB70kqKcADoSnvzJ8pvMaWUVB4qaCspEZYfm97WNdtY1wt8mlKYqIJyYUNOkTvQg==",
+ "os": ["win32"],
+ "cpu": ["x64"]
+ },
+ "@cloudflare/workers-types@5.20260901.1": {
+ "integrity": "sha512-m1rNbR3UYC1pgaEyXkSlwLFLDB11QziYUlY0z/nqjwuYtg5dw9zBrgDudoSfYM6ebbPDKU81ogBQAzLp6h1y4w=="
+ },
"@colors/colors@1.5.0": {
"integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="
},
+ "@cspotcode/source-map-support@0.8.1": {
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dependencies": [
+ "@jridgewell/trace-mapping@0.3.9"
+ ]
+ },
"@durable-streams/client@0.2.6": {
"integrity": "sha512-uHKKbWpsKLhFMeGjG0PgM6LXE3oEIi7FHKlJZkmYGxcqd4Yjjd/QEvnQnDzteRP4Av1uJVM8qjTL7kfKsgeS/w==",
"dependencies": [
@@ -593,6 +662,12 @@
"effection"
]
},
+ "@emnapi/runtime@1.11.3": {
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "dependencies": [
+ "tslib"
+ ]
+ },
"@esbuild/aix-ppc64@0.25.12": {
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"os": ["aix"],
@@ -1016,6 +1091,174 @@
"@harperfast/extended-iterable@1.0.3": {
"integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw=="
},
+ "@img/colour@1.1.0": {
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="
+ },
+ "@img/sharp-darwin-arm64@0.35.2": {
+ "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
+ "optionalDependencies": [
+ "@img/sharp-libvips-darwin-arm64"
+ ],
+ "os": ["darwin"],
+ "cpu": ["arm64"]
+ },
+ "@img/sharp-darwin-x64@0.35.2": {
+ "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
+ "optionalDependencies": [
+ "@img/sharp-libvips-darwin-x64"
+ ],
+ "os": ["darwin"],
+ "cpu": ["x64"]
+ },
+ "@img/sharp-freebsd-wasm32@0.35.2": {
+ "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==",
+ "dependencies": [
+ "@img/sharp-wasm32"
+ ],
+ "os": ["freebsd"]
+ },
+ "@img/sharp-libvips-darwin-arm64@1.3.1": {
+ "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
+ "os": ["darwin"],
+ "cpu": ["arm64"]
+ },
+ "@img/sharp-libvips-darwin-x64@1.3.1": {
+ "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
+ "os": ["darwin"],
+ "cpu": ["x64"]
+ },
+ "@img/sharp-libvips-linux-arm64@1.3.1": {
+ "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@img/sharp-libvips-linux-arm@1.3.1": {
+ "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
+ "os": ["linux"],
+ "cpu": ["arm"]
+ },
+ "@img/sharp-libvips-linux-ppc64@1.3.1": {
+ "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
+ "os": ["linux"],
+ "cpu": ["ppc64"]
+ },
+ "@img/sharp-libvips-linux-riscv64@1.3.1": {
+ "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
+ "os": ["linux"],
+ "cpu": ["riscv64"]
+ },
+ "@img/sharp-libvips-linux-s390x@1.3.1": {
+ "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
+ "os": ["linux"],
+ "cpu": ["s390x"]
+ },
+ "@img/sharp-libvips-linux-x64@1.3.1": {
+ "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@img/sharp-libvips-linuxmusl-arm64@1.3.1": {
+ "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@img/sharp-libvips-linuxmusl-x64@1.3.1": {
+ "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@img/sharp-linux-arm64@0.35.2": {
+ "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
+ "optionalDependencies": [
+ "@img/sharp-libvips-linux-arm64"
+ ],
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@img/sharp-linux-arm@0.35.2": {
+ "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
+ "optionalDependencies": [
+ "@img/sharp-libvips-linux-arm"
+ ],
+ "os": ["linux"],
+ "cpu": ["arm"]
+ },
+ "@img/sharp-linux-ppc64@0.35.2": {
+ "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
+ "optionalDependencies": [
+ "@img/sharp-libvips-linux-ppc64"
+ ],
+ "os": ["linux"],
+ "cpu": ["ppc64"]
+ },
+ "@img/sharp-linux-riscv64@0.35.2": {
+ "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
+ "optionalDependencies": [
+ "@img/sharp-libvips-linux-riscv64"
+ ],
+ "os": ["linux"],
+ "cpu": ["riscv64"]
+ },
+ "@img/sharp-linux-s390x@0.35.2": {
+ "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
+ "optionalDependencies": [
+ "@img/sharp-libvips-linux-s390x"
+ ],
+ "os": ["linux"],
+ "cpu": ["s390x"]
+ },
+ "@img/sharp-linux-x64@0.35.2": {
+ "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
+ "optionalDependencies": [
+ "@img/sharp-libvips-linux-x64"
+ ],
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@img/sharp-linuxmusl-arm64@0.35.2": {
+ "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
+ "optionalDependencies": [
+ "@img/sharp-libvips-linuxmusl-arm64"
+ ],
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@img/sharp-linuxmusl-x64@0.35.2": {
+ "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
+ "optionalDependencies": [
+ "@img/sharp-libvips-linuxmusl-x64"
+ ],
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@img/sharp-wasm32@0.35.2": {
+ "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==",
+ "dependencies": [
+ "@emnapi/runtime"
+ ]
+ },
+ "@img/sharp-webcontainers-wasm32@0.35.2": {
+ "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==",
+ "dependencies": [
+ "@img/sharp-wasm32"
+ ],
+ "cpu": ["wasm32"]
+ },
+ "@img/sharp-win32-arm64@0.35.2": {
+ "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
+ "os": ["win32"],
+ "cpu": ["arm64"]
+ },
+ "@img/sharp-win32-ia32@0.35.2": {
+ "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
+ "os": ["win32"],
+ "cpu": ["ia32"]
+ },
+ "@img/sharp-win32-x64@0.35.2": {
+ "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
+ "os": ["win32"],
+ "cpu": ["x64"]
+ },
"@jest/diff-sequences@30.3.0": {
"integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA=="
},
@@ -1057,14 +1300,14 @@
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dependencies": [
"@jridgewell/sourcemap-codec",
- "@jridgewell/trace-mapping"
+ "@jridgewell/trace-mapping@0.3.31"
]
},
"@jridgewell/remapping@2.3.5": {
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dependencies": [
"@jridgewell/gen-mapping",
- "@jridgewell/trace-mapping"
+ "@jridgewell/trace-mapping@0.3.31"
]
},
"@jridgewell/resolve-uri@3.1.2": {
@@ -1080,6 +1323,13 @@
"@jridgewell/sourcemap-codec"
]
},
+ "@jridgewell/trace-mapping@0.3.9": {
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dependencies": [
+ "@jridgewell/resolve-uri",
+ "@jridgewell/sourcemap-codec"
+ ]
+ },
"@lmdb/lmdb-darwin-arm64@3.5.6": {
"integrity": "sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==",
"os": ["darwin"],
@@ -1374,6 +1624,23 @@
"os": ["win32"],
"cpu": ["x64"]
},
+ "@poppinss/colors@4.1.6": {
+ "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+ "dependencies": [
+ "kleur"
+ ]
+ },
+ "@poppinss/dumper@0.6.5": {
+ "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+ "dependencies": [
+ "@poppinss/colors",
+ "@sindresorhus/is@7.2.0",
+ "supports-color@10.2.2"
+ ]
+ },
+ "@poppinss/exception@1.2.3": {
+ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="
+ },
"@preact/signals-core@1.14.4": {
"integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="
},
@@ -1835,7 +2102,7 @@
"@rollup/pluginutils@4.2.1": {
"integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==",
"dependencies": [
- "estree-walker",
+ "estree-walker@2.0.2",
"picomatch@2.3.2"
]
},
@@ -1988,6 +2255,12 @@
"@sindresorhus/is@4.6.0": {
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="
},
+ "@sindresorhus/is@7.2.0": {
+ "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="
+ },
+ "@speed-highlight/core@1.2.24": {
+ "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="
+ },
"@standard-schema/spec@1.1.0": {
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
@@ -2117,12 +2390,22 @@
"@babel/types"
]
},
+ "@types/chai@5.2.3": {
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dependencies": [
+ "@types/deep-eql",
+ "assertion-error"
+ ]
+ },
"@types/debug@4.1.12": {
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
"dependencies": [
"@types/ms"
]
},
+ "@types/deep-eql@4.0.2": {
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="
+ },
"@types/estree@1.0.9": {
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="
},
@@ -2189,6 +2472,62 @@
"@ungap/structured-clone@1.3.2": {
"integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="
},
+ "@vitest/expect@4.1.11": {
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "dependencies": [
+ "@standard-schema/spec",
+ "@types/chai",
+ "@vitest/spy",
+ "@vitest/utils",
+ "chai",
+ "tinyrainbow"
+ ]
+ },
+ "@vitest/mocker@4.1.11_vite@7.3.6__@types+node@24.13.3__tsx@4.23.1": {
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "dependencies": [
+ "@vitest/spy",
+ "estree-walker@3.0.3",
+ "magic-string",
+ "vite"
+ ],
+ "optionalPeers": [
+ "vite"
+ ]
+ },
+ "@vitest/pretty-format@4.1.11": {
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "dependencies": [
+ "tinyrainbow"
+ ]
+ },
+ "@vitest/runner@4.1.11": {
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "dependencies": [
+ "@vitest/utils",
+ "pathe"
+ ]
+ },
+ "@vitest/snapshot@4.1.11": {
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "dependencies": [
+ "@vitest/pretty-format",
+ "@vitest/utils",
+ "magic-string",
+ "pathe"
+ ]
+ },
+ "@vitest/spy@4.1.11": {
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="
+ },
+ "@vitest/utils@4.1.11": {
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "dependencies": [
+ "@vitest/pretty-format",
+ "convert-source-map",
+ "tinyrainbow"
+ ]
+ },
"@x0k/json-schema-merge@1.0.4": {
"integrity": "sha512-KvmMgAftbVzATq4IRnkno/SKSu+gjaR2ZUPJG5JUlY4W3twRJo03sk2914u8scmosibBZ0m7s6euZlJuqpv8Ww==",
"dependencies": [
@@ -2264,6 +2603,9 @@
"tslib"
]
},
+ "assertion-error@2.0.1": {
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="
+ },
"b4a@1.8.1": {
"integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="
},
@@ -2308,6 +2650,9 @@
"integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==",
"bin": true
},
+ "blake3-wasm@2.1.5": {
+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="
+ },
"boolbase@1.0.0": {
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
},
@@ -2331,11 +2676,14 @@
"ccount@2.0.1": {
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="
},
+ "chai@6.2.2": {
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="
+ },
"chalk@4.1.2": {
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": [
"ansi-styles@4.3.0",
- "supports-color"
+ "supports-color@7.2.0"
]
},
"chalk@5.6.2": {
@@ -2356,6 +2704,9 @@
"ci-info@4.4.0": {
"integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="
},
+ "cjs-module-lexer@1.2.3": {
+ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="
+ },
"class-variance-authority@0.7.1": {
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"dependencies": [
@@ -2429,6 +2780,9 @@
"convert-source-map@2.0.0": {
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
+ "cookie@1.1.1": {
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="
+ },
"cross-spawn@7.0.6": {
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dependencies": [
@@ -2492,6 +2846,12 @@
"environment@1.1.0": {
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="
},
+ "error-stack-parser-es@1.0.5": {
+ "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="
+ },
+ "es-module-lexer@2.3.2": {
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="
+ },
"esbuild-wasm@0.25.12": {
"integrity": "sha512-rZqkjL3Y6FwLpSHzLnaEy8Ps6veCNo1kZa9EOfJvmWtBq5dJH4iVjfmOO6Mlkv9B0tt9WFPFmb/VxlgJOnueNg==",
"bin": true
@@ -2608,12 +2968,21 @@
"estree-walker@2.0.2": {
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
+ "estree-walker@3.0.3": {
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dependencies": [
+ "@types/estree"
+ ]
+ },
"events-universal@1.0.1": {
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"dependencies": [
"bare-events"
]
},
+ "expect-type@1.4.0": {
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="
+ },
"expect@30.3.0": {
"integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==",
"dependencies": [
@@ -2845,6 +3214,9 @@
"kind-of@6.0.3": {
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="
},
+ "kleur@4.1.5": {
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="
+ },
"lightningcss-android-arm64@1.32.0": {
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"os": ["android"],
@@ -3228,6 +3600,17 @@
"micromark-util-types"
]
},
+ "miniflare@5.20260831.0-alpha": {
+ "integrity": "sha512-Hwgh1VDUiPCPGQKODQfUmy7hRAje1D55icB+9png3ueiM64rlSM87nSrtqpxAD+DlLWI4ehnYBuECaXV43zGmQ==",
+ "dependencies": [
+ "@cspotcode/source-map-support",
+ "sharp",
+ "undici",
+ "workerd",
+ "ws",
+ "youch"
+ ]
+ },
"ms@2.1.3": {
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
@@ -3271,7 +3654,7 @@
"node-emoji@2.2.0": {
"integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==",
"dependencies": [
- "@sindresorhus/is",
+ "@sindresorhus/is@4.6.0",
"char-regex",
"emojilib",
"skin-tone"
@@ -3296,6 +3679,9 @@
"object-assign@4.1.1": {
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
},
+ "obug@2.1.4": {
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="
+ },
"ordered-binary@1.6.1": {
"integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w=="
},
@@ -3385,6 +3771,12 @@
"path-key@3.1.1": {
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
},
+ "path-to-regexp@6.3.0": {
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="
+ },
+ "pathe@2.0.3": {
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
+ },
"picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
@@ -3594,6 +3986,45 @@
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": true
},
+ "semver@7.8.5": {
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "bin": true
+ },
+ "sharp@0.35.2": {
+ "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
+ "dependencies": [
+ "@img/colour",
+ "detect-libc",
+ "semver@7.8.5"
+ ],
+ "optionalDependencies": [
+ "@img/sharp-darwin-arm64",
+ "@img/sharp-darwin-x64",
+ "@img/sharp-freebsd-wasm32",
+ "@img/sharp-libvips-darwin-arm64",
+ "@img/sharp-libvips-darwin-x64",
+ "@img/sharp-libvips-linux-arm",
+ "@img/sharp-libvips-linux-arm64",
+ "@img/sharp-libvips-linux-ppc64",
+ "@img/sharp-libvips-linux-riscv64",
+ "@img/sharp-libvips-linux-s390x",
+ "@img/sharp-libvips-linux-x64",
+ "@img/sharp-libvips-linuxmusl-arm64",
+ "@img/sharp-libvips-linuxmusl-x64",
+ "@img/sharp-linux-arm",
+ "@img/sharp-linux-arm64",
+ "@img/sharp-linux-ppc64",
+ "@img/sharp-linux-riscv64",
+ "@img/sharp-linux-s390x",
+ "@img/sharp-linux-x64",
+ "@img/sharp-linuxmusl-arm64",
+ "@img/sharp-linuxmusl-x64",
+ "@img/sharp-webcontainers-wasm32",
+ "@img/sharp-win32-arm64",
+ "@img/sharp-win32-ia32",
+ "@img/sharp-win32-x64"
+ ]
+ },
"shebang-command@2.0.0": {
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dependencies": [
@@ -3606,6 +4037,9 @@
"shellwords-ts@3.0.1": {
"integrity": "sha512-GabK4ApLMqHFRGlpgNqg8dmtHTnYHt0WUUJkIeMd3QaDrUUBEDXHSSNi3I0PzMimg8W+I0EN4TshQxsnHv1cwg=="
},
+ "siginfo@2.0.0": {
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="
+ },
"sisteransi@1.0.5": {
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="
},
@@ -3641,6 +4075,12 @@
"escape-string-regexp"
]
},
+ "stackback@0.0.2": {
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="
+ },
+ "std-env@4.2.0": {
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="
+ },
"streamx@2.28.0": {
"integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==",
"dependencies": [
@@ -3679,6 +4119,9 @@
"boundary"
]
},
+ "supports-color@10.2.2": {
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="
+ },
"supports-color@7.2.0": {
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": [
@@ -3689,7 +4132,7 @@
"integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==",
"dependencies": [
"has-flag",
- "supports-color"
+ "supports-color@7.2.0"
]
},
"tailwind-merge@3.6.0": {
@@ -3740,6 +4183,12 @@
"any-promise"
]
},
+ "tinybench@2.9.0": {
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="
+ },
+ "tinyexec@1.3.0": {
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="
+ },
"tinyglobby@0.2.17": {
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dependencies": [
@@ -3750,6 +4199,9 @@
"tinypool@2.1.0": {
"integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="
},
+ "tinyrainbow@3.1.1": {
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="
+ },
"trim-lines@3.0.1": {
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="
},
@@ -3779,6 +4231,15 @@
"undici-types@7.18.2": {
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="
},
+ "undici@7.29.0": {
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="
+ },
+ "unenv@2.0.0-rc.24": {
+ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+ "dependencies": [
+ "pathe"
+ ]
+ },
"unicode-emoji-modifier-base@1.0.0": {
"integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="
},
@@ -3900,6 +4361,38 @@
],
"bin": true
},
+ "vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@24.13.3_vite@7.3.6__@types+node@24.13.3__tsx@4.23.1": {
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "dependencies": [
+ "@opentelemetry/api",
+ "@types/node@24.13.3",
+ "@vitest/expect",
+ "@vitest/mocker",
+ "@vitest/pretty-format",
+ "@vitest/runner",
+ "@vitest/snapshot",
+ "@vitest/spy",
+ "@vitest/utils",
+ "es-module-lexer",
+ "expect-type",
+ "magic-string",
+ "obug",
+ "pathe",
+ "picomatch@4.0.5",
+ "std-env",
+ "tinybench",
+ "tinyexec",
+ "tinyglobby",
+ "tinyrainbow",
+ "vite",
+ "why-is-node-running"
+ ],
+ "optionalPeers": [
+ "@opentelemetry/api",
+ "@types/node@24.13.3"
+ ],
+ "bin": true
+ },
"weak-lru-cache@1.2.2": {
"integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw=="
},
@@ -3910,6 +4403,47 @@
],
"bin": true
},
+ "why-is-node-running@2.3.0": {
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dependencies": [
+ "siginfo",
+ "stackback"
+ ],
+ "bin": true
+ },
+ "workerd@1.20260831.1": {
+ "integrity": "sha512-A2LwrkBel/FnKABPfeBAMiL6v70+rugnunqQRfWsWZjlhsTZoBScWUVunMy/xLCGLjWCQL2zp39AVR6aO0jurQ==",
+ "optionalDependencies": [
+ "@cloudflare/workerd-darwin-64",
+ "@cloudflare/workerd-darwin-arm64",
+ "@cloudflare/workerd-linux-64",
+ "@cloudflare/workerd-linux-arm64",
+ "@cloudflare/workerd-windows-64"
+ ],
+ "scripts": true,
+ "bin": true
+ },
+ "wrangler@4.128.0_@cloudflare+workers-types@5.20260901.1": {
+ "integrity": "sha512-jNXy9e8/pbx8iqTzXPiuflnitKJZoAfEUSUUDLW87bwyeMvJ7kb3yQMSbxEcfNdfHqJW38KRcKaLljOYV4N/4w==",
+ "dependencies": [
+ "@cloudflare/kv-asset-handler",
+ "@cloudflare/unenv-preset",
+ "@cloudflare/workers-types",
+ "blake3-wasm",
+ "esbuild@0.28.1",
+ "miniflare",
+ "path-to-regexp",
+ "unenv",
+ "workerd"
+ ],
+ "optionalDependencies": [
+ "fsevents"
+ ],
+ "optionalPeers": [
+ "@cloudflare/workers-types"
+ ],
+ "bin": true
+ },
"wrap-ansi@7.0.0": {
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dependencies": [
@@ -3918,6 +4452,9 @@
"strip-ansi"
]
},
+ "ws@8.21.0": {
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="
+ },
"y18n@5.0.8": {
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="
},
@@ -3939,6 +4476,23 @@
"yargs-parser"
]
},
+ "youch-core@0.3.3": {
+ "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
+ "dependencies": [
+ "@poppinss/exception",
+ "error-stack-parser-es"
+ ]
+ },
+ "youch@4.1.0-beta.10": {
+ "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
+ "dependencies": [
+ "@poppinss/colors",
+ "@poppinss/dumper",
+ "@speed-highlight/core",
+ "cookie",
+ "youch-core"
+ ]
+ },
"zod@4.4.3": {
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="
},
@@ -3980,6 +4534,8 @@
],
"packageJson": {
"dependencies": [
+ "npm:@cloudflare/vitest-plugin@1.1.3",
+ "npm:@cloudflare/workers-types@^5.20260831.1",
"npm:@durable-streams/client@~0.2.2",
"npm:@durable-streams/server@~0.3.8",
"npm:@effectionx/context-api@0.6.0",
@@ -3994,6 +4550,8 @@
"npm:@effectionx/test-adapter@0.7.4",
"npm:@effectionx/timebox@0.4.3",
"npm:@types/node@22",
+ "npm:@vitest/runner@4.1.11",
+ "npm:@vitest/snapshot@4.1.11",
"npm:acorn@^8.16.0",
"npm:ajv@^8.17.1",
"npm:effection@4.1.0",
@@ -4010,6 +4568,7 @@
"npm:tsx@^4.19.0",
"npm:typescript@5",
"npm:unist-util-select@5",
+ "npm:vitest@4.1.11",
"npm:zod@^4.3.6"
]
},
@@ -4044,6 +4603,7 @@
},
"packages/core": {
"dependencies": [
+ "npm:@cfworker/json-schema@^4.1.1",
"npm:@effectionx/context-api@0.6.0",
"npm:@secretlint/core@13.0.4",
"npm:@secretlint/profiler@13.0.4",
@@ -4063,6 +4623,7 @@
],
"packageJson": {
"dependencies": [
+ "npm:@cfworker/json-schema@^4.1.1",
"npm:@effectionx/context-api@0.6.0",
"npm:@effectionx/converge@0.1.4",
"npm:@effectionx/fetch@0.2.1",
diff --git a/package.json b/package.json
index 4e507ca7f..c2a0f0e51 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"private": true,
"type": "module",
- "description": "executable.md — treat markdown documents as executable workflows.",
+ "description": "executable.md \u2014 treat markdown documents as executable workflows.",
"homepage": "https://executable.md",
"repository": {
"type": "git",
@@ -24,7 +24,6 @@
"packageManager": "pnpm@9.15.0",
"dependencies": {
"@durable-streams/client": "^0.2.2",
- "effection": "4.1.0",
"@effectionx/context-api": "0.6.0",
"@effectionx/converge": "0.1.4",
"@effectionx/fetch": "0.2.1",
@@ -38,17 +37,20 @@
"@effectionx/timebox": "0.4.3",
"acorn": "^8.16.0",
"ajv": "^8.17.1",
+ "effection": "4.1.0",
"gray-matter": "^4.0.3",
"magic-string": "^0.30.21",
"marked": "^17.0.4",
"marked-terminal": "^7.3.0",
+ "mdast-util-to-string": "^4",
"remark": "15",
"remend": "^1.2.2",
- "zod": "^4.3.6",
"unist-util-select": "^5",
- "mdast-util-to-string": "^4"
+ "zod": "^4.3.6"
},
"devDependencies": {
+ "@cloudflare/vitest-plugin": "1.1.3",
+ "@cloudflare/workers-types": "^5.20260831.1",
"@durable-streams/server": "^0.3.8",
"@executablemd/acp": "workspace:*",
"@executablemd/cli": "workspace:*",
@@ -61,20 +63,31 @@
"@executablemd/testing": "workspace:*",
"@executablemd/workflow": "workspace:*",
"@types/node": "^22.0.0",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
"expect": "^30.0.0",
"oxfmt": "^0.41.0",
"oxlint": "1.74.0",
"tsx": "^4.19.0",
- "typescript": "^5.0.0"
+ "typescript": "^5.0.0",
+ "vitest": "4.1.11"
},
"scripts": {
"test:node": "tsx scripts/runtime-tests.ts node",
"test:bun": "bun scripts/runtime-tests.ts bun",
"test:deno": "deno task test",
"lint": "oxlint -c .oxlintrc.json --ignore-pattern 'scripts/tests/fixtures/**' --ignore-pattern '**/npm/**' --ignore-pattern 'packages/workflow/vendor/cloudflare-computer-dofs/**' --ignore-pattern 'packages/acp/vendor/acpx/**' packages scripts .reviews/components && oxfmt --check packages scripts .reviews/components/*.ts",
- "fmt": "oxfmt --write packages scripts .reviews/components/*.ts"
+ "fmt": "oxfmt --write packages scripts .reviews/components/*.ts",
+ "test:cloudflare": "node ./node_modules/vitest/vitest.mjs run --config vitest.config.ts",
+ "check:cloudflare": "tsc -p packages/workflow/tsconfig.cloudflare.json"
},
"workspaces": [
"packages/*"
- ]
+ ],
+ "pnpm": {
+ "overrides": {
+ "tsx": "4.23.1",
+ "@cloudflare/workers-types": "5.20260831.1"
+ }
+ }
}
diff --git a/packages/cli/src/deno-workflow.ts b/packages/cli/src/deno-workflow.ts
index 4b234dd49..4e6d089d1 100644
--- a/packages/cli/src/deno-workflow.ts
+++ b/packages/cli/src/deno-workflow.ts
@@ -29,8 +29,7 @@ import {
useWorkflowRunHost,
withWorkflowWorkspace,
} from "@executablemd/workflow/deno";
-import type { WorkflowExecutionTransitions } from "@executablemd/workflow/deno";
-import type { WorkflowRunDatabase } from "@executablemd/workflow";
+import type { WorkflowExecutionTransitions, WorkflowRunDatabase } from "@executablemd/workflow";
import type { HelperAssembly } from "@executablemd/workflow/credential-helper";
import { readDefinitionSource } from "./workflow-source.ts";
import type { WorkflowHost } from "./workflow.ts";
diff --git a/packages/cli/src/remote-workflow.ts b/packages/cli/src/remote-workflow.ts
new file mode 100644
index 000000000..a223ac03c
--- /dev/null
+++ b/packages/cli/src/remote-workflow.ts
@@ -0,0 +1,190 @@
+/**
+ * The remote workflow host — one run, one owner, assembled explicitly.
+ *
+ * The local host is chosen by which entrypoint is running. This one is not
+ * chosen at all: it is constructed by trusted code that already holds the four
+ * things it needs — which run, which owner endpoint, which release both sides
+ * agreed on, and how to mint a short-lived token — and hands them in. There is
+ * no flag, no environment variable, no prop and no runtime detection that
+ * reaches it, because a host that could be selected by ambient configuration
+ * would be a host somebody could redirect.
+ *
+ * What it returns is the same `WorkflowHost` the Deno host implements, with the
+ * same four methods. `xmd workflow` asks them the same questions in the same
+ * order; the answers come from a Durable Object instead of a file.
+ *
+ * The I/O adapters are here because here is where a runtime may be named. The
+ * workflow package performs no `fetch` and constructs no `WebSocket` of its
+ * own — it is handed both, so a test supplies deterministic transports and
+ * proves the same assembly.
+ */
+
+import { ensure, resource, until, type Operation } from "effection";
+import { remoteOwnerClient, useRemoteWorkflowRunner } from "@executablemd/workflow/deno";
+import type { WorkflowWorkspaceOptions } from "@executablemd/workflow/deno";
+import type {
+ OwnerHttpRequest,
+ OwnerHttpResponse,
+ OwnerSocket,
+ OwnerTransport,
+ OwnerUpgrade,
+ OwnerUpgradeRefused,
+} from "@executablemd/workflow/deno";
+import type { WorkflowExecutionTransitions, WorkflowRunDatabase } from "@executablemd/workflow";
+import type { WorkflowHost } from "./workflow.ts";
+
+/** What trusted code supplies to reach one run on one owner. */
+export interface RemoteWorkflowConfiguration {
+ /** The already-selected public run id. Never derived here. */
+ readonly runId: string;
+ /** The credential-free owner endpoint, parsed once when this is built. */
+ readonly endpoint: string;
+ /** The exact immutable release identity this deployment admits. */
+ readonly release: string;
+ /** A fresh short-lived token for the immediate request. */
+ token(): Operation;
+ /**
+ * Where this runner assembles fork candidates.
+ *
+ * Runner-local scratch, and nothing durable lives in it. Explicit for the
+ * same reason everything else here is: a directory read from the environment
+ * is a directory somebody else can choose.
+ */
+ readonly scratchRoot: string;
+ /**
+ * The HTTP and WebSocket I/O to perform, when the platform's own will not do.
+ *
+ * Absent means this runtime's `fetch` and `WebSocket`, which is what a real
+ * runner uses. A test supplies its own and proves the same assembly against a
+ * transport it controls.
+ */
+ readonly transport?: OwnerTransport;
+ /**
+ * What a live or partial attachment installs beyond the run's own Workspace.
+ *
+ * The host-owned inputs and only those: which issue tracker this program
+ * authorizes, which pull requests a document may read, how this host
+ * assembles its credential helper, and which Agent profile it installs.
+ * There is no member for a substituted repository host, a Git-host transport
+ * or an invocation observer, because each of those is a seam through which a
+ * credential this run acquires would become visible to whoever supplied it.
+ *
+ * Explicit, like everything else here. Nothing is read from a flag, an
+ * environment variable, a document prop or a global, and an absent member
+ * keeps the capability's unconfigured behavior.
+ */
+ readonly capabilities?: WorkflowWorkspaceOptions;
+}
+
+/**
+ * Assemble one remote host for one run.
+ *
+ * Everything it installs belongs to the calling scope: the executor connection
+ * an execution acquires, the read and delivery planes, the temporary trees a
+ * Workspace mutation materializes into, and the storage handles opened along
+ * the way all end when that scope does.
+ */
+export function* useRemoteWorkflowHost(
+ configuration: RemoteWorkflowConfiguration,
+): Operation {
+ const client = remoteOwnerClient({
+ runId: configuration.runId,
+ endpoint: configuration.endpoint,
+ release: configuration.release,
+ token: () => configuration.token(),
+ transport: configuration.transport ?? platformTransport(),
+ });
+ const runner = yield* useRemoteWorkflowRunner({
+ owner: client,
+ scratchRoot: configuration.scratchRoot,
+ // Projected member by member, as the published boundary is everywhere
+ // else: a spread would carry whatever else a caller put on the object.
+ ...(configuration.capabilities === undefined
+ ? {}
+ : { capabilities: permitted(configuration.capabilities) }),
+ });
+ return {
+ useRunHost(): Operation {
+ return runner.useRunHost();
+ },
+ useLifecycle(): Operation {
+ return runner.useLifecycle();
+ },
+ useDelivery(): Operation {
+ return runner.useDelivery();
+ },
+ attach(database: WorkflowRunDatabase, operation: Operation): Operation {
+ // Only the exact handle this host's own lifecycle opened is attachable,
+ // and the runner proves that by identity rather than by comparing what
+ // the handle says about itself.
+ return runner.attach(database, operation);
+ },
+ };
+}
+
+/** The two pieces of I/O this runtime already has, named once. */
+function platformTransport(): OwnerTransport {
+ return {
+ *request(request: OwnerHttpRequest): Operation {
+ const response = yield* until(
+ fetch(request.url, {
+ method: "POST",
+ headers: { ...request.headers },
+ body: request.body,
+ }),
+ );
+ // Read to completion here, so nothing downstream holds a body that has
+ // to be drained or cancelled.
+ return { status: response.status, body: yield* until(response.text()) };
+ },
+
+ connect(upgrade: OwnerUpgrade): Operation {
+ return resource(function* (provide) {
+ const socket = new WebSocket(upgrade.url, [...upgrade.protocols]);
+ // The socket belongs to this scope from the moment it exists, so a
+ // cancellation between opening and handing it over still closes it.
+ yield* ensure(() => {
+ socket.close();
+ });
+ const settled = new Promise((resolve) => {
+ socket.addEventListener("open", () => resolve(socket), { once: true });
+ // A refused upgrade closes without ever opening. The owner answered a
+ // status and a category, and a standard client is shown neither, so
+ // what travels is that the owner could not be reached for this
+ // request — its own vocabulary reaches the planes that can carry it.
+ const refused = () => resolve({ refusal: "command:unavailable" });
+ socket.addEventListener("error", refused, { once: true });
+ socket.addEventListener("close", refused, { once: true });
+ });
+ yield* provide(yield* until(settled));
+ });
+ },
+ };
+}
+
+/**
+ * The capability inputs this host passes on, and the whole of them.
+ *
+ * Named one at a time rather than forwarded: what a trusted caller may
+ * configure is a closed list, and reading a property nobody declared is how a
+ * getter somebody else wrote gets to run.
+ */
+function permitted(options: WorkflowWorkspaceOptions): WorkflowWorkspaceOptions {
+ return {
+ ...(options.gitHubIssues === undefined ? {} : { gitHubIssues: options.gitHubIssues }),
+ ...(options.gitHubPullRequests === undefined
+ ? {}
+ : {
+ gitHubPullRequests: {
+ ...(options.gitHubPullRequests.allowed === undefined
+ ? {}
+ : { allowed: options.gitHubPullRequests.allowed }),
+ ...(options.gitHubPullRequests.endpoint === undefined
+ ? {}
+ : { endpoint: options.gitHubPullRequests.endpoint }),
+ },
+ }),
+ ...(options.helper === undefined ? {} : { helper: options.helper }),
+ ...(options.agent === undefined ? {} : { agent: options.agent }),
+ };
+}
diff --git a/packages/cli/src/workflow-fork.ts b/packages/cli/src/workflow-fork.ts
index 2d8865bf2..2c3545bd6 100644
--- a/packages/cli/src/workflow-fork.ts
+++ b/packages/cli/src/workflow-fork.ts
@@ -64,10 +64,7 @@ import {
} from "@executablemd/workflow";
import type { ForkSelection, WorkflowRun } from "@executablemd/workflow";
import type { WorkflowRunDatabase } from "@executablemd/workflow";
-import type {
- WorkflowExecutionTransitions,
- WorkflowRunCreation,
-} from "@executablemd/workflow/deno";
+import type { WorkflowExecutionTransitions, WorkflowRunCreation } from "@executablemd/workflow";
import type { EstablishedDefinition } from "./workflow-definition.ts";
import type { WorkflowExecution } from "./workflow.ts";
diff --git a/packages/cli/src/workflow.ts b/packages/cli/src/workflow.ts
index d68fb4752..eec367fa9 100644
--- a/packages/cli/src/workflow.ts
+++ b/packages/cli/src/workflow.ts
@@ -73,7 +73,8 @@ import { retainedSource, validateProps } from "@executablemd/core";
import type { PropsSchema } from "@executablemd/core";
import type { RootDocumentSource } from "@executablemd/core";
import {
- definitionComponents,
+ retainedFailureReason,
+ retainedReplay,
retainedWorkflowInstallation,
workflowBundleInstallation,
WORKFLOW_RUN_STATUSES,
@@ -82,7 +83,9 @@ import {
import type { ExecutionInstallation } from "@executablemd/core/host";
import type {
ExecutorLock,
+ RetainedReplay,
WorkflowRunDatabase,
+ WorkflowRunRecord,
WorkflowRunStatus,
WorkflowStopReason,
} from "@executablemd/workflow";
@@ -90,8 +93,12 @@ import type {
WorkflowExecutionBegun,
WorkflowExecutionTransitions,
WorkflowRunCreation,
+} from "@executablemd/workflow";
+import type {
+ SuspensionController,
+ SuspensionControllerOptions,
+ SuspensionNotice,
} from "@executablemd/workflow/deno";
-import type { SuspensionControllerOptions, SuspensionNotice } from "@executablemd/workflow/deno";
import { SUSPENSION_REQUEST } from "@executablemd/workflow";
import { describeError } from "./props.ts";
import { preflightFork } from "./workflow-fork.ts";
@@ -172,8 +179,6 @@ const EXIT_BY_STATUS: Readonly> = Object.freez
running: 1,
});
-/** A failure the host classified, rather than an exception message it retained. */
-const HOST_FAILURE_CODE = "document-execution-failed";
const HOST_INTERRUPTED_CODE = "executor-interrupted";
const HOST_ORPHANED_CODE = "executor-disappeared";
@@ -421,31 +426,24 @@ function reportStatus(status: WorkflowRunStatus): void {
report(`workflow status: ${status}`);
}
-/** Whether this journal already holds the root's terminal event. */
-function* isCompleted(stream: DurableStream): Operation {
- const events = yield* stream.readAll();
- return events.some((event) => event.type === "close" && event.coroutineId === "root");
+/** Whether one retained event is the root's terminal. */
+function isRootClose(event: DurableEvent): boolean {
+ return event.type === "close" && event.coroutineId === "root";
}
/**
* The stop reason a failure gets.
*
- * A retained event that already crossed the secret filter is preferable to a
- * code, because it says which effect failed. Anything else becomes one
- * categorical host code: the alternative is retaining an exception message
- * beside the journal that filtered it, which is history nothing has filtered.
+ * The rule is the lifecycle's own and lives beside the outcome it belongs to: a
+ * retained event that already crossed the secret filter says which effect
+ * failed, and a failure the journal holds no row for gets the one categorical
+ * code. Stale recovery reading a dead executor's journal and a retained history
+ * held to its lifecycle row reach the same rule, because a reason chosen three
+ * ways would be three explanations of one failure.
*/
function* failureReason(database: WorkflowRunDatabase): Operation {
const entries = yield* database.readJournalEntries();
- if (entries.ok) {
- for (let index = entries.value.length - 1; index >= 0; index -= 1) {
- const entry = entries.value[index];
- if (entry !== undefined && entry.event.result.status === "err") {
- return { kind: "journal", eventId: entry.eventId };
- }
- }
- }
- return { kind: "host", code: HOST_FAILURE_CODE };
+ return retainedFailureReason(entries.ok ? entries.value : []);
}
/**
@@ -953,17 +951,6 @@ export function runWorkflow(
}
const { lock: executorLock } = acquired.value;
- // A resumed run closed over a component bundle reconstructs it here: under
- // the executor lock, from the retained commit, and before the execution
- // record exists. A component that is gone, changed, or unreachable leaves
- // the run's lifecycle records exactly as they are rather than adding an
- // attempt that never began.
- const reconstructed = yield* reconstructedSources(request, runId);
- if (!reconstructed.ok) {
- report(reconstructed.error.message);
- return { exitCode: 1 };
- }
-
// One transaction: whatever the previous workflow executor left is reconciled, this
// action is admitted against what that left behind, and the execution is
// recorded — or none of it is. A fork's one transaction is its whole
@@ -985,13 +972,51 @@ export function runWorkflow(
const { database, record, execution, replay } = begun.value;
reportRun(record.runId);
- // Only now, and only because execution or replay was admitted.
- const source = yield* documentSource(start, database, reconstructed.value);
- if (!source.ok) {
- report(source.error.message);
+ // The frontier this run's owner answered with, read once and decided from.
+ // Everything below asks it the same two questions: whether a document
+ // result is already recorded, and — for a resume of a run whose retained
+ // state ended — what that result is a result of.
+ const frontier = yield* database.readJournalEntries();
+ if (!frontier.ok) {
+ report(frontier.error.message);
+ return { exitCode: 1 };
+ }
+ const completed = frontier.value.some((entry) => isRootClose(entry.event));
+
+ // A resume of a run that already ended replays what its own history holds.
+ // The lifecycle decided that, in the transaction above: it reconciled
+ // whatever the previous executor left, published the canonical terminal a
+ // retained root result implies, and answered `replay`. Nothing before this
+ // point may decide it — a run whose executor committed its document result
+ // and disappeared before settling still reads `running`, and treating that
+ // status as live is what sent a completed replay to a checkout it may not
+ // have.
+ //
+ // So the definition is fetched here or not at all, and everything below is
+ // downstream of the same answer: this host's own adapter, the suspension
+ // controller, the answer provider, the `` declaration and
+ // `host.attach()` are each work for an execution that is going to import
+ // nothing, perform nothing and append nothing.
+ //
+ // `replay` alone, and deliberately: a `start` naming a run that already
+ // ended is the same terminal reuse a `resume` of one is, and the candidate
+ // definition it carried is what proved the two runs are the same run rather
+ // than a second account of what that run did. The begin transaction has
+ // already held the supplied definition, base, props and bundle to the
+ // immutable record; what a caller established describes the request, and
+ // what the run retained describes the result.
+ const prepared = replay
+ ? retainedReplay(record, frontier.value)
+ : yield* liveDocument(record, start, database);
+ if (!prepared.ok) {
+ report(prepared.error.message);
return { exitCode: 1 };
}
+ // Nothing beyond this point loads for a completed replay: no controller, no
+ // answer provider, and no import of the adapter either one comes from.
+ const support = replay ? undefined : yield* liveSupport(database);
+
// 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`. The executor
@@ -1001,19 +1026,6 @@ export function runWorkflow(
// "this invocation is durably settled" are different facts, and collapsing
// them is how a post-execution storage refusal would be republished as an
// interruption. Teardown speaks only while the phase is still `running`.
- // Imported where it is used rather than at the top of this module. This
- // file is on the ordinary `xmd run` path too, and the Deno workflow adapter
- // reaches `node:sqlite` — which Node greets with an experimental warning on
- // standard error the moment it loads. A run that opens no workflow storage
- // should not be announcing that it might have.
- // `evaluationComponents` comes through the same import, and for the same
- // reason: `` is the workflow host's component, and a run that
- // opens no workflow storage must not load the adapter that reaches
- // `node:sqlite` — which Bun does not have at all.
- const { createSuspensionController, evaluationComponents } = yield* until(
- import("@executablemd/workflow/deno"),
- );
- const suspension = createSuspensionController({ database });
const phase: LifecyclePhase = { state: "running" };
yield* ensure(function* () {
if (phase.state !== "running") {
@@ -1040,35 +1052,23 @@ export function runWorkflow(
reportStatus("interrupted");
});
- const completed = yield* isCompleted(database.journal);
const documentExecution: WorkflowExecution = {
- root: retainedSource(record.definition.rootDocumentPath, source.value.source),
+ root: prepared.value.root,
props: record.props,
stream: database.journal,
// The run already exists: the begin transition created or found it before
- // anything executed, so this installation records exactly that value,
- // allocates nothing and never consults Git. Service denial is installed
- // beside it, through the same host-service slot `xmd run` fills with a
+ // anything executed, so these installations record exactly that value,
+ // allocate nothing and never consult Git. Service denial is installed
+ // beside them, through the same host-service slot `xmd run` fills with a
// real adapter.
installations: [
- retainedWorkflowInstallation({
- runId: record.runId,
- base: record.base,
- pinnedCommit: record.definition.objectId,
- }),
- // The bundle this run is a run of, when it is a run of one. Both start
- // and resume install it, and a completed replay installs it too: the
- // retained history is held to the same components before its recorded
- // output is accepted.
- ...(source.value.components.length === 0
- ? []
- : [workflowBundleInstallation(source.value.components)]),
+ ...prepared.value.installations,
// `` names durable work after its own invocation, so this run
// declares it to the execution and canonical execution builds it from
// the claimant it minted for this attachment. Declared where the
// Workspace is attached — a completed replay restores its retained
// output and expands nothing, so it needs no component of its own.
- ...(completed || replay ? [] : [{ components: evaluationComponents(database) }]),
+ ...(support === undefined || completed || replay ? [] : [support.declaration]),
],
around(operation: Operation): Operation {
// A completed run replays its retained output and result. Attaching a
@@ -1078,10 +1078,10 @@ export function runWorkflow(
//
// The suspension controller owns the scope *inside* the attachment, so
// halting a suspended execution tears the Workspace down with it and
- // nothing survives the settlement.
- return completed || replay
- ? suspension.own(operation)
- : host.attach(database, suspension.own(operation));
+ // nothing survives the settlement. A completed replay has no controller
+ // to own anything: it reaches no wait, so there is none to construct.
+ const owned = support === undefined ? operation : support.controller.own(operation);
+ return completed || replay ? owned : host.attach(database, owned);
},
};
@@ -1097,10 +1097,17 @@ export function runWorkflow(
// swallowed by a halt. An execution whose teardown failed did not reach a
// durable wait, and `suspended` is never claimed for one.
const attempted = yield* attempt(documentExecution, execute);
- const waiting = suspension.reported() && !attempted.ok && suspension.entered(attempted.error);
- const settlement: Settlement = waiting
- ? { kind: "suspension", notice: yield* suspension.notice }
- : { kind: "document", result: attempted };
+ const notice =
+ support !== undefined &&
+ support.controller.reported() &&
+ !attempted.ok &&
+ support.controller.entered(attempted.error)
+ ? yield* support.controller.notice
+ : undefined;
+ const settlement: Settlement =
+ notice === undefined
+ ? { kind: "document", result: attempted }
+ : { kind: "suspension", notice };
// The document is over, whatever storage does next — so teardown must not
// relabel this run interrupted, even if what follows refuses.
@@ -1343,54 +1350,81 @@ function* inheritedProps(
}
/**
- * The document this run executes.
+ * The document a live or partial execution runs, and what it is held to.
*
* A `start` already established it from Git to read what the pinned document
* declares. A resume fetches what the run retained, and only once the run has
- * been admitted — a run that ended is not one to fetch a definition for.
+ * been admitted — a run that ended is not one to fetch a definition for, which
+ * is why a completed replay never arrives here at all.
*/
+function* liveDocument(
+ record: WorkflowRunRecord,
+ start: WorkflowStart | undefined,
+ database: WorkflowRunDatabase,
+): Operation> {
+ const sources = yield* documentSource(start, database);
+ if (!sources.ok) {
+ return sources;
+ }
+ return Ok({
+ root: retainedSource(record.definition.rootDocumentPath, sources.value.source),
+ installations: [
+ retainedWorkflowInstallation({
+ runId: record.runId,
+ base: record.base,
+ pinnedCommit: record.definition.objectId,
+ }),
+ // The bundle this run is a run of, when it is a run of one — the sources
+ // to import from and the admission every retained import is held to.
+ ...(sources.value.components.length === 0
+ ? []
+ : [workflowBundleInstallation(sources.value.components)]),
+ ],
+ });
+}
+
function* documentSource(
start: WorkflowStart | undefined,
database: WorkflowRunDatabase,
- reconstructed: RetainedSources | undefined,
): Operation> {
if (start !== undefined) {
return Ok({ source: start.established.source, components: start.established.components });
}
- if (reconstructed !== undefined) {
- return Ok(reconstructed);
- }
+ // The root and every component this run is closed over, from the retained
+ // commit, in one read: `loadRetainedDefinition()` reconstructs the bundle
+ // when the definition names one. The admitted run record is what says which
+ // commit that is, so this asks the run rather than an earlier snapshot of it.
return yield* loadRetainedDefinition(database.record.definition, database.retrieval?.metadata);
}
+/** What a live or partial execution needs from this host's own adapter. */
+interface LiveSupport {
+ readonly controller: SuspensionController;
+ /** The declaration `` reaches this run's durable work through. */
+ readonly declaration: ExecutionInstallation;
+}
+
/**
- * The pinned sources a resumed run closed over a bundle needs before it begins.
- *
- * Answers with nothing for a `start`, which established its own bundle from Git
- * before it asked storage for anything, and for a run whose definition names no
- * components — that one keeps loading its root after the run has been admitted,
- * because a run that ended is not one to fetch a definition for.
+ * The controller a live or partial execution waits through, and the component
+ * declaration that goes with it.
*
- * A run this host cannot inspect answers with nothing too. What that run is,
- * and whether this action may advance it, is the begin transition's to decide,
- * and answering it here would report a different refusal for the same fact.
+ * Imported where it is used rather than at the top of this module, for two
+ * reasons that point the same way. This file is on the ordinary `xmd run` path
+ * too, and the Deno workflow adapter reaches `node:sqlite` — which Node greets
+ * with an experimental warning on standard error the moment it loads, and which
+ * Bun does not have at all; a run that opens no workflow storage should not be
+ * announcing that it might have. And a completed replay reaches no wait and
+ * expands no ``, so it needs neither of these — which is what lets a
+ * host whose runs live somewhere else replay one without this adapter existing.
*/
-function* reconstructedSources(
- request: WorkflowRequest,
- runId: string,
-): Operation> {
- if (request.action !== "resume") {
- return Ok(undefined);
- }
- const snapshot = yield* WorkflowLifecycle.operations.inspect(runId);
- if (!snapshot.ok) {
- return Ok(undefined);
- }
- const { definition } = snapshot.value.record;
- if (definitionComponents(definition).length === 0) {
- return Ok(undefined);
- }
- return yield* loadRetainedDefinition(definition, snapshot.value.retrieval?.metadata);
+function* liveSupport(database: WorkflowRunDatabase): Operation {
+ const { createSuspensionController, evaluationComponents } = yield* until(
+ import("@executablemd/workflow/deno"),
+ );
+ return {
+ controller: createSuspensionController({ database }),
+ declaration: { components: evaluationComponents(database) },
+ };
}
/**
diff --git a/packages/cli/tests/remote-workflow-host.test.ts b/packages/cli/tests/remote-workflow-host.test.ts
new file mode 100644
index 000000000..f56d80020
--- /dev/null
+++ b/packages/cli/tests/remote-workflow-host.test.ts
@@ -0,0 +1,1353 @@
+/**
+ * Tier WRH14 — the configured remote host, as trusted code constructs it.
+ *
+ * What this file is about is the assembly and its boundaries: that the host has
+ * the same four methods the local one has and nothing more, that it is bound to
+ * one run and refuses another before a token is minted or anything is sent,
+ * that the two request planes take no acquisition while execution takes one,
+ * and that a storage handle it did not open cannot be attached to a document.
+ *
+ * Everything here goes through the published entrypoints and a transport this
+ * test owns. Nothing imports a provider-private module, and the owner is
+ * scripted rather than real — what a real Durable Object does with these
+ * requests is proved against one in
+ * `packages/workflow/tests/cloudflare/remote-owner-routes.vitest.ts`, because
+ * hibernation, an actual upgrade and a real transaction are not things a script
+ * can claim.
+ */
+
+import { describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import { ensure, type Operation, resource, scoped, sleep, spawn, withResolvers } from "effection";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { readTextFile } from "@effectionx/fs";
+import { agentIdentityComponents, collect, retainedSource } from "@executablemd/core";
+import { executeInstalled } from "@executablemd/core/host";
+import type {
+ AcpRuntimeDoctorReport,
+ AcpRuntimeHandle,
+ AcpRuntimeOptions,
+ ProbeCapableRuntime,
+} from "@executablemd/acp";
+import { WorkflowInputDelivery, WorkflowLifecycle } from "@executablemd/workflow";
+import type { WorkflowRunDatabase } from "@executablemd/workflow";
+import type {
+ OwnerHttpRequest,
+ OwnerHttpResponse,
+ OwnerSocket,
+ OwnerTransport,
+ OwnerUpgrade,
+ OwnerUpgradeRefused,
+ SocketListener,
+} from "@executablemd/workflow/deno";
+import { OwnerEndpointError } from "@executablemd/workflow/deno";
+import { useRemoteWorkflowHost } from "../src/remote-workflow.ts";
+import type { RemoteWorkflowConfiguration } from "../src/remote-workflow.ts";
+import type { WorkflowHost } from "../src/workflow.ts";
+import {
+ document,
+ published,
+ scriptedOwner,
+ startingTree,
+ useHostSpy,
+} from "../../workflow/tests/support/remote-owner-script.ts";
+import { useBareRemote } from "../../workflow/tests/support/git-remotes.ts";
+import { useWorkflowAgentProfile, workflowSessionPolicyDigest } from "../src/workflow-agent.ts";
+import type { WorkflowAgentProfileOptions } from "../src/workflow-agent.ts";
+import { createFakeAcp, makeStore, tripwireAcp } from "./support/fake-acp.ts";
+import type { FakeAcp } from "./support/fake-acp.ts";
+import { useTempDirectory } from "@executablemd/test-support/temp";
+
+const RUN_ID = "5cktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa";
+
+const OTHER_RUN = "4bxsfqu1yxtsmfg6aaccq1sxf1a4z456bf614gt4d6t31nqdqwzz";
+const ENDPOINT = "https://owner.example/workflow";
+const RELEASE = "factory-2026.09.10-abcdef";
+
+/** Everything one scripted owner was asked, and what it answered. */
+interface Scripted {
+ /** Every ordinary request, in order. */
+ readonly requests: OwnerHttpRequest[];
+ /** Every upgrade, in order. */
+ readonly upgrades: OwnerUpgrade[];
+ /** Every token this client minted, in order. */
+ readonly tokens: string[];
+ /** Every socket handed out, and whether it is still open. */
+ readonly sockets: { readonly protocols: readonly string[]; closed: boolean }[];
+ readonly transport: OwnerTransport;
+ token(): Operation;
+}
+
+/**
+ * One owner, scripted.
+ *
+ * `answer` decides what an ordinary request comes back as; `upgrade` decides
+ * whether the executor plane hands over a socket or a refusal. Both record
+ * everything, because most of what this file proves is what was *not* asked.
+ */
+function scripted(
+ options: {
+ answer?: (request: OwnerHttpRequest) => OwnerHttpResponse;
+ upgrade?: string;
+ } = {},
+): Scripted {
+ const requests: OwnerHttpRequest[] = [];
+ const upgrades: OwnerUpgrade[] = [];
+ const tokens: string[] = [];
+ const sockets: { readonly protocols: readonly string[]; closed: boolean }[] = [];
+ let minted = 0;
+ return {
+ requests,
+ upgrades,
+ tokens,
+ sockets,
+ // deno-lint-ignore require-yield
+ *token(): Operation {
+ const token = `token-${(minted += 1)}`;
+ tokens.push(token);
+ return token;
+ },
+ transport: {
+ // deno-lint-ignore require-yield
+ *request(request: OwnerHttpRequest): Operation {
+ requests.push(request);
+ return (
+ options.answer?.(request) ?? {
+ status: 200,
+ body: JSON.stringify({ outcome: "refused", refusal: "command:absent" }),
+ }
+ );
+ },
+ connect(upgrade: OwnerUpgrade): Operation {
+ return resource(function* (provide) {
+ upgrades.push(upgrade);
+ if (options.upgrade !== undefined) {
+ yield* provide({ refusal: options.upgrade });
+ return;
+ }
+ const held = { protocols: upgrade.protocols, closed: false };
+ sockets.push(held);
+ const socket: OwnerSocket = {
+ send(): void {},
+ close(): void {
+ held.closed = true;
+ },
+ addEventListener(_type: string, _listener: SocketListener): void {},
+ removeEventListener(_type: string, _listener: SocketListener): void {},
+ };
+ yield* ensure(() => {
+ held.closed = true;
+ });
+ yield* provide(socket);
+ });
+ },
+ },
+ };
+}
+
+/** The host, built the way trusted code builds one. */
+function* host(
+ owner: Scripted,
+ runId: string = RUN_ID,
+ endpoint: string = ENDPOINT,
+): Operation {
+ return yield* useRemoteWorkflowHost({
+ runId,
+ endpoint,
+ release: RELEASE,
+ token: () => owner.token(),
+ scratchRoot: "/tmp/xmd-remote-host-test",
+ transport: owner.transport,
+ });
+}
+
+/** What reading a handle nothing opened would do, if anything read one. */
+function refuse(): never {
+ throw new Error("PLANTED-FOREIGN-DATABASE-USED");
+}
+
+/** The configured public host, over a scripted owner's socket. */
+function hostFor(
+ owner: { readonly socket: OwnerSocket },
+ capabilities?: NonNullable,
+): Operation {
+ return useRemoteWorkflowHost({
+ ...(capabilities === undefined ? {} : { capabilities }),
+ runId: RUN_ID,
+ endpoint: ENDPOINT,
+ release: RELEASE,
+ // deno-lint-ignore require-yield
+ *token(): Operation {
+ return "token-1";
+ },
+ scratchRoot: "/tmp/xmd-remote-public-host",
+ transport: {
+ // deno-lint-ignore require-yield
+ *request(): Operation {
+ throw new Error("PLANTED-REQUEST-PLANE-REACHED");
+ },
+ connect(): Operation {
+ return resource(function* (provide) {
+ yield* provide(owner.socket);
+ });
+ },
+ },
+ });
+}
+
+/** One authored document, executed as this run's root. */
+function documentOf(source: string, database: WorkflowRunDatabase): Operation {
+ return document(source, database);
+}
+
+/** A storage handle nothing opened: shaped like one, and one nothing may use. */
+function foreignDatabase(): WorkflowRunDatabase {
+ return {
+ get record() {
+ return refuse();
+ },
+ get retrieval() {
+ return refuse();
+ },
+ get journal() {
+ return refuse();
+ },
+ readJournalEntries: refuse,
+ transact: refuse,
+ replaceRetrievalMetadata: refuse,
+ readDocumentExecutions: refuse,
+ };
+}
+
+/**
+ * What a caller may configure, at the type level.
+ *
+ * The published boundary excludes a substituted repository host, a Git-host
+ * transport and an invocation observer, because each is a seam through which a
+ * credential this run acquires would become visible to whoever supplied it.
+ * That exclusion is a property of the *type*, so this is where it is asserted:
+ * adding `composition` back to what the public configuration accepts stops this
+ * file compiling.
+ */
+type Capabilities = NonNullable;
+type NoComposition = "composition" extends keyof Capabilities ? never : true;
+type NoObserver = "observe" extends keyof Capabilities ? never : true;
+type NoAccess = "access" extends keyof NonNullable
+ ? never
+ : true;
+const NARROW: [NoComposition, NoObserver, NoAccess] = [true, true, true];
+
+describe("the configured remote workflow host", () => {
+ it("has the four methods a host has, and no others", function* () {
+ const owner = scripted();
+ const assembled = yield* scoped(function* () {
+ const built = yield* host(owner);
+ return Object.keys(built).toSorted();
+ });
+ expect(assembled).toEqual(["attach", "useDelivery", "useLifecycle", "useRunHost"]);
+ // And what it may be configured with is the host-owned list, proved above
+ // where the property lives.
+ expect(NARROW).toEqual([true, true, true]);
+ // Constructing a host reaches no owner: no token was minted, no request was
+ // sent and nothing was upgraded.
+ expect(owner.tokens).toEqual([]);
+ expect(owner.requests).toEqual([]);
+ expect(owner.upgrades).toEqual([]);
+ });
+
+ it("refuses an endpoint that cannot address an owner, before anything else", function* () {
+ const owner = scripted();
+ const refused: Record = {};
+ const offered: Record = {
+ "endpoint-absent": "",
+ "endpoint-unparseable": "not a url",
+ "endpoint-scheme": "ftp://owner.example",
+ "endpoint-credentials": "https://user:secret@owner.example",
+ "endpoint-query": "https://owner.example/workflow?token=x",
+ "endpoint-fragment": "https://owner.example/workflow#fragment",
+ };
+ for (const [expected, endpoint] of Object.entries(offered)) {
+ refused[expected] = yield* scoped(function* () {
+ try {
+ // Parsed by the client's own construction, which building the host
+ // reaches before it installs anything at all.
+ yield* host(owner, RUN_ID, endpoint);
+ return "admitted";
+ } catch (error) {
+ return error instanceof OwnerEndpointError ? error.refusal : "other";
+ }
+ });
+ }
+ expect(refused).toEqual(Object.fromEntries(Object.keys(offered).map((key) => [key, key])));
+ // Every one of them refused here, and none of them minted a token.
+ expect(owner.tokens).toEqual([]);
+ expect(owner.requests).toEqual([]);
+ });
+
+ it("reads through the request plane, taking no acquisition", function* () {
+ const owner = scripted();
+ const outcome = yield* scoped(function* () {
+ const built = yield* host(owner);
+ yield* built.useLifecycle();
+ const inspected = yield* WorkflowLifecycle.operations.inspect(RUN_ID);
+ return inspected.ok ? "answered" : inspected.error.name;
+ });
+ // The owner answered `absent`, which is a fact about the run rather than a
+ // failure of the plane.
+ expect(outcome).toBe("WorkflowRunNotFoundError");
+ // One ordinary request, on the read path of the configured endpoint, with
+ // one freshly minted token beside the body rather than inside it.
+ expect(owner.requests).toHaveLength(1);
+ expect(owner.requests[0]?.url).toBe(`${ENDPOINT}/runs/${RUN_ID}/read`);
+ expect(owner.requests[0]?.headers["authorization"]).toBe("Bearer token-1");
+ expect(owner.requests[0]?.body).not.toContain("token-1");
+ // And nothing was acquired to answer it.
+ expect(owner.upgrades).toEqual([]);
+ expect(owner.sockets).toEqual([]);
+ });
+
+ it("delivers through its own request plane, taking no acquisition", function* () {
+ const owner = scripted({
+ answer: () => ({
+ status: 200,
+ body: JSON.stringify({ outcome: "refused", refusal: "command:not-suspended" }),
+ }),
+ });
+ const outcome = yield* scoped(function* () {
+ const built = yield* host(owner);
+ yield* built.useDelivery();
+ const delivered = yield* WorkflowInputDelivery.operations.deliver({
+ runId: RUN_ID,
+ suspensionId: "suspension-1",
+ value: "answered",
+ secretDetection: false,
+ });
+ return delivered.ok ? "retained" : "refused";
+ });
+ expect(outcome).toBe("refused");
+ expect(owner.requests).toHaveLength(1);
+ expect(owner.requests[0]?.url).toBe(`${ENDPOINT}/runs/${RUN_ID}/delivery`);
+ expect(owner.upgrades).toEqual([]);
+ });
+
+ it("is bound to one run, and refuses another before minting a token", function* () {
+ const owner = scripted();
+ const outcomes = yield* scoped(function* () {
+ const built = yield* host(owner);
+ yield* built.useLifecycle();
+ yield* built.useDelivery();
+ const inspected = yield* WorkflowLifecycle.operations.inspect(OTHER_RUN);
+ const delivered = yield* WorkflowInputDelivery.operations.deliver({
+ runId: OTHER_RUN,
+ suspensionId: "suspension-1",
+ value: "answered",
+ secretDetection: false,
+ });
+ return {
+ inspected: inspected.ok ? "answered" : inspected.error.message,
+ delivered: delivered.ok ? "retained" : "refused",
+ };
+ });
+ // Refused because this owner's plane is one run's, whichever layer says so
+ // first — and said without a token having been minted for it.
+ expect(outcomes.inspected).toContain("other than");
+ expect(outcomes.delivered).toBe("refused");
+ // The refusal happened here: no token was minted, and nothing was sent.
+ expect(owner.tokens).toEqual([]);
+ expect(owner.requests).toEqual([]);
+ expect(owner.upgrades).toEqual([]);
+ });
+
+ it("acquires one socket for execution, and gives it up with its scope", function* () {
+ const owner = scripted();
+ const acquired = yield* scoped(function* () {
+ const built = yield* host(owner);
+ yield* built.useRunHost();
+ const taken = yield* WorkflowLifecycle.operations.acquireExecutor(RUN_ID);
+ return taken.ok ? taken.value.kind : `failed:${taken.error.message}`;
+ });
+ expect(acquired).toBe("acquired");
+ // One upgrade, on the executor path, offering this build's protocol with
+ // the release and a fresh token beside it — and the URL carries neither.
+ expect(owner.upgrades).toHaveLength(1);
+ expect(owner.upgrades[0]?.url).toBe(`${ENDPOINT}/runs/${RUN_ID}/executor`);
+ expect(owner.upgrades[0]?.url).not.toContain("token");
+ expect(owner.upgrades[0]?.protocols).toEqual([
+ "executablemd.workflow.owner.v1",
+ RELEASE,
+ "token-1",
+ ]);
+ // No ordinary request was needed to execute, and the socket is closed now
+ // that the scope that acquired it has ended.
+ expect(owner.requests).toEqual([]);
+ expect(owner.sockets).toHaveLength(1);
+ expect(owner.sockets[0]?.closed).toBe(true);
+ });
+
+ it("reports a run another executor holds, rather than failing", function* () {
+ const owner = scripted({ upgrade: "acquisition:already-running" });
+ const outcome = yield* scoped(function* () {
+ const built = yield* host(owner);
+ yield* built.useRunHost();
+ const acquired = yield* WorkflowLifecycle.operations.acquireExecutor(RUN_ID);
+ return acquired.ok ? acquired.value.kind : `failed:${acquired.error.message}`;
+ });
+ expect(outcome).toBe("already-running");
+ expect(owner.sockets).toEqual([]);
+ });
+
+ it("runs an authored File through the configured public host", function* () {
+ const outcome = yield* scoped(function* () {
+ const captured = yield* startingTree();
+ const owner = scriptedOwner(captured);
+ // The configured public host, over a transport whose socket is that
+ // scripted owner. Everything between the two is production code: the
+ // client, its three planes, the runner and the attachment.
+ const built = yield* hostFor(owner);
+ const transitions = yield* built.useRunHost();
+ const taken = yield* WorkflowLifecycle.operations.acquireExecutor(RUN_ID);
+ if (!taken.ok || taken.value.kind !== "acquired") {
+ throw new Error("expected the configured host to take the acquisition");
+ }
+ const begun = yield* transitions.begin(taken.value.lock, {
+ runId: RUN_ID,
+ action: "resume",
+ });
+ if (!begun.ok) {
+ throw begun.error;
+ }
+ const database = begun.value.database;
+ const ambient = yield* useHostSpy();
+ const rendered = yield* built.attach(
+ database,
+ document(
+ ["# Remote", "", 'through the public host'].join("\n"),
+ database,
+ ),
+ );
+ return {
+ attached: String(rendered).trimEnd(),
+ owner,
+ before: captured.root.rootId,
+ ambient,
+ };
+ });
+
+ expect(outcome.attached).toBe("# Remote");
+ // The ambient host filesystem was never asked, and the owner received one
+ // proposal carrying the new root and the effect's own journal row.
+ expect(outcome.ambient).toEqual([]);
+ const proposals = published(outcome.owner.commits);
+ expect(proposals).toHaveLength(1);
+ expect(proposals[0]?.["expectedWorkspaceRootId"]).toBe(outcome.before);
+ expect(JSON.stringify(proposals[0]?.["publication"])).toContain("/NOTES.md");
+ });
+
+ it("clones and retains a Repository, then continues its Git mutation from that history", function* () {
+ const outcome = yield* scoped(function* () {
+ const remote = yield* useBareRemote({
+ commits: [
+ {
+ message: "first",
+ entries: [
+ { path: "which.txt", content: "main\n" },
+ { path: "nested/note.md", content: "note\n" },
+ ],
+ },
+ {
+ message: "release",
+ branch: "release",
+ entries: [{ path: "which.txt", content: "release\n" }],
+ },
+ ],
+ });
+ const captured = yield* startingTree();
+ const owner = scriptedOwner(captured);
+ // Installed around both executions, at the position a runtime entrypoint
+ // installs it and with a working directory a workflow run must never
+ // resolve against: anything either execution let fall through to the
+ // caller's filesystem is visible here rather than silent.
+ const ambient = yield* useHostSpy();
+ const source = [
+ "# Remote",
+ "",
+ ``,
+ '',
+ '',
+ "",
+ "switched to: {which}",
+ "",
+ ].join("\n");
+
+ /** One document execution through the configured public host. */
+ function* runThrough(
+ authored: string,
+ socket: OwnerSocket,
+ ): Operation<{ output: string; failure: string }> {
+ return yield* scoped(function* () {
+ const built = yield* hostFor({ socket });
+ const transitions = yield* built.useRunHost();
+ const taken = yield* WorkflowLifecycle.operations.acquireExecutor(RUN_ID);
+ if (!taken.ok || taken.value.kind !== "acquired") {
+ throw new Error("expected the configured host to take the acquisition");
+ }
+ const begun = yield* transitions.begin(taken.value.lock, {
+ runId: RUN_ID,
+ action: "resume",
+ });
+ if (!begun.ok) {
+ throw begun.error;
+ }
+ try {
+ const rendered = yield* built.attach(
+ begun.value.database,
+ documentOf(authored, begun.value.database),
+ );
+ return { output: String(rendered), failure: "" };
+ } catch (error) {
+ return { output: "", failure: chain(error) };
+ }
+ });
+ }
+
+ // The first execution clones, retains the Repository, and is cancelled
+ // with the Git mutation's proposal still in flight. So the owner decided
+ // the creation and never decided the mutation, and what it holds is the
+ // prefix it accepted rather than a history nobody wrote.
+ const withheld: Record[] = [];
+ const proposing = withResolvers();
+ const attempt = yield* spawn(() =>
+ runThrough(
+ source,
+ withholding(owner.socket, withheld, () => proposing.resolve()),
+ ),
+ );
+ yield* proposing.operation;
+ yield* attempt.halt();
+
+ const accepted = owner.commits.length;
+ const asked = owner.sent.length;
+ const prefix = owner.entries();
+
+ // The remote is gone before the continuation runs, so nothing it does
+ // can involve the network — and what it continues from is the journal the
+ // owner retained beside the root and the mapping.
+ yield* remote.remove();
+ const again = yield* runThrough(source, owner.socket);
+
+ // The same anchored prefix, read again now that the journal has run past
+ // it. An owner answers the prefix a reader anchored — not the history
+ // that arrived afterwards — and refuses to answer at all for an anchor it
+ // never minted, or for a cursor that is not inside the snapshot that
+ // anchor names: at the anchor is already outside it.
+ const terminal = String(prefix.at(-1)?.eventId);
+ const reread = {
+ head: ask(owner, { command: "journal", anchorEventId: terminal, afterEventId: null }),
+ rest: ask(owner, {
+ command: "journal",
+ anchorEventId: terminal,
+ afterEventId: prefix[1]?.eventId ?? null,
+ }),
+ atAnchor: ask(owner, {
+ command: "journal",
+ anchorEventId: terminal,
+ afterEventId: terminal,
+ }),
+ beyond: ask(owner, {
+ command: "journal",
+ anchorEventId: terminal,
+ afterEventId: owner.entries()[prefix.length]?.eventId ?? null,
+ }),
+ unknown: ask(owner, {
+ command: "journal",
+ anchorEventId: "owner-event-nothing",
+ afterEventId: null,
+ }),
+ };
+
+ return {
+ withheld,
+ again,
+ ambient,
+ retained: prefix,
+ creation: owner.commits.slice(0, accepted),
+ continuation: owner.commits.slice(accepted),
+ replayed: owner.sent.slice(asked),
+ reread,
+ owner,
+ };
+ });
+
+ // One proposal reached the owner, carrying the Repository mapping and the
+ // root that holds its checkout; the mutation's proposal reached it never.
+ const retaining = published(outcome.creation);
+ expect(retaining).toHaveLength(1);
+ expect(JSON.stringify(retaining[0]?.["publication"])).toContain("/project");
+ expect(outcome.withheld).toHaveLength(1);
+ expect(only(outcome.withheld[0])).toContain('"type":"workspace_git_switch"');
+ // The retained *record* names the checkout by its logical Workspace path
+ // and the remote by a fingerprint. No locator and no host path is in it:
+ // the locator travels beside the record, which is where a reattachment
+ // reads it from and where it is not part of retained identity.
+ const retainedMappings = retaining[0]?.["mappings"];
+ const proposed = Array.isArray(retainedMappings) ? retainedMappings[0] : undefined;
+ const record = JSON.stringify(Reflect.get(proposed ?? {}, "record"));
+ expect(record).toContain("locatorFingerprint");
+ expect(record).toContain('"checkoutPath":"/repositories/');
+ expect(record).not.toContain("/tmp");
+ expect(record).not.toContain("/var/folders");
+ expect(record).not.toContain("xmd-remote-");
+ expect(record).not.toContain('locator"');
+
+ // What the owner holds is one coherent prefix: the creation's own journal
+ // row, carrying the root that transaction published, and nothing from the
+ // transaction it never decided. A root and a mapping beside a journal
+ // missing the transaction that created them is not a state this owner can
+ // be in, and neither is a journal holding a transaction the owner refused
+ // to decide.
+ const creationRoot = Reflect.get(
+ retaining[0]?.["publication"] ?? {},
+ "proposedWorkspaceRootId",
+ );
+ const repositoryEvent = outcome.retained.find((entry) => isRepositoryEffect(entry.record));
+ expect(repositoryEvent?.workspaceRootId).toBe(creationRoot);
+ expect(
+ outcome.retained.filter((entry) => entry.record.includes('"type":"workspace_git_switch"')),
+ ).toEqual([]);
+ expect(outcome.retained.at(-1)?.workspaceRootId).toBe(creationRoot);
+
+ // The continuation read that prefix — anchored pages, from the terminal
+ // event the frontier named.
+ const pages = outcome.replayed.filter((request) => request["command"] === "journal");
+ expect(pages.length > 0).toBe(true);
+ expect(pages[0]?.["anchorEventId"]).toBe(outcome.retained.at(-1)?.eventId);
+ // And the prefix a reader anchors is the prefix it gets, however far the
+ // journal has run since: the whole of it in pages, nothing that arrived
+ // after it, and no answer at all for an anchor this owner never minted or
+ // a cursor outside that prefix.
+ expect(listed(outcome.reread.head)).toEqual(
+ outcome.retained.slice(0, 2).map((entry) => entry.eventId),
+ );
+ expect(member(outcome.reread.head["value"], "done")).toBe(false);
+ expect(listed(outcome.reread.rest)).toEqual(
+ outcome.retained.slice(2).map((entry) => entry.eventId),
+ );
+ expect(member(outcome.reread.rest["value"], "done")).toBe(true);
+ // A cursor at the anchor and a cursor past it are the same refusal, and it
+ // is the owner's own: `readJournalPage()` refuses a cursor whose sequence
+ // is at or after the anchor's. Answering either with an empty page would be
+ // a fixture admitting a cursor state the real boundary rejects.
+ expect(String(outcome.reread.atAnchor["raised"])).toContain("outside its anchored snapshot");
+ expect(String(outcome.reread.beyond["raised"])).toContain("outside its anchored snapshot");
+ expect(String(outcome.reread.unknown["raised"])).toContain("never minted");
+
+ // The recorded creation restored rather than cloning again — the remote it
+ // was cloned from no longer exists — and the checkout the Git mutation
+ // needed was reconstructed from the root that replayed record selected.
+ // The live switch started from exactly that root and moved a checkout that
+ // really was on `main`, which is what a checkout rebuilt from the recorded
+ // Workspace and proved against the record looks like.
+ expect(outcome.again.failure).toBe("");
+ const mutation = published(outcome.continuation);
+ const switched = mutation.find((intent) =>
+ only(intent).includes('"type":"workspace_git_switch"'),
+ );
+ expect(switched?.["expectedWorkspaceRootId"]).toBe(creationRoot);
+ expect(only(switched)).toContain('"before":{"branch":"main"');
+ expect(only(switched)).toContain('"after":{"branch":"release"');
+ // And then the branch's own file, read live from that same checkout.
+ const read = mutation.find((intent) => only(intent).includes('"type":"workspace_file"'));
+ expect(only(read)).toContain('"content":"release');
+ expect(outcome.again.output).toContain("switched to: release");
+
+ // Two publications and no third: only the work the cancellation left
+ // undone. The mutation's own journal row carries the root it published,
+ // and that root is the run's — a read moves nothing, so the switch is the
+ // last thing that moved it.
+ expect(mutation).toHaveLength(2);
+ const mutationRoot = Reflect.get(switched?.["publication"] ?? {}, "proposedWorkspaceRootId");
+ const gitEvent = outcome.owner
+ .entries()
+ .find((entry) => entry.record.includes('"type":"workspace_git_switch"'));
+ expect(gitEvent?.workspaceRootId).toBe(mutationRoot);
+ expect(outcome.owner.currentRoot).toBe(mutationRoot);
+
+ // And the creation restored rather than running again, which is a claim
+ // about *every* commit the continuation made rather than about the two
+ // that published. A Repository effect that executed a second time while
+ // the owner already held a compatible mapping would neither clone nor
+ // publish a root — it would return that mapping and append its own event
+ // — so a check that looked only at publications could not see it. This
+ // one looks at the whole sequence, and at what the owner ends up holding:
+ // one Repository effect row in the journal, the one already in the
+ // retained prefix, and no Repository mapping proposed again.
+ expect(records(outcome.continuation).filter(isRepositoryEffect)).toEqual([]);
+ expect(mappingsOf(outcome.continuation, "repository")).toEqual([]);
+ const repositoryRows = outcome.owner
+ .entries()
+ .filter((entry) => isRepositoryEffect(entry.record));
+ expect(repositoryRows).toHaveLength(1);
+ expect(repositoryRows[0]?.eventId).toBe(repositoryEvent?.eventId);
+ // The ambient host filesystem was asked for nothing by either execution.
+ expect(outcome.ambient).toEqual([]);
+ });
+
+ it("prompts through the shipped Agent profile, and retains the conversation it got", function* () {
+ const root = yield* useTempDirectory("xmd-remote-agent-");
+ const source = yield* readTextFile(join(FIXTURES, "claude-session.md"));
+
+ const outcome = yield* scoped(function* () {
+ const captured = yield* startingTree();
+ const owner = scriptedOwner(captured);
+ // One provider store across every attachment below: a provider keeps its
+ // sessions across processes, so a fresh one would be a provider that
+ // forgot rather than a run that came back.
+ const store = makeStore();
+
+ // The first attachment is cancelled while the second prompt's turn is in
+ // flight. The session has been established and its mapping has committed
+ // by then, so what the cancellation leaves unfinished is the turn rather
+ // than the retention — which is what gives the restart below something
+ // to resolve from.
+ const live = createFakeAcp();
+ live.script({ reply: "the reviewer saw the release notes" });
+ live.script({ reply: "", manual: true });
+ const marks: Mark[] = [];
+ const interrupted = yield* spawn(() =>
+ attaching(sampled(owner.socket, live, marks), root, source, {
+ createRuntime: live.create,
+ sessionStore: store,
+ }),
+ );
+ yield* live.startedTurns(2);
+ yield* interrupted.halt();
+ const created = retained(owner);
+ const asserted = storeAssertions(store);
+
+ // The restart: the same run, the same provider store, and a provider that
+ // answers the turn the first attempt never finished.
+ const resumed = createFakeAcp();
+ resumed.script({ reply: "and they recommended shipping it" });
+ const again = yield* attaching(owner.socket, root, source, {
+ createRuntime: resumed.create,
+ sessionStore: store,
+ });
+
+ // And once the document has finished, a further attachment restores it
+ // from what the owner retains and reaches no provider at all — not even
+ // to create a runtime.
+ const reached: string[] = [];
+ const replayed = yield* attaching(owner.socket, root, source, {
+ createRuntime: tripwireAcp((what) => reached.push(what)),
+ sessionStore: store,
+ });
+
+ return {
+ created,
+ asserted,
+ marks,
+ enlisted: enlistment(owner),
+ establishedFirst: established(live),
+ promptedFirst: [...live.prompts],
+ again,
+ establishedAgain: established(resumed),
+ promptedAgain: [...resumed.prompts],
+ reattached: retained(owner),
+ held: storeAssertions(store),
+ replayed,
+ reached,
+ };
+ });
+
+ // One session, established on the runner, and one mapping at the owner
+ // carrying exactly what the provider asserted about it — under the shipped
+ // session policy rather than a digest this test invented.
+ expect(outcome.establishedFirst).toHaveLength(1);
+ expect(outcome.created).toHaveLength(1);
+ expect(member(outcome.created[0], "provider")).toBe("acpx");
+ expect(member(outcome.created[0], "policy")).toBe(workflowSessionPolicyDigest());
+ expect(member(member(outcome.created[0], "assertion"), "kind")).toBe("acpx.agentSessionId");
+ expect([String(member(member(outcome.created[0], "assertion"), "value"))]).toEqual(
+ outcome.asserted,
+ );
+ // It crossed as a mappings-only intent — one mapping, no event and no
+ // publication — and the owner answered it with no minted identity at all.
+ // One identity per proposed event is the client's rule, and a transaction
+ // that proposed none is answered with none.
+ expect(outcome.enlisted?.publication).toBe(null);
+ expect(outcome.enlisted?.events).toEqual([]);
+ expect(outcome.enlisted?.journalEventIds).toEqual([]);
+ // The order is the whole of it, sampled at the owner: the conversation
+ // existed, the owner then accepted which one it was, and only then did
+ // anything prompt it.
+ expect(outcome.marks).toEqual([{ ensured: 1, prompts: 0 }]);
+ expect(outcome.promptedFirst).toHaveLength(2);
+ expect(outcome.promptedFirst[0]).toContain("What did the reviewer see?");
+
+ // The restart reattaches the exact conversation: the same placement, the
+ // same provider-native identity, no second session and no second mapping.
+ expect(outcome.again).toBe("attached");
+ expect(outcome.establishedAgain).toEqual(outcome.establishedFirst);
+ expect(outcome.held).toEqual(outcome.asserted);
+ expect(outcome.reattached).toEqual(outcome.created);
+ // And it prompted only the work the cancellation left unfinished.
+ expect(outcome.promptedAgain).toHaveLength(1);
+ expect(outcome.promptedAgain[0]).toContain("And what did they recommend?");
+
+ // A completed document restores without a provider.
+ expect(outcome.replayed).toBe("attached");
+ expect(outcome.reached).toEqual([]);
+ });
+
+ it("proposes no mapping for a session that never became one", function* () {
+ const captured = yield* startingTree();
+ const root = yield* useTempDirectory("xmd-remote-agent-window-");
+ const source = yield* readTextFile(join(FIXTURES, "claude-session.md"));
+
+ // A provider whose establishment fails outright.
+ const failed = yield* scoped(function* () {
+ const owner = scriptedOwner(captured);
+ const outcome = yield* attaching(owner.socket, root, source, {
+ createRuntime: establishing(
+ () => {},
+ () => Promise.reject(new Error("PlantedEstablishFailure")),
+ [],
+ ),
+ sessionStore: makeStore(),
+ });
+ return { outcome, proposed: retained(owner) };
+ });
+
+ // And one cancelled with the establishment still in flight — a real
+ // Effection cancellation of the attachment, in the window between asking a
+ // provider for a conversation and retaining which one it is.
+ const cancelled = yield* scoped(function* () {
+ const owner = scriptedOwner(captured);
+ const store = makeStore();
+ const asking = withResolvers();
+ const closed: string[] = [];
+ let answer: (handle: AcpRuntimeHandle) => void = () => {};
+ const attempt = yield* spawn(() =>
+ attaching(owner.socket, root, source, {
+ createRuntime: establishing(
+ () => asking.resolve(),
+ () =>
+ new Promise((resolve) => {
+ answer = resolve;
+ }),
+ closed,
+ ),
+ sessionStore: store,
+ }),
+ );
+ yield* asking.operation;
+ const halting = yield* spawn(() => attempt.halt());
+ // Cancellation is delivered on microtasks, so by the next macrotask the
+ // provider's own cleanup is what is waiting for this answer rather than
+ // the run. `closed` below is what confirms this stood in that window: a
+ // provider that answers a cancelled establishment has a live session to
+ // give back, and giving it back is the only thing left to do with it.
+ yield* sleep(0);
+ answer(ESTABLISHED_LATE);
+ yield* halting;
+ return {
+ closed,
+ proposed: retained(owner),
+ asserted: storeAssertions(store),
+ };
+ });
+
+ expect(failed.outcome).toContain("raised:");
+ expect(failed.proposed).toEqual([]);
+ // The cancellation landed where it was aimed, and what the provider
+ // answered afterwards was closed rather than adopted.
+ expect(cancelled.closed).toEqual(["cancelled before the session was established"]);
+ // Nothing was retained and nothing was asserted, so a later attachment
+ // resolves from an empty run rather than from a conversation nobody can
+ // name.
+ expect(cancelled.proposed).toEqual([]);
+ expect(cancelled.asserted).toEqual([]);
+ });
+
+ it("refuses a conversation the provider replaced, and leaves the run as it was", function* () {
+ const root = yield* useTempDirectory("xmd-remote-agent-conflict-");
+ const source = yield* readTextFile(join(FIXTURES, "claude-session.md"));
+
+ const outcome = yield* scoped(function* () {
+ const captured = yield* startingTree();
+ const owner = scriptedOwner(captured);
+ const store = makeStore();
+
+ // One run, one owner, and the state that run actually left there. The
+ // session is established through the shipped profile and the execution
+ // is cancelled with its first Prompt genuinely in flight: the mapping
+ // commits before that Prompt begins, so what this owner is holding is
+ // the conversation, the root and the journal prefix of the transaction
+ // that put it there — with a turn still unfinished, which is what gives
+ // the attachment below something to continue.
+ const live = createFakeAcp();
+ live.script({ reply: "", manual: true });
+ const attempt = yield* spawn(() =>
+ attaching(owner.socket, root, source, {
+ createRuntime: live.create,
+ sessionStore: store,
+ }),
+ );
+ yield* live.startedTurns(1);
+ yield* attempt.halt();
+
+ const before = {
+ root: owner.currentRoot,
+ journal: owner.entries(),
+ mappings: owner.agentSessions(),
+ commits: owner.commits.length,
+ };
+
+ // The provider comes back holding a different conversation under the
+ // same placement. Nothing else changes: the same owner, the same run,
+ // the same store, the same configured host.
+ for (const [key, held] of store.records) {
+ store.records.set(key, { ...held, agentSessionId: "another-conversation" });
+ }
+ const provider = createFakeAcp();
+ provider.script({ reply: "a turn nothing may reach" });
+ const refused = yield* attaching(owner.socket, root, source, {
+ createRuntime: provider.create,
+ sessionStore: store,
+ });
+
+ return {
+ before,
+ refused,
+ established: provider.ensured.length,
+ started: provider.started,
+ prompts: provider.prompts.length,
+ continuation: owner.commits.slice(before.commits),
+ after: {
+ root: owner.currentRoot,
+ journal: owner.entries(),
+ mappings: owner.agentSessions(),
+ },
+ };
+ });
+
+ // The interrupted run really did leave a conversation, a root and a
+ // journal behind — otherwise there is nothing here to conflict with.
+ expect(outcome.before.mappings).toHaveLength(1);
+ expect(outcome.before.journal.length > 0).toBe(true);
+ // The refusal is the shipped policy's own. A continuation that stops before
+ // its retained history is exhausted is a divergence, and this one carries
+ // the refusal as its cause: what the run refused about is still what the
+ // failure says.
+ expect(outcome.refused).toContain("Divergence");
+ expect(outcome.refused).toContain("different durable identity");
+ // It happened where the decision belongs: before a replacement session was
+ // established — the provider was never started at all — and before
+ // anything was prompted.
+ expect(outcome.established).toBe(0);
+ expect(outcome.started).toBe(false);
+ expect(outcome.prompts).toBe(0);
+ // And before any mapping proposal — before any commit at all: this owner
+ // was not asked to retain, replace or forget anything.
+ expect(mappingsOf(outcome.continuation, "agent-session")).toEqual([]);
+ expect(outcome.continuation).toEqual([]);
+ // What it holds is what it held: the same mapping, the same root, and the
+ // same journal, entry for entry.
+ expect(outcome.after.mappings).toEqual(outcome.before.mappings);
+ expect(outcome.after.root).toBe(outcome.before.root);
+ expect(outcome.after.journal).toEqual(outcome.before.journal);
+ });
+
+ it("attaches nothing it did not open", function* () {
+ const owner = scripted();
+ const refused = yield* scoped(function* () {
+ const built = yield* host(owner);
+ yield* built.useRunHost();
+ try {
+ yield* built.attach(foreignDatabase(), never());
+ return "attached";
+ } catch (error) {
+ return error instanceof Error ? error.message : "other";
+ }
+ });
+ expect(refused).toContain("not opened by this remote host");
+ // Refused before the handle was read at all: the planted accessors say so,
+ // and no temporary tree, materialization or request happened either.
+ expect(owner.requests).toEqual([]);
+ });
+});
+
+/** An operation an attachment must never reach. */
+// deno-lint-ignore require-yield
+function* never(): Operation {
+ throw new Error("PLANTED-ATTACHED-OPERATION-RAN");
+}
+
+/** Where the workflow Agent documents this suite drives live. */
+const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "workflow-agent");
+
+/**
+ * The journal records one proposal carries, as the owner receives them.
+ *
+ * Read out of the intent rather than re-encoded, because a record is a string
+ * on the wire and searching its JSON encoding would be searching the escaping.
+ */
+function only(intent: Record | undefined): string {
+ const events = intent?.["events"];
+ return (Array.isArray(events) ? events : []).map((event) => String(event)).join("");
+}
+
+/**
+ * Every journal record a sequence of commits proposed, in order.
+ *
+ * The whole sequence, not the publications in it: durable work that restores
+ * from a compatible mapping publishes nothing and still appends its own event,
+ * so a claim about what a continuation did has to be a claim about every commit
+ * it made.
+ */
+function records(commits: readonly Record[]): string[] {
+ return commits.flatMap((intent) => {
+ const events = intent["events"];
+ return (Array.isArray(events) ? events : []).map((event) => String(event));
+ });
+}
+
+/** Every mapping of one kind a sequence of commits proposed, in order. */
+function mappingsOf(commits: readonly Record[], kind: string): unknown[] {
+ return commits.flatMap((intent) => {
+ const mappings = intent["mappings"];
+ return (Array.isArray(mappings) ? mappings : []).filter(
+ (mapping) => member(mapping, "kind") === kind,
+ );
+ });
+}
+
+/**
+ * The mappings-only commit this run enlisted its Agent session through, and
+ * what the owner answered it.
+ *
+ * Read out of the wire traffic rather than reconstructed: what is being checked
+ * is the shape of an intent that carries a mapping and nothing else, and the
+ * shape of the answer to it.
+ */
+function enlistment(owner: {
+ readonly sent: readonly Record[];
+ readonly answered: readonly Record[];
+}): { publication: unknown; events: unknown; journalEventIds: unknown } | undefined {
+ const intent = owner.sent.find(
+ (request) =>
+ request["command"] === "commit" && mappingsOf([request], "agent-session").length > 0,
+ );
+ if (intent === undefined) {
+ return undefined;
+ }
+ const given = owner.answered.find((answer) => answer["id"] === intent["id"]);
+ return {
+ publication: intent["publication"],
+ events: intent["events"],
+ journalEventIds: member(given?.["value"], "journalEventIds"),
+ };
+}
+
+/**
+ * One request straight to an owner, answered the way it answers the client.
+ *
+ * The scripted owner answers inside `send`, so this is its own answer to
+ * exactly this request rather than a reconstruction of one. A request it
+ * refuses to answer at all comes back as what it raised.
+ */
+function ask(
+ owner: { readonly socket: OwnerSocket },
+ request: Record,
+): Record {
+ let answer: Record = {};
+ const listener: SocketListener = (event) => {
+ answer = JSON.parse(String(event.data));
+ };
+ owner.socket.addEventListener("message", listener);
+ try {
+ owner.socket.send(JSON.stringify({ id: "read-1", ...request }));
+ } catch (error) {
+ return { raised: chain(error) };
+ } finally {
+ owner.socket.removeEventListener("message", listener);
+ }
+ return answer;
+}
+
+/** The event identities one journal answer listed, in order. */
+function listed(answer: Record): string[] {
+ const entries = member(answer["value"], "entries");
+ return (Array.isArray(entries) ? entries : []).map((entry) => String(member(entry, "eventId")));
+}
+
+/**
+ * One failure and everything it was caused by, in order.
+ *
+ * A failure this stack reports is often a wrapper over the decision that caused
+ * it — a run that refuses before its retained history is exhausted is reported
+ * as a divergence carrying that refusal — and a test asserting on the outermost
+ * message alone would be asserting on the wrapper.
+ */
+function chain(error: unknown): string {
+ const messages: string[] = [];
+ let current: unknown = error;
+ while (current instanceof Error && messages.length < 8) {
+ messages.push(current.message);
+ current = current.cause;
+ }
+ return messages.length === 0 ? String(error) : messages.join(" <- ");
+}
+
+/** Whether one journal record is a Repository effect's own result. */
+function isRepositoryEffect(record: string): boolean {
+ return record.includes('"type":"workspace_repository"');
+}
+
+/** One member of a value nothing has checked. */
+function member(value: unknown, name: string): unknown {
+ return value !== null && typeof value === "object" ? Reflect.get(value, name) : undefined;
+}
+
+/**
+ * The Agent-session mapping records this owner was asked to retain, in order.
+ *
+ * Read back out of the intents it received, so what is counted is what crossed
+ * rather than what this process believes it staged.
+ */
+function retained(owner: {
+ readonly commits: readonly Record[];
+}): Record[] {
+ return owner.commits.flatMap((intent) => {
+ const proposed = intent["mappings"];
+ return (Array.isArray(proposed) ? proposed : [])
+ .filter((mapping) => member(mapping, "kind") === "agent-session")
+ .map((mapping) => JSON.parse(JSON.stringify(member(mapping, "record"))));
+ });
+}
+
+/** The distinct sessions this provider was asked to establish. */
+function established(fake: FakeAcp): string[] {
+ return [...new Set(fake.ensured.map((input) => input.sessionKey))].sort();
+}
+
+/** Every provider-native identity the substituted store currently holds. */
+function storeAssertions(store: ReturnType): string[] {
+ return [...store.records.values()]
+ .flatMap((record) => (record.agentSessionId === undefined ? [] : [record.agentSessionId]))
+ .sort();
+}
+
+/** What the provider had done by the time the owner accepted a mapping. */
+interface Mark {
+ readonly ensured: number;
+ readonly prompts: number;
+}
+
+/**
+ * The owner's socket, with the provider sampled at each mapping commit.
+ *
+ * The scripted owner answers inside `send`, so what is read after it returns is
+ * what the provider had done at the moment the mapping was accepted. That is
+ * the only place the order between establishing a conversation, retaining which
+ * one it is, and prompting it can be observed at all — afterwards, all three
+ * have happened.
+ */
+function sampled(socket: OwnerSocket, fake: FakeAcp, marks: Mark[]): OwnerSocket {
+ return {
+ send(data: string): void {
+ const intent: Record = JSON.parse(data);
+ const proposed = intent["mappings"];
+ const carries = (Array.isArray(proposed) ? proposed : []).some(
+ (mapping) => member(mapping, "kind") === "agent-session",
+ );
+ socket.send(data);
+ if (carries) {
+ marks.push({
+ ensured: fake.ensured.length,
+ prompts: fake.prompts.length,
+ });
+ }
+ },
+ close(): void {
+ socket.close();
+ },
+ addEventListener(type: "message" | "close" | "error", listener: SocketListener): void {
+ socket.addEventListener(type, listener);
+ },
+ removeEventListener(type: "message" | "close" | "error", listener: SocketListener): void {
+ socket.removeEventListener(type, listener);
+ },
+ };
+}
+
+/** What a turn against a session that was never established would do. */
+function tooEarly(): never {
+ throw new Error("PLANTED-TURN-WITHOUT-A-SESSION");
+}
+
+/**
+ * A provider whose establishment does one thing: what a case here tells it to.
+ *
+ * Two of the cases are about the window between asking a provider for a
+ * conversation and retaining which one it is. Nothing is retained inside it, so
+ * what has to be driven is the provider's own answer — one that fails, and one
+ * that never comes.
+ */
+function establishing(
+ asking: () => void,
+ answer: () => Promise,
+ closed: string[],
+): (options: AcpRuntimeOptions) => ProbeCapableRuntime {
+ return function create(): ProbeCapableRuntime {
+ return {
+ doctor(): Promise {
+ return Promise.resolve({ ok: true, message: "fake agent ready" });
+ },
+ ensureSession(): Promise {
+ asking();
+ return answer();
+ },
+ startTurn: tooEarly,
+ runTurn: tooEarly,
+ cancel(): Promise {
+ return Promise.resolve();
+ },
+ close(input: { readonly handle: AcpRuntimeHandle; readonly reason: string }): Promise {
+ closed.push(input.reason);
+ return Promise.resolve();
+ },
+ };
+ };
+}
+
+/**
+ * The session a cancelled establishment answers with, too late to be used.
+ *
+ * A provider asked for a conversation answers whether or not anybody is still
+ * waiting, so this is a live session with nothing left to do with it but give
+ * it back.
+ */
+const ESTABLISHED_LATE: AcpRuntimeHandle = {
+ sessionKey: "cancelled-session",
+ backend: "acpx",
+ runtimeSessionName: "cancelled-session",
+ acpxRecordId: "cancelled-session",
+ backendSessionId: "acp:cancelled-session",
+ agentSessionId: "agent-session:cancelled-session",
+};
+
+/**
+ * One authored Agent document, executed as this run's root inside the
+ * attachment.
+ *
+ * Installed the way `xmd` itself installs it: `` names durable work
+ * after its own invocation, so the execution is told about the identity
+ * components rather than having them registered around it.
+ */
+function prompting(source: string, database: WorkflowRunDatabase): Operation {
+ return scoped(function* () {
+ return yield* collect(
+ yield* executeInstalled(
+ {
+ ...retainedSource("workflows/claude-session.md", source),
+ stream: database.journal,
+ },
+ [{ components: agentIdentityComponents() }],
+ ),
+ );
+ });
+}
+
+/**
+ * One attachment with the shipped Agent profile configured, and what it did.
+ *
+ * The profile is `useWorkflowAgentProfile()` itself, passed through the public
+ * configuration's `capabilities.agent`; only the agent process and the store it
+ * keeps its own sessions in are substituted.
+ */
+function attaching(
+ socket: OwnerSocket,
+ root: string,
+ source: string,
+ provider: {
+ readonly createRuntime: WorkflowAgentProfileOptions["createRuntime"];
+ readonly sessionStore: WorkflowAgentProfileOptions["sessionStore"];
+ },
+): Operation {
+ return scoped(function* () {
+ const built = yield* hostFor(
+ { socket },
+ {
+ agent: (attachment) =>
+ useWorkflowAgentProfile({
+ root,
+ attachment,
+ defaultAgent: "claude",
+ ...provider,
+ }),
+ },
+ );
+ const transitions = yield* built.useRunHost();
+ const taken = yield* WorkflowLifecycle.operations.acquireExecutor(RUN_ID);
+ if (!taken.ok || taken.value.kind !== "acquired") {
+ throw new Error("expected the configured host to take the acquisition");
+ }
+ const begun = yield* transitions.begin(taken.value.lock, {
+ runId: RUN_ID,
+ action: "resume",
+ });
+ if (!begun.ok) {
+ throw begun.error;
+ }
+ try {
+ yield* built.attach(begun.value.database, prompting(source, begun.value.database));
+ return "attached";
+ } catch (error) {
+ return `raised:${chain(error)}`;
+ }
+ });
+}
+
+/**
+ * The owner's socket, with one Workspace proposal held back.
+ *
+ * How a partial history is produced without inventing one. The Repository
+ * creation commits whole — root, staged content, mapping and its own journal
+ * row — and the proposal after it is still in flight when the run is
+ * cancelled: it never reaches the owner, so the owner never decides it and
+ * appends nothing for it. What is left is a prefix an owner can actually be
+ * holding, with real live work after it.
+ */
+function withholding(
+ socket: OwnerSocket,
+ withheld: Record[],
+ reached: () => void,
+): OwnerSocket {
+ return {
+ send(data: string): void {
+ const intent: Record = JSON.parse(data);
+ const publication = intent["publication"];
+ const mappings = intent["mappings"];
+ const creation = (Array.isArray(mappings) ? mappings : []).some(
+ (mapping) => member(mapping, "kind") === "repository",
+ );
+ if (withheld.length === 0 && publication !== null && publication !== undefined && !creation) {
+ withheld.push(intent);
+ reached();
+ return;
+ }
+ socket.send(data);
+ },
+ close(): void {
+ socket.close();
+ },
+ addEventListener(type: "message" | "close" | "error", listener: SocketListener): void {
+ socket.addEventListener(type, listener);
+ },
+ removeEventListener(type: "message" | "close" | "error", listener: SocketListener): void {
+ socket.removeEventListener(type, listener);
+ },
+ };
+}
diff --git a/packages/cli/tests/workflow-cli.test.ts b/packages/cli/tests/workflow-cli.test.ts
index 422469f38..5835fd620 100644
--- a/packages/cli/tests/workflow-cli.test.ts
+++ b/packages/cli/tests/workflow-cli.test.ts
@@ -585,6 +585,17 @@ const LOOP_ROOT = [
"",
].join("\n");
+/** A checkpoint that waits, so one bundled run can be resumed while still live. */
+const WAITING_CHECKPOINT = [
+ "checkpoint reached.",
+ "",
+ '',
+ "Proceed with the change?",
+ "",
+ "",
+].join("\n");
+
const LOOP_FILES: Record = {
"flows/loop.md": LOOP_ROOT,
"flows/InstructionFiles.md": "instruction files listed.\n",
@@ -763,29 +774,72 @@ describe("Tier WFC — a workflow closed over a component bundle", () => {
});
it("WFC19: a resume whose pinned components are unreachable is refused whole", function* () {
+ // A run that has not ended is the case this is about. It continues by
+ // importing the components its definition pins, so it reconstructs them
+ // from the repository — and a repository that is gone refuses the resume
+ // rather than continuing under whatever is there now. A run that already
+ // ended imports nothing and asks the repository nothing, which is WFC20.
+ yield* useFixture(
+ { ...LOOP_FILES, "flows/UserCheckpoint.md": WAITING_CHECKPOINT },
+ function* (fixture) {
+ const started = yield* xmd(fixture, [
+ "workflow",
+ "start",
+ "--id=loop-4",
+ "flows/loop.md",
+ ]).join();
+ expect(started.code).toBe(2);
+ expect(reportedStatus(started.stderr)).toBe("suspended");
+
+ const before = yield* xmd(fixture, ["workflow", "history", "loop-4", "--json"]).join();
+
+ // The repository this run retains is no longer a repository.
+ yield* rm(join(fixture.repository, ".git"), { recursive: true, force: true });
+
+ const resumed = yield* xmd(fixture, ["workflow", "resume", "loop-4"]).join();
+
+ expect(resumed.code).toBe(1);
+ expect(reportedStatus(resumed.stderr)).toBeUndefined();
+
+ // Its lifecycle records are exactly what they were: the refusal happened
+ // before an execution was recorded.
+ yield* git(fixture.repository, ["init", "-q", "--initial-branch=main", "."]);
+ const after = yield* xmd(fixture, ["workflow", "history", "loop-4", "--json"]).join();
+ expect(after.stdout).toBe(before.stdout);
+ },
+ );
+ });
+
+ it("WFC20: a completed bundled run replays with no repository at all", function* () {
yield* useFixture(LOOP_FILES, function* (fixture) {
const started = yield* xmd(fixture, [
"workflow",
"start",
- "--id=loop-4",
+ "--id=loop-5",
"flows/loop.md",
]).join();
expect(started.code).toBe(0);
+ const before = yield* xmd(fixture, ["workflow", "history", "loop-5", "--json"]).join();
- const before = yield* xmd(fixture, ["workflow", "history", "loop-4", "--json"]).join();
-
- // The repository this run retains is no longer a repository.
+ // Not a stale checkout and not a rewritten object: no repository. A
+ // completed replay restores what the run retained, so there is nothing
+ // here for it to read and nothing it asks for.
yield* rm(join(fixture.repository, ".git"), { recursive: true, force: true });
+ for (const name of Object.keys(LOOP_FILES)) {
+ yield* rm(join(fixture.repository, name), { force: true });
+ }
- const resumed = yield* xmd(fixture, ["workflow", "resume", "loop-4"]).join();
+ const resumed = yield* xmd(fixture, ["workflow", "resume", "loop-5"]).join();
- expect(resumed.code).toBe(1);
- expect(reportedStatus(resumed.stderr)).toBeUndefined();
+ expect(resumed.code).toBe(0);
+ expect(reportedStatus(resumed.stderr)).toBe("completed");
+ // Every stage the run recorded, in the order it recorded them.
+ expect(resumed.stdout).toContain("discovered.");
+ expect(resumed.stdout).toContain("instruction files listed.");
+ expect(resumed.stdout).toContain("implemented.");
- // Its lifecycle records are exactly what they were: the refusal happened
- // before an execution was recorded.
yield* git(fixture.repository, ["init", "-q", "--initial-branch=main", "."]);
- const after = yield* xmd(fixture, ["workflow", "history", "loop-4", "--json"]).join();
+ const after = yield* xmd(fixture, ["workflow", "history", "loop-5", "--json"]).join();
expect(after.stdout).toBe(before.stdout);
});
});
diff --git a/packages/cli/tests/workflow-host-boundary.test.ts b/packages/cli/tests/workflow-host-boundary.test.ts
new file mode 100644
index 000000000..fcd3d752e
--- /dev/null
+++ b/packages/cli/tests/workflow-host-boundary.test.ts
@@ -0,0 +1,95 @@
+/**
+ * Tier WRH — the host assembly boundary a second host has to satisfy.
+ *
+ * `WorkflowHost` is four methods, and a remote host is one more implementation
+ * of them rather than a wider surface. That is the settled contract, and the
+ * way it fails quietly is by growing: a fifth method, or a transitions type only
+ * one adapter can name, and the "same four questions" claim stops being true
+ * while every existing test still passes.
+ *
+ * So both halves are pinned here. The key set is compared exactly, and the
+ * provider-neutral lifecycle types are imported from the package root — which
+ * is where they mean what they mean — so this stops compiling if they retreat
+ * behind a runtime-named entrypoint.
+ */
+
+import { describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import type { Operation } from "effection";
+import type { WorkflowRunDatabase } from "@executablemd/workflow";
+import type {
+ WorkflowBeginRequest,
+ WorkflowExecutionBegun,
+ WorkflowExecutionTransitions,
+ WorkflowForkRequest,
+ WorkflowForkSelection,
+ WorkflowRunCreation,
+} from "@executablemd/workflow";
+import type { WorkflowHost } from "../src/workflow.ts";
+
+/**
+ * Compile-time proofs. `Assert` is the only instantiation that checks, so
+ * each of these stops compiling the moment its claim becomes false.
+ */
+type Assert = T;
+
+/** The host boundary is exactly these four methods. */
+type FourMethods = Assert<
+ keyof WorkflowHost extends "useRunHost" | "useLifecycle" | "useDelivery" | "attach" ? true : false
+>;
+const FOUR_METHODS: FourMethods = true;
+
+/** Every provider-neutral lifecycle type resolves through the package root. */
+type NeutralTypes = Assert<
+ [
+ WorkflowExecutionTransitions,
+ WorkflowBeginRequest,
+ WorkflowExecutionBegun,
+ WorkflowForkRequest,
+ WorkflowForkSelection,
+ WorkflowRunCreation,
+ ] extends [unknown, unknown, unknown, unknown, unknown, unknown]
+ ? true
+ : false
+>;
+const NEUTRAL_TYPES: NeutralTypes = true;
+
+/**
+ * A host built only from the four methods and only from root-exported types.
+ *
+ * It answers nothing — the point is that it type-checks, which is the claim a
+ * second adapter depends on.
+ */
+function neutralHost(): WorkflowHost {
+ return {
+ useRunHost(): Operation {
+ throw new Error("not this test's question");
+ },
+ useLifecycle(): Operation {
+ throw new Error("not this test's question");
+ },
+ useDelivery(): Operation {
+ throw new Error("not this test's question");
+ },
+ attach(_database: WorkflowRunDatabase, operation: Operation): Operation {
+ return operation;
+ },
+ };
+}
+
+describe("the workflow host boundary", () => {
+ it("is exactly four methods", function* () {
+ expect(FOUR_METHODS).toEqual(true);
+ expect(Object.keys(neutralHost()).toSorted()).toEqual([
+ "attach",
+ "useDelivery",
+ "useLifecycle",
+ "useRunHost",
+ ]);
+ });
+
+ it("is satisfiable from the package root alone", function* () {
+ expect(NEUTRAL_TYPES).toEqual(true);
+ expect(typeof neutralHost().attach).toEqual("function");
+ });
+});
diff --git a/packages/cli/tests/workflow-installation.test.ts b/packages/cli/tests/workflow-installation.test.ts
index 0263f6e7e..f1091a3fe 100644
--- a/packages/cli/tests/workflow-installation.test.ts
+++ b/packages/cli/tests/workflow-installation.test.ts
@@ -26,10 +26,11 @@ import {
useWorkflowLifecycle,
useWorkflowRunHost,
} from "@executablemd/workflow/deno";
-import type { WorkflowExecutionTransitions } from "@executablemd/workflow/deno";
+import type { WorkflowExecutionTransitions } from "@executablemd/workflow";
import { Git, WorkflowLifecycle, WorkflowRunStorage } from "@executablemd/workflow";
import type { WorkflowRunDatabase, WorkflowRunStatus } from "@executablemd/workflow";
import type { Json } from "@executablemd/core";
+import type { DurableEvent } from "@executablemd/durable-streams";
import { runWorkflow } from "../src/workflow.ts";
import type { WorkflowExecution, WorkflowHost, WorkflowRequest } from "../src/workflow.ts";
@@ -161,14 +162,31 @@ function refusingHost(root: string, refuse: "settle" | "none", attempted: string
};
}
+/**
+ * The root import a document execution records before anything else.
+ *
+ * A completed replay is held to it: the retained selection is what says which
+ * document the recorded result is a result of, and a history that closes the
+ * root without one describes a run that never imported anything.
+ */
+function rootImport(path: string, content: string): DurableEvent {
+ return {
+ type: "yield",
+ coroutineId: "root",
+ description: { type: "import_component", name: "__root__" },
+ result: { status: "ok", value: { kind: "repository", path, content } },
+ };
+}
+
/** Record a root terminal, so the next pass over this journal is a replay. */
-function* closeRoot(root: string, runId: string): Operation {
+function* closeRoot(root: string, runId: string, contents: string): Operation {
yield* scoped(function* () {
yield* useWorkflowRunHost({ root });
const found = yield* WorkflowRunStorage.operations.lookup(runId);
if (!found.ok) {
throw found.error;
}
+ yield* found.value.journal.append(rootImport("workflow.md", contents));
yield* found.value.journal.append({
type: "close",
coroutineId: "root",
@@ -326,7 +344,9 @@ describe("Tier WFI — what a run hands to canonical core", () => {
recordingHost(root, attached),
function* (execution): Operation> {
executions += 1;
- // Close the root, so the next pass is a completed replay.
+ // Close the root, so the next pass is a completed replay — behind the
+ // import that says which document the result is a result of.
+ yield* execution.stream.append(rootImport("workflow.md", created.contents));
yield* execution.stream.append({
type: "close",
coroutineId: "root",
@@ -423,7 +443,7 @@ describe("Tier WFI — what a run hands to canonical core", () => {
const created = yield* startedRun(root);
yield* useGit(created.repository, created.objectId, created.contents);
if (admitted === "completed") {
- yield* closeRoot(root, created.runId);
+ yield* closeRoot(root, created.runId, created.contents);
}
yield* endRun(root, created.runId, admitted);
yield* runWorkflow(
diff --git a/packages/cli/tests/workflow-lifecycle-control.test.ts b/packages/cli/tests/workflow-lifecycle-control.test.ts
index 13d2a754d..eab347016 100644
--- a/packages/cli/tests/workflow-lifecycle-control.test.ts
+++ b/packages/cli/tests/workflow-lifecycle-control.test.ts
@@ -24,7 +24,7 @@ import {
useWorkflowLifecycle,
useWorkflowRunHost,
} from "@executablemd/workflow/deno";
-import type { WorkflowExecutionTransitions } from "@executablemd/workflow/deno";
+import type { WorkflowExecutionTransitions } from "@executablemd/workflow";
import { Git, suspendFor, WorkflowLifecycle } from "@executablemd/workflow";
import type { WorkflowRunDatabase } from "@executablemd/workflow";
import { collect, inlineSource, registerComponents } from "@executablemd/core";
diff --git a/packages/cli/tests/workflow-replay.test.ts b/packages/cli/tests/workflow-replay.test.ts
new file mode 100644
index 000000000..6309e80f3
--- /dev/null
+++ b/packages/cli/tests/workflow-replay.test.ts
@@ -0,0 +1,1919 @@
+/**
+ * Tier WRH12 — what a completed run reaches when it is asked to run again.
+ *
+ * The rule is easy to state and easy to get wrong in one direction: a completed
+ * replay may read the run's own storage, because that is where the result is,
+ * and may reach nothing else. So this drives `runWorkflow()` — the same
+ * orchestration the shared CLI drives — with canonical core underneath it, and
+ * makes every other boundary fail if it is entered: the Git capability throws
+ * on every question, and the host's `attach()` throws when it is called at all.
+ *
+ * A completed replay under those conditions is not "a run that happened to
+ * work". It is a run that could not have consulted a checkout, could not have
+ * opened a Workspace, and produced the retained bytes anyway.
+ *
+ * The local Deno host is the oracle here rather than the subject. What the
+ * remote owner does with the same reads is proved against a real Durable Object
+ * in `packages/workflow/tests/cloudflare/remote-replay.vitest.ts`; what the
+ * shared decision does with retained values is proved over values in
+ * `packages/workflow/tests/replay-inputs.test.ts`.
+ */
+
+import { describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import { call, ensure, Err, Ok, resource, scoped } from "effection";
+import type { Operation, Result } from "effection";
+import { rm, writeTextFile } from "@effectionx/fs";
+import { exec } from "@effectionx/process";
+import { mkdtemp } from "node:fs/promises";
+import { until } from "effection";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { DatabaseSync } from "node:sqlite";
+import type { Json } from "@executablemd/core";
+import type { DurableEvent } from "@executablemd/durable-streams";
+import { executeInstalled } from "@executablemd/core/host";
+import {
+ useWorkflowInputDelivery,
+ useWorkflowLifecycle,
+ useWorkflowRunHost,
+ withWorkflowWorkspace,
+ workflowRunPath,
+} from "@executablemd/workflow/deno";
+import type { WorkflowExecutionTransitions } from "@executablemd/workflow";
+import { forkRunRecordEvent, Git, WorkflowLifecycle } from "@executablemd/workflow";
+import type {
+ WorkflowDefinition,
+ WorkflowHistoryEntry,
+ WorkflowRunDatabase,
+ WorkflowRunStatus,
+} from "@executablemd/workflow";
+import { establishDefinition } from "../src/workflow-definition.ts";
+import { runWorkflow } from "../src/workflow.ts";
+import { runWorkflowManagement } from "../src/workflow-management.ts";
+import type {
+ WorkflowExecution,
+ WorkflowHost,
+ WorkflowRequest,
+ WorkflowStart,
+} from "../src/workflow.ts";
+
+const REQUEST: WorkflowRequest = {
+ action: "start",
+ target: "workflow.md",
+ id: undefined,
+ verbose: false,
+ raw: false,
+ secretDetection: false,
+};
+
+const CHECKPOINT_SCHEMA =
+ '{"type":"object","properties":{"proceed":{"type":"boolean"}},"required":["proceed"]}';
+
+/** A document with no wait and no effect: the smallest completed run. */
+const PLAIN = "# Retained\n\nthe run recorded this line.\n";
+
+/** A document that fails after the root import, so its terminal carries an error. */
+const FAILING = "# Retained\n\npartial line.\n\n\n";
+
+/** A document that waits, so a run can be observed while it has not ended. */
+const WAITING = [
+ "# Retained",
+ "",
+ "before the wait.",
+ "",
+ ``,
+ "Proceed with the change?",
+ "",
+ "",
+ "decision: {decision.proceed}",
+ "",
+].join("\n");
+
+/** The same wait, inside a root closed over a component it must reconstruct. */
+const BUNDLED_WAITING = [
+ "---",
+ "workflow:",
+ " components:",
+ " Stage: ./Stage.md",
+ "---",
+ "",
+ "# Retained",
+ "",
+ "",
+ "",
+ ``,
+ "Proceed with the change?",
+ "",
+ "",
+ "decision: {decision.proceed}",
+ "",
+].join("\n");
+
+/** A root closed over two components, one of which it never invokes. */
+const BUNDLED = [
+ "---",
+ "workflow:",
+ " components:",
+ " Stage: ./Stage.md",
+ " Unused: ./Unused.md",
+ "---",
+ "",
+ "# Retained",
+ "",
+ "",
+ "",
+].join("\n");
+
+interface Fixture {
+ readonly repository: string;
+ readonly runs: string;
+}
+
+function* git(repository: string, args: string[]): Operation {
+ const result = yield* exec("git", { arguments: args, cwd: repository }).expect();
+ if (result.code !== 0) {
+ throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
+ }
+ return result.stdout;
+}
+
+/** One committed definition and one empty run store, both this case's own. */
+function useFixture(source: string, components: Record = {}): Operation {
+ return resource(function* (provide) {
+ const repository = yield* until(mkdtemp(join(tmpdir(), "xmd-wrp-repo-")));
+ const runs = yield* until(mkdtemp(join(tmpdir(), "xmd-wrp-runs-")));
+ yield* ensure(function* () {
+ yield* rm(repository, { recursive: true, force: true });
+ yield* rm(runs, { recursive: true, force: true });
+ });
+ yield* git(repository, ["init", "--quiet"]);
+ yield* git(repository, ["config", "user.email", "wrp@example.test"]);
+ yield* git(repository, ["config", "user.name", "WRP"]);
+ yield* writeTextFile(join(repository, "workflow.md"), source);
+ yield* git(repository, ["add", "workflow.md"]);
+ for (const [name, content] of Object.entries(components)) {
+ yield* writeTextFile(join(repository, `${name}.md`), content);
+ yield* git(repository, ["add", `${name}.md`]);
+ }
+ yield* git(repository, ["-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "definition"]);
+ yield* provide({ repository, runs });
+ });
+}
+
+/** The Git capability, answered from the fixture repository itself. */
+function useRepositoryGit(repository: string): Operation {
+ return Git.around(
+ {
+ // deno-lint-ignore require-yield
+ *repositoryRoot(): Operation {
+ return repository;
+ },
+ *revParse([revision]): Operation {
+ return (yield* git(repository, [
+ "rev-parse",
+ "--verify",
+ "--end-of-options",
+ revision,
+ ])).trim();
+ },
+ *readObject([commit, path]): Operation {
+ return yield* git(repository, ["cat-file", "blob", `${commit}:${path}`]);
+ },
+ // deno-lint-ignore require-yield
+ *objectFormat(): Operation<"sha1" | "sha256"> {
+ return "sha1";
+ },
+ },
+ { at: "min" },
+ );
+}
+
+/**
+ * A Git capability that answers nothing and records being asked.
+ *
+ * The point of the recording is that the assertion can be about the question
+ * rather than about the answer: a replay that reached here would be refused,
+ * and `asked` says which question it reached with.
+ */
+function useRefusingGit(asked: string[]): Operation {
+ const refuse = (question: string): never => {
+ asked.push(question);
+ throw new Error(`PLANTED-GIT-REACHED: ${question}`);
+ };
+ return Git.around(
+ {
+ // deno-lint-ignore require-yield
+ *repositoryRoot(): Operation {
+ return refuse("repositoryRoot");
+ },
+ // deno-lint-ignore require-yield
+ *revParse(): Operation {
+ return refuse("revParse");
+ },
+ // deno-lint-ignore require-yield
+ *readObject(): Operation {
+ return refuse("readObject");
+ },
+ // deno-lint-ignore require-yield
+ *objectFormat(): Operation<"sha1" | "sha256"> {
+ return refuse("objectFormat");
+ },
+ },
+ { at: "min" },
+ );
+}
+
+/** The production local host, recording each attachment it opens. */
+function liveHost(runs: string, attached: string[]): WorkflowHost {
+ return {
+ useRunHost(): Operation {
+ return useWorkflowRunHost({ root: runs });
+ },
+ useLifecycle(): Operation {
+ return useWorkflowLifecycle({ root: runs });
+ },
+ useDelivery(): Operation {
+ return useWorkflowInputDelivery({ root: runs });
+ },
+ attach(database: WorkflowRunDatabase, operation: Operation): Operation {
+ attached.push(database.record.runId);
+ return withWorkflowWorkspace(database, operation);
+ },
+ };
+}
+
+/** The same host, with the one boundary a completed replay must never enter. */
+function replayHost(runs: string, attached: string[]): WorkflowHost {
+ const live = liveHost(runs, attached);
+ return {
+ useRunHost: live.useRunHost,
+ useLifecycle: live.useLifecycle,
+ useDelivery: live.useDelivery,
+ attach(): Operation {
+ attached.push("attach");
+ throw new Error("PLANTED-ATTACHMENT-REACHED");
+ },
+ };
+}
+
+interface Invocation {
+ readonly exitCode: number;
+ readonly out: string[];
+ readonly err: string[];
+}
+
+/** One `runWorkflow()` invocation, with what it reported on each stream. */
+function invoke(
+ request: WorkflowRequest,
+ start: WorkflowStart | undefined,
+ host: WorkflowHost,
+ execute: (execution: WorkflowExecution) => Operation>,
+): Operation {
+ return scoped(function* () {
+ const out: string[] = [];
+ const err: string[] = [];
+ const log = console.log;
+ const error = console.error;
+ yield* ensure(() => {
+ console.log = log;
+ console.error = error;
+ });
+ console.log = (...parts: unknown[]) => out.push(parts.map((part) => String(part)).join(" "));
+ console.error = (...parts: unknown[]) => err.push(parts.map((part) => String(part)).join(" "));
+ const outcome = yield* runWorkflow(request, start, host, execute);
+ return { exitCode: outcome.exitCode, out, err };
+ });
+}
+
+/** What one document execution was given, what it rendered, and how it ended. */
+interface Rendered {
+ root: unknown;
+ /** Whether any installation offered an execution view to import from. */
+ imports: boolean;
+ output: string;
+ result: Result | undefined;
+}
+
+/** The pinned document, executed as this run's root through canonical core. */
+function pinnedBody(seen: Rendered[]): (execution: WorkflowExecution) => Operation> {
+ return function* (execution): Operation> {
+ return yield* execution.around(
+ call(function* (): Operation> {
+ const running = yield* executeInstalled(
+ { ...execution.root, stream: execution.stream, props: execution.props },
+ execution.installations,
+ );
+ // The close value of the output stream is the complete or partial
+ // rendered text, so a failed execution still reports what it rendered.
+ const subscription = yield* running.output;
+ let next = yield* subscription.next();
+ while (!next.done) {
+ next = yield* subscription.next();
+ }
+ const result = yield* running;
+ seen.push({
+ root: { ...execution.root },
+ imports: execution.installations.some(
+ (installation) => installation.bundle !== undefined,
+ ),
+ output: next.value,
+ result,
+ });
+ return result.ok ? Ok(undefined) : Err(new Error(String(result.error.message)));
+ }),
+ );
+ };
+}
+
+/** What `xmd workflow start` establishes, through the command's own module. */
+function* startFor(fixture: Fixture): Operation {
+ const established = yield* establishDefinition(join(fixture.repository, "workflow.md"));
+ if (!established.ok) {
+ throw established.error;
+ }
+ return { established: established.value, props: {}, propsSchema: {} };
+}
+
+/** The run id one invocation reported, or the empty string when it reported none. */
+function runIdOf(invocation: Invocation): string {
+ const line = invocation.err.find((entry) => entry.startsWith("workflow run: "));
+ return line === undefined ? "" : line.slice("workflow run: ".length).trim();
+}
+
+/** What one invocation published as this run's status, if anything. */
+function statusOf(invocation: Invocation): string | undefined {
+ const line = invocation.err.find((entry) => entry.startsWith("workflow status: "));
+ return line === undefined ? undefined : line.slice("workflow status: ".length).trim();
+}
+
+/** Everything about a run a replay must not move, read through the host itself. */
+interface Retained {
+ readonly status: WorkflowRunStatus;
+ readonly stopReason: string;
+ /**
+ * Which rule chose the reason, in terms two different runs can be compared
+ * by: a journal reason names a row, and a row's identity is its own run's.
+ */
+ readonly reasonAt: string;
+ readonly updatedAt: string;
+ readonly executions: number;
+ /** What each execution ended as, in order. `null` is one still open. */
+ readonly ended: (WorkflowRunStatus | null)[];
+ readonly currentWorkspaceRootId: string;
+ readonly journal: string;
+}
+
+function* retained(runs: string, runId: string): Operation {
+ return yield* scoped(function* () {
+ yield* useWorkflowLifecycle({ root: runs });
+ const snapshot = yield* WorkflowLifecycle.operations.inspect(runId);
+ if (!snapshot.ok) {
+ throw snapshot.error;
+ }
+ const history = yield* WorkflowLifecycle.operations.history(runId);
+ if (!history.ok) {
+ throw history.error;
+ }
+ const stopReason = snapshot.value.record.stopReason;
+ const at =
+ stopReason === undefined
+ ? "none"
+ : stopReason.kind === "host"
+ ? `host:${stopReason.code}`
+ : `journal:${history.value.findIndex((entry) => entry.eventId === stopReason.eventId)}`;
+ return {
+ status: snapshot.value.record.status,
+ stopReason: JSON.stringify(stopReason ?? null),
+ reasonAt: at,
+ updatedAt: snapshot.value.record.updatedAt,
+ executions: snapshot.value.executions.length,
+ ended: snapshot.value.executions.map((execution) => execution.stopStatus ?? null),
+ currentWorkspaceRootId: snapshot.value.currentWorkspaceRootId,
+ // Identity and content of every retained row, in order: a length would
+ // not notice one rewritten under a new id.
+ journal: JSON.stringify(
+ history.value.map((entry: WorkflowHistoryEntry) => [
+ entry.eventId,
+ entry.workspaceRootId,
+ entry.event,
+ ]),
+ ),
+ };
+ });
+}
+
+/** The retained answers this run holds, read the way something outside XMD would. */
+function answers(runs: string, runId: string): { suspensionId: string; state: string }[] {
+ const database = new DatabaseSync(workflowRunPath(runs, runId), { readOnly: true });
+ try {
+ return database
+ .prepare("SELECT suspension_id, state FROM workflow_suspension_answers ORDER BY rowid")
+ .all()
+ .map((row) => ({
+ suspensionId: String(row["suspension_id"]),
+ state: String(row["state"]),
+ }));
+ } finally {
+ database.close();
+ }
+}
+
+/** How many `suspension_answer` events this run's history holds. */
+function* acceptedAnswers(runs: string, runId: string): Operation {
+ return yield* scoped(function* () {
+ yield* useWorkflowLifecycle({ root: runs });
+ const history = yield* WorkflowLifecycle.operations.history(runId);
+ if (!history.ok) {
+ throw history.error;
+ }
+ return history.value.filter(
+ (entry) =>
+ entry.event.type === "yield" && entry.event.description.type === "suspension_answer",
+ ).length;
+ });
+}
+
+describe("what a completed run reaches when it is asked to run again", () => {
+ it("WRP1: replays the retained result with no repository and no Workspace", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ const live: Rendered[] = [];
+ const replayed: Rendered[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(PLAIN);
+ const started = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ REQUEST,
+ yield* startFor(fixture),
+ liveHost(fixture.runs, attached),
+ pinnedBody(live),
+ );
+ });
+ expect(started.exitCode).toBe(0);
+ const runId = runIdOf(started);
+ const before = yield* retained(fixture.runs, runId);
+
+ // From here the repository answers nothing and the host attaches
+ // nothing. Either one being reached is a planted failure.
+ const resumed = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ replayHost(fixture.runs, attached),
+ pinnedBody(replayed),
+ );
+ });
+ return { started, resumed, before, after: yield* retained(fixture.runs, runId) };
+ });
+
+ expect(outcome.resumed.exitCode).toBe(0);
+ expect(statusOf(outcome.resumed)).toBe("completed");
+ // Nothing was asked of the repository, and the only attachment is the live
+ // run's own.
+ expect(asked).toEqual([]);
+ expect(attached).toHaveLength(1);
+
+ // The document canonical execution was handed is the one the run recorded,
+ // reported by the path its definition names. Not a placeholder, not the
+ // working tree, and not an empty source.
+ expect(replayed[0]?.root).toEqual({ path: "workflow.md", source: PLAIN, retained: true });
+ expect(replayed[0]?.root).toEqual(live[0]?.root);
+
+ // Byte for byte, and the same result.
+ expect(replayed).toHaveLength(1);
+ expect(replayed[0]?.output).toBe(live[0]?.output);
+ expect(replayed[0]?.result?.ok).toBe(true);
+ expect(replayed[0]?.result?.ok === true && replayed[0]?.result.value).toEqual(
+ live[0]?.result?.ok === true ? live[0]?.result.value : undefined,
+ );
+
+ // The run is exactly where it was, apart from the one execution envelope
+ // the lifecycle records for the invocation that replayed it. A replay
+ // observes an outcome that already won, so it republishes nothing — not the
+ // status, not the reason, and not when the run last moved.
+ expect(outcome.after.status).toBe("completed");
+ expect(outcome.after.stopReason).toBe(outcome.before.stopReason);
+ expect(outcome.after.updatedAt).toBe(outcome.before.updatedAt);
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ expect(outcome.after.currentWorkspaceRootId).toBe(outcome.before.currentWorkspaceRootId);
+ expect(outcome.after.executions).toBe(outcome.before.executions + 1);
+ });
+
+ it("WRP2: recovers a stale failure to itself, refuses resume, and replays it", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ const live: Rendered[] = [];
+ const replayed: Rendered[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(FAILING);
+
+ // The document fails and its settlement never lands, so the run is left
+ // holding a result nothing published. Recovery reads the same result the
+ // settlement would have, and publishes the same outcome.
+ const started = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ REQUEST,
+ yield* startFor(fixture),
+ refusingSettlement(fixture.runs),
+ pinnedBody(live),
+ );
+ });
+ const runId = runIdOf(started);
+ expect(runId).not.toBe("");
+ const before = yield* retained(fixture.runs, runId);
+ expect(before.status).toBe("running");
+
+ // What an uninterrupted settlement would have published, for comparison
+ // with what recovery does.
+ const uninterrupted = yield* scoped(function* () {
+ const fixtureTwo = yield* useFixture(FAILING);
+ const settled = yield* scoped(function* () {
+ yield* useRepositoryGit(fixtureTwo.repository);
+ return yield* invoke(
+ { ...REQUEST, id: "settled-1" },
+ yield* startFor(fixtureTwo),
+ liveHost(fixtureTwo.runs, []),
+ pinnedBody([]),
+ );
+ });
+ expect(settled.exitCode).toBe(1);
+ const state = yield* retained(fixtureTwo.runs, "settled-1");
+ return { status: state.status, reason: state.reasonAt };
+ });
+
+ // A resume is what the settled lifecycle refuses for a failed run.
+ const refused = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ replayHost(fixture.runs, attached),
+ pinnedBody(replayed),
+ );
+ });
+ const recovered = yield* retained(fixture.runs, runId);
+
+ // The same run, named again by a compatible start, replays that failure.
+ const candidate = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* startFor(fixture);
+ });
+ const again = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, id: runId },
+ candidate,
+ replayHost(fixture.runs, attached),
+ pinnedBody(replayed),
+ );
+ });
+ return {
+ refused,
+ again,
+ before,
+ recovered,
+ uninterrupted,
+ after: yield* retained(fixture.runs, runId),
+ };
+ });
+
+ // Recovery published exactly what an uninterrupted settlement publishes —
+ // one semantic outcome, reached two ways.
+ expect(outcome.recovered.status).toBe(outcome.uninterrupted.status);
+ expect(outcome.recovered.status).toBe("failed");
+ expect(outcome.recovered.reasonAt).toBe(outcome.uninterrupted.reason);
+ expect(outcome.recovered.journal).toBe(outcome.before.journal);
+ // The resume is refused by the settled failed-run rule, without a replay
+ // envelope of its own.
+ expect(outcome.refused.exitCode).toBe(1);
+ expect(outcome.refused.err.join(" ")).toContain("workflow run failed");
+ expect(outcome.recovered.executions).toBe(outcome.before.executions);
+ expect(statusOf(outcome.refused)).toBeUndefined();
+
+ // The compatible start replays the same failure and the partial output it
+ // had rendered, reaching no repository and no Workspace.
+ expect(replayed).toHaveLength(1);
+ expect(replayed[0]?.result?.ok).toBe(false);
+ expect(replayed[0]?.output).toBe(live[0]?.output);
+ expect(replayed[0]?.output).toContain("partial line.");
+ expect(outcome.again.exitCode).toBe(1);
+ expect(asked).toEqual([]);
+ expect(attached).toEqual([]);
+ // And the retained failure is the one that stands, byte for byte.
+ expect(outcome.after.status).toBe("failed");
+ expect(outcome.after.stopReason).toBe(outcome.recovered.stopReason);
+ expect(outcome.after.updatedAt).toBe(outcome.recovered.updatedAt);
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ expect(outcome.after.currentWorkspaceRootId).toBe(outcome.before.currentWorkspaceRootId);
+ expect(outcome.after.executions).toBe(outcome.recovered.executions + 1);
+ });
+
+ it("WRP3: replays a bundled run without reading one component", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ const live: Rendered[] = [];
+ const replayed: Rendered[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(BUNDLED, {
+ Stage: "staged.\n",
+ // Declared, committed, and never invoked by the root. A replay may not
+ // fetch it, and its absence from the history is not a refusal.
+ Unused: "never imported.\n",
+ });
+ const started = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ REQUEST,
+ yield* startFor(fixture),
+ liveHost(fixture.runs, attached),
+ pinnedBody(live),
+ );
+ });
+ expect(started.exitCode).toBe(0);
+ const runId = runIdOf(started);
+ const before = yield* retained(fixture.runs, runId);
+
+ const resumed = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ replayHost(fixture.runs, attached),
+ pinnedBody(replayed),
+ );
+ });
+ return { resumed, before, after: yield* retained(fixture.runs, runId) };
+ });
+
+ expect(outcome.resumed.exitCode).toBe(0);
+ expect(statusOf(outcome.resumed)).toBe("completed");
+ expect(asked).toEqual([]);
+ expect(attached).toHaveLength(1);
+ expect(replayed[0]?.root).toEqual({ path: "workflow.md", source: BUNDLED, retained: true });
+ expect(replayed[0]?.output).toBe(live[0]?.output);
+ expect(replayed[0]?.output).toContain("staged.");
+ expect(replayed[0]?.output).not.toContain("never imported.");
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ });
+
+ it("WRP4: refuses retained state that describes no completed run", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(PLAIN);
+ const started = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ REQUEST,
+ yield* startFor(fixture),
+ liveHost(fixture.runs, attached),
+ pinnedBody([]),
+ );
+ });
+ const runId = runIdOf(started);
+
+ // A lifecycle row that says the run ended, over a history that records no
+ // result: the two cannot both be right, and neither is a replay.
+ yield* emptyJournal(fixture.runs, runId);
+ const before = yield* retained(fixture.runs, runId);
+ expect(before.status).toBe("completed");
+
+ const refused = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ replayHost(fixture.runs, attached),
+ pinnedBody([]),
+ );
+ });
+ return { refused, before, after: yield* retained(fixture.runs, runId) };
+ });
+
+ expect(outcome.refused.exitCode).toBe(1);
+ // Refused before an attachment, a native operation or a definition read.
+ expect(asked).toEqual([]);
+ expect(attached).toHaveLength(1);
+ expect(outcome.refused.err.join(" ")).toContain("records no document result");
+ // No status was published for a run whose status did not change, and the
+ // journal and Workspace frontier are exactly what they were.
+ expect(statusOf(outcome.refused)).toBeUndefined();
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ expect(outcome.after.status).toBe("completed");
+ expect(outcome.after.currentWorkspaceRootId).toBe(outcome.before.currentWorkspaceRootId);
+ });
+
+ it("WRP8: recovers a bundled run whose result committed and whose settlement did not", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ const live: Rendered[] = [];
+ const replayed: Rendered[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(BUNDLED, {
+ Stage: "staged.\n",
+ Unused: "never imported.\n",
+ });
+
+ // The executor committed the document's result and then went without
+ // settling. This is the supported crash window, not damaged input: the
+ // run reads `running`, and its journal already holds the outcome.
+ const crashed = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ REQUEST,
+ yield* startFor(fixture),
+ refusingSettlement(fixture.runs, attached),
+ pinnedBody(live),
+ );
+ });
+ expect(crashed.exitCode).toBe(1);
+ const runId = runIdOf(crashed);
+ const before = yield* retained(fixture.runs, runId);
+ expect(before.status).toBe("running");
+
+ // No checkout, no Workspace. Before the correction this reached Git for
+ // the bundle, because the status the run still carried was `running`.
+ const resumed = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ replayHost(fixture.runs, attached),
+ pinnedBody(replayed),
+ );
+ });
+ return { resumed, before, after: yield* retained(fixture.runs, runId) };
+ });
+
+ expect(outcome.resumed.exitCode).toBe(0);
+ expect(statusOf(outcome.resumed)).toBe("completed");
+ // The lifecycle recovered it; nothing was asked of the repository and
+ // nothing was attached.
+ expect(asked).toEqual([]);
+ expect(attached).toHaveLength(1);
+ expect(replayed[0]?.output).toBe(live[0]?.output);
+ expect(replayed[0]?.output).toContain("staged.");
+ expect(replayed[0]?.result?.ok).toBe(true);
+
+ // The frontier is untouched, the stale envelope was closed by the settled
+ // recovery, and the run is the completed run its history says it is.
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ expect(outcome.after.currentWorkspaceRootId).toBe(outcome.before.currentWorkspaceRootId);
+ expect(outcome.after.ended).toEqual(["completed", "completed"]);
+ expect(outcome.after.status).toBe("completed");
+ });
+
+ it("WRP9: recovers a bundled run whose committed result is a failure", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ const live: Rendered[] = [];
+ const replayed: Rendered[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(BUNDLED, {
+ Stage: "staged.\n\n\n",
+ Unused: "never imported.\n",
+ });
+ const crashed = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ REQUEST,
+ yield* startFor(fixture),
+ refusingSettlement(fixture.runs, attached),
+ pinnedBody(live),
+ );
+ });
+ expect(crashed.exitCode).toBe(1);
+ const runId = runIdOf(crashed);
+ const before = yield* retained(fixture.runs, runId);
+ expect(before.status).toBe("running");
+ expect(live[0]?.result?.ok).toBe(false);
+
+ // Recovery reads the document's own result, so a run whose document
+ // failed recovers as failed — and the settled rule then refuses a resume.
+ const refused = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ replayHost(fixture.runs, attached),
+ pinnedBody(replayed),
+ );
+ });
+ const recovered = yield* retained(fixture.runs, runId);
+
+ const candidate = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* startFor(fixture);
+ });
+ const again = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, id: runId },
+ candidate,
+ replayHost(fixture.runs, attached),
+ pinnedBody(replayed),
+ );
+ });
+ return { refused, again, before, recovered, after: yield* retained(fixture.runs, runId) };
+ });
+
+ expect(outcome.recovered.status).toBe("failed");
+ expect(outcome.recovered.journal).toBe(outcome.before.journal);
+ expect(outcome.refused.exitCode).toBe(1);
+ expect(outcome.refused.err.join(" ")).toContain("workflow run failed");
+ expect(outcome.recovered.executions).toBe(outcome.before.executions);
+
+ // The same failure, replayed rather than retried, with the output it had
+ // rendered before it failed.
+ expect(replayed).toHaveLength(1);
+ expect(replayed[0]?.result?.ok).toBe(false);
+ expect(replayed[0]?.output).toBe(live[0]?.output);
+ expect(outcome.again.exitCode).toBe(1);
+ expect(asked).toEqual([]);
+ expect(attached).toHaveLength(1);
+ expect(outcome.after.status).toBe("failed");
+ expect(outcome.after.stopReason).toBe(outcome.recovered.stopReason);
+ expect(outcome.after.updatedAt).toBe(outcome.recovered.updatedAt);
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ expect(outcome.after.currentWorkspaceRootId).toBe(outcome.before.currentWorkspaceRootId);
+ });
+
+ it("WRP10: recovers a bundled run whose retained result is a failed terminal", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ let executed = 0;
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(BUNDLED, {
+ Stage: "staged.\n",
+ Unused: "never imported.\n",
+ });
+ const established = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* startFor(fixture);
+ });
+
+ // A root coroutine that ended by raising rather than by producing a
+ // document result. `rootOutcome()` reads that as the run having failed,
+ // and names the exact row as its reason.
+ const runId = yield* seedStaleRun(fixture, established, raisedHistory(established));
+ const before = yield* retained(fixture.runs, runId);
+ expect(before.status).toBe("running");
+
+ const resumed = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ replayHost(fixture.runs, attached),
+ // deno-lint-ignore require-yield
+ function* (): Operation> {
+ executed += 1;
+ return Ok(undefined);
+ },
+ );
+ });
+ return { resumed, before, after: yield* retained(fixture.runs, runId) };
+ });
+
+ // The lifecycle recovered the canonical failed outcome and then applied the
+ // settled refusal: a run that failed is not resumed.
+ expect(outcome.resumed.exitCode).toBe(1);
+ expect(outcome.resumed.err.join(" ")).toContain("workflow run failed");
+ expect(outcome.after.status).toBe("failed");
+ expect(outcome.after.ended).toEqual(["failed"]);
+ // And it got there without a repository, a Workspace or an execution.
+ expect(asked).toEqual([]);
+ expect(attached).toEqual([]);
+ expect(executed).toBe(0);
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ expect(outcome.after.currentWorkspaceRootId).toBe(outcome.before.currentWorkspaceRootId);
+ });
+
+ it("WRP11: replays a completed run named again by a compatible start", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ const live: Rendered[] = [];
+ const replayed: Rendered[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(BUNDLED, {
+ Stage: "staged.\n",
+ Unused: "never imported.\n",
+ });
+ const runId = "compatible-1";
+
+ // Establishing the candidate is what proves the two runs are the same
+ // run, and it reads the repository. It happens before the invocation, and
+ // everything the invocation itself asks of Git is recorded separately.
+ const started = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ { ...REQUEST, id: runId },
+ yield* startFor(fixture),
+ liveHost(fixture.runs, attached),
+ pinnedBody(live),
+ );
+ });
+ expect(started.exitCode).toBe(0);
+ expect(runIdOf(started)).toBe(runId);
+ const before = yield* retained(fixture.runs, runId);
+ expect(before.status).toBe("completed");
+
+ // The same definition and props, named at the same run. The candidate is
+ // established under a repository that answers; the invocation runs under
+ // one that refuses, so anything it asks for after admission is a planted
+ // failure.
+ const candidate = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* startFor(fixture);
+ });
+ const again = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, id: runId },
+ candidate,
+ replayHost(fixture.runs, attached),
+ pinnedBody(replayed),
+ );
+ });
+ return { again, before, after: yield* retained(fixture.runs, runId) };
+ });
+
+ expect(outcome.again.exitCode).toBe(0);
+ expect(statusOf(outcome.again)).toBe("completed");
+ // Nothing was asked of the repository after admission, and nothing was
+ // attached: the candidate described the request, and the run's own history
+ // supplied the result.
+ expect(asked).toEqual([]);
+ expect(attached).toHaveLength(1);
+ expect(replayed).toHaveLength(1);
+ expect(replayed[0]?.root).toEqual({ path: "workflow.md", source: BUNDLED, retained: true });
+ expect(replayed[0]?.output).toBe(live[0]?.output);
+ expect(replayed[0]?.output).toContain("staged.");
+ expect(replayed[0]?.result?.ok).toBe(true);
+ // The live run was given a bundle to import from; the replay was not. It
+ // resolves no name, so it is granted no authority to resolve one.
+ expect(live[0]?.imports).toBe(true);
+ expect(replayed[0]?.imports).toBe(false);
+
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ expect(outcome.after.currentWorkspaceRootId).toBe(outcome.before.currentWorkspaceRootId);
+ expect(outcome.after.status).toBe("completed");
+ expect(outcome.after.stopReason).toBe(outcome.before.stopReason);
+ expect(outcome.after.updatedAt).toBe(outcome.before.updatedAt);
+ expect(outcome.after.executions).toBe(outcome.before.executions + 1);
+ });
+
+ it("WRP12: refuses a compatible start over a lifecycle row its result contradicts", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ let executed = 0;
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(BUNDLED, {
+ Stage: "staged.\n",
+ Unused: "never imported.\n",
+ });
+ const established = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* startFor(fixture);
+ });
+
+ // The root raised, and the row says the run completed. Two accounts of
+ // one run, and a replay that reused either would be choosing between them.
+ const runId = yield* seedStaleRun(fixture, established, raisedHistory(established), {
+ status: "completed",
+ });
+ const before = yield* retained(fixture.runs, runId);
+ expect(before.status).toBe("completed");
+
+ const refused = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, id: runId },
+ established,
+ replayHost(fixture.runs, attached),
+ // deno-lint-ignore require-yield
+ function* (): Operation> {
+ executed += 1;
+ return Ok(undefined);
+ },
+ );
+ });
+ const stalled = yield* retained(fixture.runs, runId);
+
+ // The next acquisition closes exactly the envelope the refusal left, and
+ // publishes no replacement outcome for the run.
+ yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, id: runId },
+ established,
+ replayHost(fixture.runs, attached),
+ // deno-lint-ignore require-yield
+ function* (): Operation> {
+ executed += 1;
+ return Ok(undefined);
+ },
+ );
+ });
+ return { refused, before, stalled, after: yield* retained(fixture.runs, runId) };
+ });
+
+ expect(outcome.refused.exitCode).toBe(1);
+ expect(outcome.refused.err.join(" ")).toContain("describe different outcomes");
+ // Refused before terminal reuse, before live support and before any
+ // attachment: nothing executed and nothing was asked of the repository.
+ expect(executed).toBe(0);
+ expect(asked).toEqual([]);
+ expect(attached).toEqual([]);
+ expect(statusOf(outcome.refused)).toBeUndefined();
+
+ // The one difference is the envelope begin had already inserted.
+ expect(outcome.stalled.journal).toBe(outcome.before.journal);
+ expect(outcome.stalled.currentWorkspaceRootId).toBe(outcome.before.currentWorkspaceRootId);
+ expect(outcome.stalled.status).toBe("completed");
+ expect(outcome.stalled.stopReason).toBe(outcome.before.stopReason);
+ expect(outcome.stalled.updatedAt).toBe(outcome.before.updatedAt);
+ expect(outcome.stalled.executions).toBe(outcome.before.executions + 1);
+ expect(outcome.stalled.ended.at(-1)).toBe(null);
+
+ // And the settled terminal-replay recovery closes that envelope alone: the
+ // next begin finishes it as interrupted, publishes no outcome for the run,
+ // and refuses the same contradiction again.
+ expect(outcome.after.ended).toEqual([...outcome.before.ended, "interrupted", null]);
+ expect(outcome.after.status).toBe("completed");
+ expect(outcome.after.stopReason).toBe(outcome.before.stopReason);
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ });
+
+ it("WRP13: replays a coherent failed run named again by a compatible start", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ const replayed: Rendered[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(BUNDLED, {
+ Stage: "staged.\n",
+ Unused: "never imported.\n",
+ });
+ const established = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* startFor(fixture);
+ });
+
+ // A failed row naming the exact retained result it failed at.
+ const runId = yield* seedStaleRun(fixture, established, raisedHistory(established), {
+ status: "failed",
+ reason: "root-close",
+ });
+ const before = yield* retained(fixture.runs, runId);
+ expect(before.status).toBe("failed");
+
+ const again = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, id: runId },
+ established,
+ replayHost(fixture.runs, attached),
+ pinnedBody(replayed),
+ );
+ });
+ return { again, before, after: yield* retained(fixture.runs, runId) };
+ });
+
+ // The same failure, replayed rather than retried.
+ expect(outcome.again.exitCode).toBe(1);
+ expect(replayed).toHaveLength(1);
+ expect(replayed[0]?.result?.ok).toBe(false);
+ expect(outcome.again.err.join(" ")).toContain("the executor died");
+ expect(asked).toEqual([]);
+ expect(attached).toEqual([]);
+
+ // And the retained failed outcome is the one that stands.
+ expect(outcome.after.status).toBe("failed");
+ expect(outcome.after.stopReason).toBe(outcome.before.stopReason);
+ expect(outcome.after.updatedAt).toBe(outcome.before.updatedAt);
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ expect(outcome.after.currentWorkspaceRootId).toBe(outcome.before.currentWorkspaceRootId);
+ });
+
+ it("WRP14: refuses a start whose definition is not the run it names", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ let executed = 0;
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(BUNDLED, {
+ Stage: "staged.\n",
+ Unused: "never imported.\n",
+ });
+ const runId = "incompatible-1";
+ const started = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ { ...REQUEST, id: runId },
+ yield* startFor(fixture),
+ liveHost(fixture.runs, attached),
+ pinnedBody([]),
+ );
+ });
+ expect(started.exitCode).toBe(0);
+ const before = yield* retained(fixture.runs, runId);
+
+ // One component says something else, and it is committed. The bundle is
+ // definition identity, so this names a run of different code.
+ yield* writeTextFile(join(fixture.repository, "Stage.md"), "staged differently.\n");
+ yield* git(fixture.repository, ["add", "-A"]);
+ yield* git(fixture.repository, [
+ "-c",
+ "commit.gpgsign=false",
+ "commit",
+ "--quiet",
+ "-m",
+ "a component changed",
+ ]);
+
+ const candidate = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* startFor(fixture);
+ });
+ const refused = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, id: runId },
+ candidate,
+ replayHost(fixture.runs, attached),
+ // deno-lint-ignore require-yield
+ function* (): Operation> {
+ executed += 1;
+ return Ok(undefined);
+ },
+ );
+ });
+ return { refused, before, after: yield* retained(fixture.runs, runId) };
+ });
+
+ expect(outcome.refused.exitCode).toBe(1);
+ expect(outcome.refused.err.join(" ")).toContain("definition");
+ expect(statusOf(outcome.refused)).toBeUndefined();
+ // Refused inside the begin transaction, before a replay execution existed.
+ expect(executed).toBe(0);
+ expect(attached).toHaveLength(1);
+ expect(outcome.after.executions).toBe(outcome.before.executions);
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ expect(outcome.after.status).toBe(outcome.before.status);
+ });
+
+ it("WRP15: refuses every action over a terminal it cannot read, and moves nothing", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ let executed = 0;
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(BUNDLED, {
+ Stage: "staged.\n",
+ Unused: "never imported.\n",
+ });
+ const established = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* startFor(fixture);
+ });
+
+ // A run left `running`, with an execution nobody closed, over a root
+ // result this build cannot read.
+ const runId = yield* seedStaleRun(fixture, established, damagedHistory(established));
+ const before = yield* retained(fixture.runs, runId);
+ expect(before.status).toBe("running");
+ expect(before.ended).toEqual([null]);
+
+ const body = () =>
+ // deno-lint-ignore require-yield
+ function* (): Operation> {
+ executed += 1;
+ return Ok(undefined);
+ };
+
+ const outcomes = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ const started = yield* invoke(
+ { ...REQUEST, id: runId },
+ established,
+ replayHost(fixture.runs, attached),
+ body(),
+ );
+ const resumed = yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ replayHost(fixture.runs, attached),
+ body(),
+ );
+ const cancelled = yield* scoped(function* () {
+ const err: string[] = [];
+ const error = console.error;
+ yield* ensure(() => {
+ console.error = error;
+ });
+ console.error = (...parts: unknown[]) => err.push(parts.map(String).join(" "));
+ const managed = yield* runWorkflowManagement(
+ { action: "cancel", runId },
+ replayHost(fixture.runs, attached),
+ );
+ return { exitCode: managed.exitCode, out: [], err };
+ });
+ return { started, resumed, cancelled };
+ });
+ return { ...outcomes, before, after: yield* retained(fixture.runs, runId) };
+ });
+
+ // Every action refuses, with the one sentence and nothing the history held.
+ for (const [name, invocation] of Object.entries(outcome)) {
+ if (name === "before" || name === "after") {
+ continue;
+ }
+ const said = "err" in invocation ? invocation.err.join(" ") : "";
+ expect([name, "exitCode" in invocation ? invocation.exitCode : 0]).toEqual([name, 1]);
+ expect([name, said.includes("cannot read")]).toEqual([name, true]);
+ expect([name, said.includes("status:")]).toEqual([name, false]);
+ }
+
+ // No live authority was constructed and no document ran.
+ expect(executed).toBe(0);
+ expect(asked).toEqual([]);
+ expect(attached).toEqual([]);
+
+ // Nothing at all changed: not the run row, not the journal, not the
+ // Workspace root, and not the execution the previous executor left open.
+ expect(outcome.after).toEqual(outcome.before);
+ });
+
+ it("WRP17: refuses a terminal row whose own journal it cannot read", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ let executed = 0;
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(BUNDLED, {
+ Stage: "staged.\n",
+ Unused: "never imported.\n",
+ });
+ const established = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* startFor(fixture);
+ });
+
+ // A row that already says the run ended, over a result nothing can read.
+ // The row does not vouch for the journal: both accounts have to agree
+ // before either is reused.
+ const ended: readonly WorkflowRunStatus[] = ["completed", "failed"];
+ const seen: { status: WorkflowRunStatus; before: Retained; after: Retained; said: string }[] =
+ [];
+ for (const status of ended) {
+ const runId = yield* seedStaleRun(fixture, established, damagedHistory(established), {
+ status,
+ });
+ const before = yield* retained(fixture.runs, runId);
+ expect([status, before.status]).toEqual([status, status]);
+
+ const said = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ const started = yield* invoke(
+ { ...REQUEST, id: runId },
+ established,
+ replayHost(fixture.runs, attached),
+ // deno-lint-ignore require-yield
+ function* (): Operation> {
+ executed += 1;
+ return Ok(undefined);
+ },
+ );
+ const resumed =
+ status === "completed"
+ ? yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ replayHost(fixture.runs, attached),
+ // deno-lint-ignore require-yield
+ function* (): Operation> {
+ executed += 1;
+ return Ok(undefined);
+ },
+ )
+ : started;
+ expect([status, started.exitCode, resumed.exitCode]).toEqual([status, 1, 1]);
+ expect([status, statusOf(started), statusOf(resumed)]).toEqual([
+ status,
+ undefined,
+ undefined,
+ ]);
+ return `${started.err.join(" ")} ${resumed.err.join(" ")}`;
+ });
+
+ seen.push({ status, before, after: yield* retained(fixture.runs, runId), said });
+ }
+ return seen;
+ });
+
+ for (const { status, before, after, said } of outcome) {
+ // Nothing was inserted, closed or published for either row.
+ expect([status, after]).toEqual([status, before]);
+ expect([status, said.includes("cannot read")]).toEqual([status, true]);
+ }
+ expect(executed).toBe(0);
+ expect(asked).toEqual([]);
+ expect(attached).toEqual([]);
+ });
+
+ it("WRP16: refuses a history holding a second result, without choosing one", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(PLAIN);
+ const established = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* startFor(fixture);
+ });
+
+ // Two results, and a row recorded after the first of them. No single
+ // execution produced this, so neither result is the run's.
+ const runId = yield* seedStaleRun(fixture, established, (definition, id) => {
+ const events: DurableEvent[] = [
+ forkRunRecordEvent({
+ runId: id,
+ base: established.established.base,
+ pinnedCommit: definition.objectId,
+ }),
+ rootImportEvent(definition.rootDocumentPath, established.established.source),
+ {
+ type: "close",
+ coroutineId: "root",
+ result: { status: "ok", value: { status: "ok", output: "first\n", value: "first\n" } },
+ },
+ {
+ type: "close",
+ coroutineId: "root",
+ result: {
+ status: "ok",
+ value: { status: "ok", output: "second\n", value: "second\n" },
+ },
+ },
+ ];
+ return events;
+ });
+ const before = yield* retained(fixture.runs, runId);
+
+ const refused = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, id: runId },
+ established,
+ replayHost(fixture.runs, attached),
+ // deno-lint-ignore require-yield
+ function* (): Operation> {
+ return Ok(undefined);
+ },
+ );
+ });
+ return { refused, before, after: yield* retained(fixture.runs, runId) };
+ });
+
+ expect(outcome.refused.exitCode).toBe(1);
+ expect(outcome.refused.err.join(" ")).toContain("cannot read");
+ expect(statusOf(outcome.refused)).toBeUndefined();
+ // Neither result was chosen, and nothing was published from either.
+ expect(outcome.after).toEqual(outcome.before);
+ expect(asked).toEqual([]);
+ expect(attached).toEqual([]);
+ });
+
+ it("WRP18: refuses a root import it cannot hold to one verified selection", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(PLAIN);
+ const established = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* startFor(fixture);
+ });
+
+ const source = established.established.source;
+ const settled: DurableEvent = {
+ type: "close",
+ coroutineId: "root",
+ result: { status: "ok", value: { status: "ok", output: "done\n", value: "done\n" } },
+ };
+
+ /** What the root import records, said in a way this build cannot hold. */
+ const unreadable: Record readonly DurableEvent[]> = {
+ // A child recorded the root's own import a second time.
+ duplicated: (path) => [
+ rootImportEvent(path, source),
+ {
+ type: "yield",
+ coroutineId: "child",
+ description: { type: "import_component", name: "__root__" },
+ result: { status: "ok", value: { kind: "repository", path, content: source } },
+ },
+ settled,
+ ],
+ // The only import names the root, and the root did not record it.
+ disowned: (path) => [
+ {
+ type: "yield",
+ coroutineId: "child",
+ description: { type: "import_component", name: "__root__" },
+ result: { status: "ok", value: { kind: "repository", path, content: source } },
+ },
+ settled,
+ ],
+ // The selection the run replays from is missing the document itself.
+ contentless: (path) => [
+ {
+ type: "yield",
+ coroutineId: "root",
+ description: { type: "import_component", name: "__root__" },
+ result: { status: "ok", value: { kind: "repository", path } },
+ },
+ settled,
+ ],
+ // A failure record reduced to the selector it was asked for.
+ forged: (path) => [
+ {
+ type: "yield",
+ coroutineId: "root",
+ description: { type: "import_component", name: "__root__" },
+ result: {
+ status: "ok",
+ value: {
+ kind: "target-failure",
+ path,
+ content: source,
+ failure: { selector: "Missing" },
+ },
+ },
+ },
+ settled,
+ ],
+ // An exact target the retained document does not offer.
+ absent: (path) => [
+ {
+ type: "yield",
+ coroutineId: "root",
+ description: { type: "import_component", name: "__root__" },
+ result: {
+ status: "ok",
+ value: { kind: "repository", path, content: source, target: "Missing" },
+ },
+ },
+ settled,
+ ],
+ // A recorded selection failure with no selector left to replay.
+ selectorless: (path) => [
+ {
+ type: "yield",
+ coroutineId: "root",
+ description: { type: "import_component", name: "__root__" },
+ result: {
+ status: "ok",
+ value: {
+ kind: "target-failure",
+ path,
+ content: source,
+ failure: { kind: "no-match", matches: [], available: [] },
+ },
+ },
+ },
+ settled,
+ ],
+ };
+
+ const refusals: Record = {};
+ for (const [says, history] of Object.entries(unreadable)) {
+ const runId = yield* seedStaleRun(fixture, established, (definition, id) => [
+ forkRunRecordEvent({
+ runId: id,
+ base: established.established.base,
+ pinnedCommit: definition.objectId,
+ }),
+ ...history(definition.rootDocumentPath),
+ ]);
+ const before = yield* retained(fixture.runs, runId);
+ const refused = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, id: runId },
+ established,
+ replayHost(fixture.runs, attached),
+ // deno-lint-ignore require-yield
+ function* (): Operation> {
+ return Ok(undefined);
+ },
+ );
+ });
+ const after = yield* retained(fixture.runs, runId);
+ refusals[says] = {
+ exitCode: refused.exitCode,
+ said: refused.err.join(" "),
+ moved: JSON.stringify(after) !== JSON.stringify(before),
+ };
+ }
+ return refusals;
+ });
+
+ // Each history refuses before the terminal is named, and recovery
+ // publishes nothing over a run it cannot say the root of.
+ for (const [says, refusal] of Object.entries(outcome)) {
+ expect([says, refusal.exitCode]).toEqual([says, 1]);
+ expect([says, refusal.said.includes("cannot read")]).toEqual([says, true]);
+ expect([says, refusal.moved]).toEqual([says, false]);
+ }
+ expect(asked).toEqual([]);
+ expect(attached).toEqual([]);
+ });
+
+ it("WRP5: a run that has not ended still reconstructs, and its refusal is cleaned up", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ const rendered: Rendered[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(BUNDLED_WAITING, { Stage: "staged.\n" });
+ const started = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ REQUEST,
+ yield* startFor(fixture),
+ liveHost(fixture.runs, attached),
+ pinnedBody([]),
+ );
+ });
+ expect(started.exitCode).toBe(2);
+ const runId = runIdOf(started);
+ expect(statusOf(started)).toBe("suspended");
+ const before = yield* retained(fixture.runs, runId);
+
+ // The same repository refusal a completed replay is indifferent to. A
+ // suspended run is not: it continues by importing, so it reads the
+ // definition and refuses whole when it cannot.
+ const refused = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ liveHost(fixture.runs, attached),
+ pinnedBody([]),
+ );
+ });
+ const stalled = yield* retained(fixture.runs, runId);
+
+ // The cleanup is the settled one, and it is the next acquisition's: it
+ // closes exactly the envelope that refused and continues the run into
+ // the wait it was standing at.
+ const again = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ liveHost(fixture.runs, attached),
+ pinnedBody(rendered),
+ );
+ });
+ return { refused, again, before, stalled, after: yield* retained(fixture.runs, runId) };
+ });
+
+ expect(outcome.refused.exitCode).toBe(1);
+ // The repository was asked, which is the whole distinction.
+ expect(asked.length).toBeGreaterThan(0);
+ // Nothing was published for a run this invocation could not advance.
+ expect(statusOf(outcome.refused)).toBeUndefined();
+
+ // The lifecycle decided first, so the envelope exists. What it may not
+ // touch is the frontier: the journal and the Workspace root the run stands
+ // on are exactly what they were.
+ expect(outcome.stalled.journal).toBe(outcome.before.journal);
+ expect(outcome.stalled.currentWorkspaceRootId).toBe(outcome.before.currentWorkspaceRootId);
+ expect(outcome.stalled.executions).toBe(outcome.before.executions + 1);
+ expect(outcome.stalled.ended.at(-1)).toBe(null);
+
+ // And the settled recovery closes exactly that envelope as interrupted,
+ // without inventing an outcome for the run.
+ expect(outcome.after.ended.slice(0, -1)).toEqual([...outcome.before.ended, "interrupted"]);
+ expect(statusOf(outcome.again)).toBe("suspended");
+ expect(outcome.after.status).toBe("suspended");
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ // Two attachments: the live start's and the recovered continuation's. The
+ // refused invocation attached nothing.
+ expect(attached).toHaveLength(2);
+ });
+
+ it("WRP6: replays a recorded answer without consuming or appending another", function* () {
+ const asked: string[] = [];
+ const attached: string[] = [];
+ const replayed: Rendered[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(WAITING);
+ const started = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ REQUEST,
+ yield* startFor(fixture),
+ liveHost(fixture.runs, attached),
+ pinnedBody([]),
+ );
+ });
+ expect(started.exitCode).toBe(2);
+ const runId = runIdOf(started);
+ const suspensionId = String(
+ started.err.find((line) => line.startsWith("workflow suspension: ")),
+ )
+ .slice("workflow suspension: ".length)
+ .trim();
+
+ const delivered = yield* scoped(function* () {
+ const out: string[] = [];
+ const log = console.log;
+ yield* ensure(() => {
+ console.log = log;
+ });
+ console.log = (...parts: unknown[]) => out.push(parts.map(String).join(" "));
+ return yield* runWorkflowManagement(
+ {
+ action: "answer",
+ runId,
+ suspensionId,
+ value: { proceed: true },
+ secretDetection: true,
+ },
+ liveHost(fixture.runs, attached),
+ );
+ });
+ expect(delivered.exitCode).toBe(0);
+
+ const finished = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ liveHost(fixture.runs, attached),
+ pinnedBody([]),
+ );
+ });
+ expect(finished.exitCode).toBe(0);
+
+ const before = yield* retained(fixture.runs, runId);
+ const answersBefore = answers(fixture.runs, runId);
+ const acceptedBefore = yield* acceptedAnswers(fixture.runs, runId);
+
+ const replay = yield* scoped(function* () {
+ yield* useRefusingGit(asked);
+ return yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ replayHost(fixture.runs, attached),
+ pinnedBody(replayed),
+ );
+ });
+ return {
+ replay,
+ before,
+ answersBefore,
+ acceptedBefore,
+ after: yield* retained(fixture.runs, runId),
+ answersAfter: answers(fixture.runs, runId),
+ acceptedAfter: yield* acceptedAnswers(fixture.runs, runId),
+ };
+ });
+
+ expect(outcome.replay.exitCode).toBe(0);
+ expect(statusOf(outcome.replay)).toBe("completed");
+ // The delivered value reached the document through the retained event.
+ expect(replayed[0]?.output).toContain("decision: true");
+ expect(asked).toEqual([]);
+
+ // One answer, still spent, and one accepted event — before and after.
+ expect(outcome.answersBefore).toEqual([
+ { suspensionId: outcome.answersBefore[0]?.suspensionId ?? "", state: "consumed" },
+ ]);
+ expect(outcome.answersAfter).toEqual(outcome.answersBefore);
+ expect(outcome.acceptedBefore).toBe(1);
+ expect(outcome.acceptedAfter).toBe(1);
+ expect(outcome.after.journal).toBe(outcome.before.journal);
+ });
+
+ it("WRP7: releases the run it replayed, and closed only its own envelope", function* () {
+ const attached: string[] = [];
+
+ const outcome = yield* scoped(function* () {
+ const fixture = yield* useFixture(PLAIN);
+ const started = yield* scoped(function* () {
+ yield* useRepositoryGit(fixture.repository);
+ return yield* invoke(
+ REQUEST,
+ yield* startFor(fixture),
+ liveHost(fixture.runs, attached),
+ pinnedBody([]),
+ );
+ });
+ const runId = runIdOf(started);
+
+ yield* scoped(function* () {
+ yield* useRefusingGit([]);
+ return yield* invoke(
+ { ...REQUEST, action: "resume", target: runId },
+ undefined,
+ replayHost(fixture.runs, attached),
+ pinnedBody([]),
+ );
+ });
+
+ // The acquisition ended with the invocation, so the next one takes it.
+ const second = yield* scoped(function* () {
+ yield* useWorkflowLifecycle({ root: fixture.runs });
+ return yield* WorkflowLifecycle.operations.acquireExecutor(runId);
+ });
+ return { second, after: yield* retained(fixture.runs, runId) };
+ });
+
+ expect(outcome.second.ok).toBe(true);
+ expect(outcome.second.ok === true && outcome.second.value.kind).toBe("acquired");
+ // Two envelopes: the run's own execution and the replay's. Both are closed,
+ // and the run is still the completed run it was.
+ expect(outcome.after.executions).toBe(2);
+ expect(outcome.after.status).toBe("completed");
+ });
+});
+
+/** The production host with the settlement its storage refuses. */
+function refusingSettlement(runs: string, attached: string[] = []): WorkflowHost {
+ const live = liveHost(runs, attached);
+ return {
+ *useRunHost(): Operation {
+ const transitions = yield* live.useRunHost();
+ return {
+ begin: transitions.begin,
+ fork: transitions.fork,
+ stageFork: transitions.stageFork,
+ // deno-lint-ignore require-yield
+ *settle(): Operation> {
+ return Err(new Error("PLANTED-STORAGE-REFUSAL"));
+ },
+ };
+ },
+ useLifecycle: live.useLifecycle,
+ useDelivery: live.useDelivery,
+ attach: live.attach,
+ };
+}
+
+/**
+ * A run this host created and then stopped holding, with the history a dead
+ * executor left behind.
+ *
+ * Created through the same transitions production uses and left exactly as a
+ * lost executor leaves a run: `running`, one execution nobody closed, and a
+ * journal that already records what the document did.
+ */
+function* seedStaleRun(
+ fixture: Fixture,
+ start: WorkflowStart,
+ events: (definition: WorkflowDefinition, runId: string) => readonly DurableEvent[],
+ ending?: { readonly status: WorkflowRunStatus; readonly reason?: "root-close" },
+): Operation {
+ return yield* scoped(function* () {
+ const transitions = yield* useWorkflowRunHost({ root: fixture.runs });
+ const runId = crypto.randomUUID();
+ const acquired = yield* WorkflowLifecycle.operations.acquireExecutor(runId);
+ if (!acquired.ok) {
+ throw acquired.error;
+ }
+ if (acquired.value.kind !== "acquired") {
+ throw new Error(`${runId} already has a live workflow executor`);
+ }
+ const begun = yield* transitions.begin(acquired.value.lock, {
+ runId,
+ action: "start",
+ creation: {
+ definition: start.established.definition,
+ base: start.established.base,
+ props: {},
+ retrieval: start.established.retrieval,
+ },
+ });
+ if (!begun.ok) {
+ throw begun.error;
+ }
+ for (const event of events(start.established.definition, runId)) {
+ yield* begun.value.database.journal.append(event);
+ }
+ if (ending === undefined) {
+ return runId;
+ }
+ // Settled by this same acquisition, so the run is left the way an executor
+ // that finished leaves one rather than the way a lost one does.
+ const entries = yield* begun.value.database.readJournalEntries();
+ if (!entries.ok) {
+ throw entries.error;
+ }
+ const close = entries.value.find(
+ (entry) => entry.event.type === "close" && entry.event.coroutineId === "root",
+ );
+ const settled = yield* transitions.settle(acquired.value.lock, {
+ executionId: begun.value.execution.executionId,
+ status: ending.status,
+ ...(ending.reason === undefined
+ ? {}
+ : { reason: { kind: "journal", eventId: close?.eventId ?? "" } }),
+ });
+ if (!settled.ok) {
+ throw settled.error;
+ }
+ return runId;
+ });
+}
+
+/**
+ * A history whose root recorded a result this build cannot read.
+ *
+ * The `Close` says the document ended; what it says it ended as is not the
+ * closed form canonical core writes, so nothing here can name the outcome.
+ */
+function damagedHistory(
+ start: WorkflowStart,
+): (definition: WorkflowDefinition, runId: string) => readonly DurableEvent[] {
+ return (definition, runId) => {
+ const events: DurableEvent[] = [
+ forkRunRecordEvent({
+ runId,
+ base: start.established.base,
+ pinnedCommit: definition.objectId,
+ }),
+ rootImportEvent(definition.rootDocumentPath, start.established.source),
+ { type: "close", coroutineId: "root", result: { status: "ok", value: { status: "err" } } },
+ ];
+ return events;
+ };
+}
+
+/** The root import canonical execution records before anything else. */
+function rootImportEvent(path: string, content: string): DurableEvent {
+ return {
+ type: "yield",
+ coroutineId: "root",
+ description: { type: "import_component", name: "__root__" },
+ result: { status: "ok", value: { kind: "repository", path, content } },
+ };
+}
+
+/** The history a run that raised out of its root leaves behind. */
+function raisedHistory(
+ start: WorkflowStart,
+): (definition: WorkflowDefinition, runId: string) => readonly DurableEvent[] {
+ return (definition, runId) => {
+ const events: DurableEvent[] = [
+ forkRunRecordEvent({
+ runId,
+ base: start.established.base,
+ pinnedCommit: definition.objectId,
+ }),
+ rootImportEvent(definition.rootDocumentPath, start.established.source),
+ {
+ type: "close",
+ coroutineId: "root",
+ result: { status: "err", error: { message: "the executor died", name: "Error" } },
+ },
+ ];
+ return events;
+ };
+}
+
+/**
+ * Take every retained event out of one run's journal, leaving its lifecycle row
+ * saying the run ended.
+ *
+ * Damage rather than a scenario: what is under test is that the two halves are
+ * required to agree, and a run cannot be brought into that state by asking the
+ * lifecycle for it.
+ */
+function* emptyJournal(runs: string, runId: string): Operation {
+ const database = new DatabaseSync(workflowRunPath(runs, runId));
+ try {
+ database.prepare("DELETE FROM journal_events").run();
+ } finally {
+ database.close();
+ }
+ yield* until(Promise.resolve(undefined));
+}
diff --git a/packages/cli/tests/workflow-suspension.test.ts b/packages/cli/tests/workflow-suspension.test.ts
index 9a14429b5..7374d5980 100644
--- a/packages/cli/tests/workflow-suspension.test.ts
+++ b/packages/cli/tests/workflow-suspension.test.ts
@@ -41,7 +41,13 @@ import { until } from "effection";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { DatabaseSync } from "node:sqlite";
-import { collect, execute, inlineSource, registerComponents } from "@executablemd/core";
+import {
+ collect,
+ execute,
+ inlineSource,
+ registerComponents,
+ retainedSource,
+} from "@executablemd/core";
import { executeInstalled } from "@executablemd/core/host";
import { durableCall, InMemoryStream } from "@executablemd/durable-streams";
import {
@@ -49,7 +55,7 @@ import {
useWorkflowLifecycle,
useWorkflowRunHost,
} from "@executablemd/workflow/deno";
-import type { WorkflowExecutionTransitions } from "@executablemd/workflow/deno";
+import type { WorkflowExecutionTransitions } from "@executablemd/workflow";
import { Git, SUSPENSION_REQUEST, suspendFor, WorkflowLifecycle } from "@executablemd/workflow";
import type { WorkflowRunDatabase } from "@executablemd/workflow";
import { workflowRunPath } from "@executablemd/workflow/deno";
@@ -403,7 +409,10 @@ function body(): (execution: WorkflowExecution) => Operation> {
try {
yield* collect(
yield* executeInstalled(
- { ...inlineSource("\n"), stream: execution.stream },
+ // Reported by the path this run's definition names, as the shared
+ // CLI reports it: a completed replay is held to that agreement,
+ // and an inline identity would be a document the run is not of.
+ { ...retainedSource("workflow.md", "\n"), stream: execution.stream },
execution.installations,
),
);
@@ -1273,7 +1282,9 @@ describe("Tier CKX — a checkpoint a document asked for", () => {
);
expect(invalid.exitCode).not.toBe(0);
expect(invalid.written.err.join(" ")).toContain("does not satisfy the response schema");
- expect(invalid.written.err.join(" ")).toContain("/proceed must be boolean");
+ expect(invalid.written.err.join(" ")).toContain(
+ "/proceed must be of the type this schema declares",
+ );
expect(yield* storageDigest(path)).toEqual(before);
const accepted = yield* manage(
diff --git a/packages/core/canonicalize.ts b/packages/core/canonicalize.ts
new file mode 100644
index 000000000..cad1f446a
--- /dev/null
+++ b/packages/core/canonicalize.ts
@@ -0,0 +1,13 @@
+/**
+ * @module
+ *
+ * Canonical JSON ordering, for runtimes that cannot load a Node builtin.
+ *
+ * `canonicalize` is already public from the package root. This subpath exists
+ * so a consumer can select it without loading the root barrel, which reaches
+ * `node:crypto`, `node:process` and the rest of the host surface — a Cloudflare
+ * Worker resolving that graph fails to typecheck, and the operation it needs is
+ * pure. Same function, same behavior, narrower resolution path.
+ */
+
+export { canonicalize } from "./src/canonicalize.ts";
diff --git a/packages/core/component-name.ts b/packages/core/component-name.ts
new file mode 100644
index 000000000..7f3e49c03
--- /dev/null
+++ b/packages/core/component-name.ts
@@ -0,0 +1,13 @@
+/**
+ * @module
+ *
+ * How a document spells a component name, for runtimes that cannot load the
+ * engine.
+ *
+ * `isComponentName` is already public from the package root. This subpath
+ * selects it without the root barrel, which reaches `node:crypto`,
+ * `node:process` and the rest of the host surface. Same function, narrower
+ * resolution path.
+ */
+
+export { isComponentName } from "./src/component-name.ts";
diff --git a/packages/core/deno.json b/packages/core/deno.json
index ac93c2365..950254932 100644
--- a/packages/core/deno.json
+++ b/packages/core/deno.json
@@ -3,7 +3,12 @@
"version": "0.9.0",
"exports": {
".": "./mod.ts",
- "./host": "./host.ts"
+ "./canonicalize": "./canonicalize.ts",
+ "./component-name": "./component-name.ts",
+ "./document-target": "./document-target.ts",
+ "./host": "./host.ts",
+ "./elicitation": "./elicitation.ts",
+ "./secrets": "./secrets.ts"
},
"imports": {
"@effectionx/context-api": "npm:@effectionx/context-api@0.6.0",
@@ -11,6 +16,7 @@
"@secretlint/secretlint-rule-preset-recommend": "npm:@secretlint/secretlint-rule-preset-recommend@13.0.4",
"@secretlint/types": "npm:@secretlint/types@13.0.4",
"acorn": "npm:acorn@^8.16.0",
+ "@cfworker/json-schema": "npm:@cfworker/json-schema@^4.1.1",
"ajv": "npm:ajv@^8.17.1",
"gray-matter": "npm:gray-matter@^4.0.3",
"magic-string": "npm:magic-string@^0.30.21",
diff --git a/packages/core/document-target.ts b/packages/core/document-target.ts
new file mode 100644
index 000000000..202533cb8
--- /dev/null
+++ b/packages/core/document-target.ts
@@ -0,0 +1,13 @@
+/**
+ * @module
+ *
+ * How an exact document target is spelled, for runtimes that cannot load a
+ * Markdown parser.
+ *
+ * `isCanonicalDocumentTarget` is already public from the package root under
+ * that fuller name. This subpath selects the spelling predicate without the
+ * catalog and selector machinery behind it, and without the root barrel's host
+ * surface. Same function, narrower resolution path.
+ */
+
+export { isCanonicalTarget as isCanonicalDocumentTarget } from "./src/document-target-spelling.ts";
diff --git a/packages/core/elicitation.ts b/packages/core/elicitation.ts
new file mode 100644
index 000000000..a26820a10
--- /dev/null
+++ b/packages/core/elicitation.ts
@@ -0,0 +1,17 @@
+/**
+ * @module
+ *
+ * The elicitation response judgment, for runtimes that cannot load the root.
+ *
+ * `prepareResponseValidator` is already public from the package root. This
+ * subpath exists so a consumer can select it without loading the root barrel,
+ * which reaches a terminal renderer, `node:crypto` and the rest of the host
+ * surface — a Cloudflare Worker resolving that graph fails to load it at all.
+ *
+ * The judgment itself generates no code, so it runs wherever a run's owner
+ * does, and a response schema receives one verdict whichever boundary asks.
+ */
+
+export { prepareResponseValidator, ResponseSchemaError } from "./src/elicitation-schema.ts";
+export type { ResponseValidator } from "./src/elicitation-schema.ts";
+export type { NormalizedIssue } from "./src/validate.ts";
diff --git a/packages/core/host.ts b/packages/core/host.ts
index 0850b8202..d0d34fa05 100644
--- a/packages/core/host.ts
+++ b/packages/core/host.ts
@@ -70,6 +70,18 @@ export { executeInstalled } from "./src/execute.ts";
export type { ExecutionInstallation, JournalAdmission } from "./src/execute.ts";
export type { DurablePreparation } from "./src/document-request.ts";
+/**
+ * Text a host holds, as a root document reported by the path it came from.
+ *
+ * The same function the package root publishes, reached here because a host
+ * that supplies a root is often a host that cannot resolve the root barrel: it
+ * pulls the terminal renderer and the rest of the reader-facing surface, and a
+ * durable owner running inside a Worker has neither. This entrypoint already
+ * resolves canonical execution and nothing beyond it.
+ */
+export { retainedSource } from "./src/root-source.ts";
+export type { RetainedRootDocument } from "./src/root-source.ts";
+
/**
* What a trusted host declares to an execution when one of its components names
* durable work after its own invocation — see `src/invocation-identity.ts`.
@@ -130,3 +142,18 @@ export type {
*/
export { AGENT_PROMPT, parsePromptRecord } from "./src/agent/journal.ts";
export type { PromptRecord } from "./src/agent/journal.ts";
+
+/**
+ * The retained root-import protocol, for a host that reads a retained journal.
+ *
+ * Same reasoning as the Prompt record above, with more at stake. A workflow run
+ * decides from its own retained events whether a completed history may publish
+ * an outcome and be replayed, and which document that history was about — and
+ * the only thing that can answer that is the parser canonical execution admits
+ * partial histories through. Read a second way, the same record would answer to
+ * a second, weaker protocol: a selection the executor would refuse could
+ * publish a terminal outcome and authorize a replay. So the parser crosses the
+ * boundary rather than being described again.
+ */
+export { recordedRootImport } from "./src/root-selection.ts";
+export type { RootImportRecord, SelectionOutcome } from "./src/root-selection.ts";
diff --git a/packages/core/package.json b/packages/core/package.json
index 6f80ed609..59b4b394d 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -5,9 +5,15 @@
"type": "module",
"exports": {
".": "./mod.ts",
- "./host": "./host.ts"
+ "./canonicalize": "./canonicalize.ts",
+ "./component-name": "./component-name.ts",
+ "./document-target": "./document-target.ts",
+ "./host": "./host.ts",
+ "./elicitation": "./elicitation.ts",
+ "./secrets": "./secrets.ts"
},
"dependencies": {
+ "@cfworker/json-schema": "^4.1.1",
"@effectionx/context-api": "0.6.0",
"@effectionx/converge": "0.1.4",
"@effectionx/fetch": "0.2.1",
diff --git a/packages/core/secrets.ts b/packages/core/secrets.ts
new file mode 100644
index 000000000..6c3fe29d7
--- /dev/null
+++ b/packages/core/secrets.ts
@@ -0,0 +1,20 @@
+/**
+ * @module
+ *
+ * The configured secret gate, for runtimes that cannot load the package root.
+ *
+ * `createSecretScanner` is already public from the package root. This subpath
+ * exists so a consumer can select the gate without loading the root barrel,
+ * which reaches a terminal renderer and the rest of the host surface — a
+ * Cloudflare Worker resolving that graph fails to load it at all. The gate
+ * itself is the recommended Secretlint preset and this repository's own
+ * credential rule, and it generates no code, so it runs wherever the run's
+ * owner does.
+ *
+ * Same scanner, same rules, same findings. Narrower resolution path.
+ */
+
+export { createSecretScanner } from "./src/secrets/scanner.ts";
+export type { SecretScanner } from "./src/secrets/scanner.ts";
+export { SecretDetectedError } from "./src/secrets/findings.ts";
+export type { SecretFinding } from "./src/secrets/findings.ts";
diff --git a/packages/core/src/canonical.ts b/packages/core/src/canonical.ts
index 473065517..38b7c3f6e 100644
--- a/packages/core/src/canonical.ts
+++ b/packages/core/src/canonical.ts
@@ -7,6 +7,11 @@
* replay would stop matching. Sorting the keys before serializing is what makes
* the name depend on what the value *is*.
*
+ * The canonicalization itself lives in `./canonicalize.ts`, which names no
+ * host; this module adds the digest, which needs one. Both remain exported
+ * from the package root, and `@executablemd/core/canonicalize` publishes the
+ * pure half for consumers that cannot load a Node builtin.
+ *
* Callers compose their own identity and hash it here, rather than handing over
* a shape this module defines: what belongs in a fingerprint is a property of
* the thing being identified, and two callers disagree about it. ``
@@ -15,31 +20,10 @@
*/
import { createHash } from "node:crypto";
-import type { Json, JsonObject } from "./types.ts";
+import { canonicalize } from "./canonicalize.ts";
+import type { Json } from "./types.ts";
-/** The same value with every object's keys in sorted order. */
-export function canonicalize(value: Json): Json {
- if (Array.isArray(value)) {
- return value.map(canonicalize);
- }
- if (value === null || typeof value !== "object") {
- return value;
- }
- const sorted: JsonObject = {};
- for (const key of Object.keys(value).sort()) {
- // Defined rather than assigned: `sorted[key] = …` reaches
- // `Object.prototype`'s setter for `__proto__` and drops the key on Node and
- // Bun, so a schema declaring that name would canonicalize differently
- // depending on where it ran.
- Object.defineProperty(sorted, key, {
- value: canonicalize(value[key]),
- enumerable: true,
- writable: true,
- configurable: true,
- });
- }
- return sorted;
-}
+export { canonicalize };
/** The SHA-256 of a canonicalized value, as hex. */
export function canonicalFingerprint(value: Json): string {
diff --git a/packages/core/src/canonicalize.ts b/packages/core/src/canonicalize.ts
new file mode 100644
index 000000000..930688e29
--- /dev/null
+++ b/packages/core/src/canonicalize.ts
@@ -0,0 +1,41 @@
+/**
+ * A stable name for a JSON value, with no host behind it.
+ *
+ * Two values that differ only in key order are the same value, and
+ * `JSON.stringify` would otherwise make them different names — so a document
+ * that reordered a schema's properties would look like a different question and
+ * replay would stop matching. Sorting the keys before serializing is what makes
+ * the name depend on what the value *is*.
+ *
+ * This is a leaf on purpose. The operation is pure arithmetic over a JSON
+ * value, and it sat beside `canonicalFingerprint()`, which reaches
+ * `node:crypto` — so a runtime that has no Node builtins could not import one
+ * without the other, and a Cloudflare Worker that needs to canonicalize a
+ * record could not do it at all. Nothing here imports anything but a type.
+ */
+
+import type { Json, JsonObject } from "./types.ts";
+
+/** The same value with every object's keys in sorted order. */
+export function canonicalize(value: Json): Json {
+ if (Array.isArray(value)) {
+ return value.map(canonicalize);
+ }
+ if (value === null || typeof value !== "object") {
+ return value;
+ }
+ const sorted: JsonObject = {};
+ for (const key of Object.keys(value).sort()) {
+ // Defined rather than assigned: `sorted[key] = …` reaches
+ // `Object.prototype`'s setter for `__proto__` and drops the key on Node and
+ // Bun, so a schema declaring that name would canonicalize differently
+ // depending on where it ran.
+ Object.defineProperty(sorted, key, {
+ value: canonicalize(value[key]),
+ enumerable: true,
+ writable: true,
+ configurable: true,
+ });
+ }
+ return sorted;
+}
diff --git a/packages/core/src/component-name.ts b/packages/core/src/component-name.ts
new file mode 100644
index 000000000..634e7d181
--- /dev/null
+++ b/packages/core/src/component-name.ts
@@ -0,0 +1,18 @@
+/**
+ * How a document spells a component name, with nothing else behind it.
+ *
+ * The grammar registration is held to, offered as a predicate so a host
+ * deciding what a name may be does not restate it. It answers about spelling
+ * alone: a name that passes may still be structural syntax, a reserved
+ * registration, or a name nothing supplies.
+ *
+ * A leaf, so a consumer validating a retained name — a stored workflow
+ * definition checking its component bundle — does not load the registration
+ * machinery, or the engine behind it, to ask one question about a string.
+ */
+
+const SEGMENT = /^[A-Z][A-Za-z0-9_]*$/;
+
+export function isComponentName(name: string): boolean {
+ return name.length > 0 && name.split(".").every((segment) => SEGMENT.test(segment));
+}
diff --git a/packages/core/src/components/registration.ts b/packages/core/src/components/registration.ts
index 10b34314d..4f3f77420 100644
--- a/packages/core/src/components/registration.ts
+++ b/packages/core/src/components/registration.ts
@@ -17,6 +17,9 @@ import type { Context, Operation } from "effection";
import { Component } from "../component-api.ts";
import { updateOwn } from "../scope-local.ts";
import { RESERVED_STRUCTURAL } from "../structural.ts";
+import { isComponentName } from "../component-name.ts";
+
+export { isComponentName };
import { compilePropsSchema, compileReturnsSchema } from "../validate.ts";
import type {
ComponentRegistry,
@@ -79,20 +82,6 @@ const OwnContributions: Context = createContext(
new Map(),
);
-const SEGMENT = /^[A-Z][A-Za-z0-9_]*$/;
-
-/**
- * Whether `name` is spelled the way a document writes a component name.
- *
- * The grammar registration is held to, offered as a predicate so a host
- * deciding what a name may be does not restate it. It answers about spelling
- * alone: a name that passes may still be structural syntax, a reserved
- * registration, or a name nothing supplies.
- */
-export function isComponentName(name: string): boolean {
- return name.length > 0 && name.split(".").every((segment) => SEGMENT.test(segment));
-}
-
function kindOf(registration: ComponentRegistration): Kind {
return registration.reserved === true ? "reserved" : "default";
}
diff --git a/packages/core/src/document-target-spelling.ts b/packages/core/src/document-target-spelling.ts
new file mode 100644
index 000000000..370892b76
--- /dev/null
+++ b/packages/core/src/document-target-spelling.ts
@@ -0,0 +1,123 @@
+/**
+ * How an exact document target is spelled, with no host and no parser behind
+ * it.
+ *
+ * Percent-encoding a label, decoding one, normalizing it, and asking whether a
+ * fragment is already canonical are string arithmetic. They live apart from the
+ * catalog and selector machinery that uses them because a consumer that only
+ * needs to validate a retained target — a stored workflow definition checking
+ * the one it kept — should not have to load a Markdown parser, or a runtime
+ * that has one, to do it.
+ */
+
+const UNRESERVED = /^[A-Za-z0-9\-._~]$/;
+const HEX = /^[0-9A-Fa-f]$/;
+
+const ENCODER = new TextEncoder();
+
+function encodeCharacter(character: string): string {
+ let encoded = "";
+ for (const byte of ENCODER.encode(character)) {
+ encoded += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
+ }
+ return encoded;
+}
+
+/**
+ * Percent-encode one canonical label. Everything outside RFC 3986's unreserved
+ * set is escaped, so `/`, `*`, `#`, and `%` inside a heading cannot be read as
+ * hierarchy or operator syntax.
+ */
+export function encodeTargetLabel(label: string): string {
+ let encoded = "";
+ for (const character of label) {
+ encoded += UNRESERVED.test(character) ? character : encodeCharacter(character);
+ }
+ return encoded;
+}
+
+/**
+ * Percent-encode a decoded filesystem path. Separators survive as raw `/`; a
+ * `/` that is part of a filename cannot be told apart from one afterwards, so
+ * this is a formatter for paths the caller already holds, not a round trip.
+ */
+export function encodeDocumentPath(path: string): string {
+ let encoded = "";
+ for (const character of path) {
+ encoded +=
+ character === "/" || UNRESERVED.test(character) ? character : encodeCharacter(character);
+ }
+ return encoded;
+}
+
+/**
+ * Decode one percent-encoded chunk, or `undefined` when it is not decodable.
+ *
+ * Malformed escapes, byte sequences that are not UTF-8, and NUL are all
+ * refused rather than repaired: a selector that cannot be read exactly is not a
+ * selector this can match against. `+` is an ordinary character — this is URI
+ * path syntax, not a form encoding.
+ */
+export function decodePercentEncoded(text: string): string | undefined {
+ const characters = Array.from(text);
+ const bytes: number[] = [];
+ for (let index = 0; index < characters.length; index++) {
+ const character = characters[index]!;
+ if (character !== "%") {
+ for (const byte of ENCODER.encode(character)) {
+ bytes.push(byte);
+ }
+ continue;
+ }
+ const high = characters[index + 1];
+ const low = characters[index + 2];
+ if (high === undefined || low === undefined || !HEX.test(high) || !HEX.test(low)) {
+ return undefined;
+ }
+ bytes.push(Number.parseInt(`${high}${low}`, 16));
+ index += 2;
+ }
+ try {
+ // `ignoreBOM` is stated rather than defaulted: it is already false
+ // everywhere this runs, and Cloudflare's own type declares both options
+ // required, so saying it keeps one spelling readable to every runtime.
+ const decoded = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(
+ new Uint8Array(bytes),
+ );
+ return decoded.includes("\u0000") ? undefined : decoded;
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * The canonical form of rendered heading text: NFC, every run of Unicode
+ * whitespace collapsed to one ASCII space, trimmed, case preserved.
+ */
+export function normalizeLabel(text: string): string {
+ return text.normalize("NFC").replace(/\s+/gu, " ").trim();
+}
+
+/**
+ * Whether a fragment is already an exact canonical target.
+ *
+ * A level is canonical only when decoding it, normalizing the label, and
+ * re-encoding that label reproduce the level byte for byte. Requiring the whole
+ * round trip is what makes this total: it rejects a wildcard operator, an empty
+ * level, a lowercase escape, a raw `#`, an NFD spelling, a tab, and leading,
+ * trailing, or uncollapsed whitespace without naming any of them, because none
+ * of them is what this module would have written.
+ */
+export function isCanonicalTarget(target: string): boolean {
+ if (target.length === 0) {
+ return false;
+ }
+ return target.split("/").every((level) => {
+ const decoded = decodePercentEncoded(level);
+ if (decoded === undefined || decoded.length === 0) {
+ return false;
+ }
+ const label = normalizeLabel(decoded);
+ return label === decoded && encodeTargetLabel(label) === level;
+ });
+}
diff --git a/packages/core/src/document-targets.ts b/packages/core/src/document-targets.ts
index 6567a8519..6a5897c97 100644
--- a/packages/core/src/document-targets.ts
+++ b/packages/core/src/document-targets.ts
@@ -28,6 +28,21 @@ import { remark } from "remark";
import { toString as mdastToString } from "mdast-util-to-string";
import type { ComponentSpan } from "./scanner.ts";
+import {
+ decodePercentEncoded,
+ encodeDocumentPath,
+ encodeTargetLabel,
+ isCanonicalTarget,
+ normalizeLabel,
+} from "./document-target-spelling.ts";
+
+export {
+ decodePercentEncoded,
+ encodeDocumentPath,
+ encodeTargetLabel,
+ isCanonicalTarget,
+ normalizeLabel,
+};
/** A half-open slice of the original document body. */
export interface SourceRange {
@@ -518,113 +533,6 @@ function sameList(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((item, index) => item === right[index]);
}
-const UNRESERVED = /^[A-Za-z0-9\-._~]$/;
-const HEX = /^[0-9A-Fa-f]$/;
-
-const ENCODER = new TextEncoder();
-
-function encodeCharacter(character: string): string {
- let encoded = "";
- for (const byte of ENCODER.encode(character)) {
- encoded += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
- }
- return encoded;
-}
-
-/**
- * Percent-encode one canonical label. Everything outside RFC 3986's unreserved
- * set is escaped, so `/`, `*`, `#`, and `%` inside a heading cannot be read as
- * hierarchy or operator syntax.
- */
-export function encodeTargetLabel(label: string): string {
- let encoded = "";
- for (const character of label) {
- encoded += UNRESERVED.test(character) ? character : encodeCharacter(character);
- }
- return encoded;
-}
-
-/**
- * Percent-encode a decoded filesystem path. Separators survive as raw `/`; a
- * `/` that is part of a filename cannot be told apart from one afterwards, so
- * this is a formatter for paths the caller already holds, not a round trip.
- */
-export function encodeDocumentPath(path: string): string {
- let encoded = "";
- for (const character of path) {
- encoded +=
- character === "/" || UNRESERVED.test(character) ? character : encodeCharacter(character);
- }
- return encoded;
-}
-
-/**
- * Decode one percent-encoded chunk, or `undefined` when it is not decodable.
- *
- * Malformed escapes, byte sequences that are not UTF-8, and NUL are all
- * refused rather than repaired: a selector that cannot be read exactly is not a
- * selector this can match against. `+` is an ordinary character — this is URI
- * path syntax, not a form encoding.
- */
-export function decodePercentEncoded(text: string): string | undefined {
- const characters = Array.from(text);
- const bytes: number[] = [];
- for (let index = 0; index < characters.length; index++) {
- const character = characters[index]!;
- if (character !== "%") {
- for (const byte of ENCODER.encode(character)) {
- bytes.push(byte);
- }
- continue;
- }
- const high = characters[index + 1];
- const low = characters[index + 2];
- if (high === undefined || low === undefined || !HEX.test(high) || !HEX.test(low)) {
- return undefined;
- }
- bytes.push(Number.parseInt(`${high}${low}`, 16));
- index += 2;
- }
- try {
- const decoded = new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes));
- return decoded.includes("\u0000") ? undefined : decoded;
- } catch {
- return undefined;
- }
-}
-
-/**
- * The canonical form of rendered heading text: NFC, every run of Unicode
- * whitespace collapsed to one ASCII space, trimmed, case preserved.
- */
-export function normalizeLabel(text: string): string {
- return text.normalize("NFC").replace(/\s+/gu, " ").trim();
-}
-
-/**
- * Whether a fragment is already an exact canonical target.
- *
- * A level is canonical only when decoding it, normalizing the label, and
- * re-encoding that label reproduce the level byte for byte. Requiring the whole
- * round trip is what makes this total: it rejects a wildcard operator, an empty
- * level, a lowercase escape, a raw `#`, an NFD spelling, a tab, and leading,
- * trailing, or uncollapsed whitespace without naming any of them, because none
- * of them is what this module would have written.
- */
-export function isCanonicalTarget(target: string): boolean {
- if (target.length === 0) {
- return false;
- }
- return target.split("/").every((level) => {
- const decoded = decodePercentEncoded(level);
- if (decoded === undefined || decoded.length === 0) {
- return false;
- }
- const label = normalizeLabel(decoded);
- return label === decoded && encodeTargetLabel(label) === level;
- });
-}
-
type LevelPart =
| { readonly kind: "literal"; readonly text: string }
| { readonly kind: "wildcard" };
diff --git a/packages/core/src/draft-07-meta-schema.ts b/packages/core/src/draft-07-meta-schema.ts
new file mode 100644
index 000000000..b8cf8ee07
--- /dev/null
+++ b/packages/core/src/draft-07-meta-schema.ts
@@ -0,0 +1,107 @@
+/**
+ * The draft-07 meta-schema, as published.
+ *
+ * A response schema is admitted by validating it against this, the way the
+ * compiler this replaced admitted one with `validateSchema: true`. Carrying it
+ * here rather than fetching it is the only way a run's owner can admit a schema
+ * at all: it resolves no references and reaches no network.
+ *
+ * Transcribed from . Its own `$id` and
+ * `$schema` are kept so a schema that declares `"$schema": "…draft-07/schema#"`
+ * is describing this exact document.
+ */
+
+import type { Json } from "./types.ts";
+
+export const DRAFT_07_META_SCHEMA: Json = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ $id: "http://json-schema.org/draft-07/schema#",
+ title: "Core schema meta-schema",
+ definitions: {
+ schemaArray: { type: "array", minItems: 1, items: { $ref: "#" } },
+ nonNegativeInteger: { type: "integer", minimum: 0 },
+ nonNegativeIntegerDefault0: {
+ allOf: [{ $ref: "#/definitions/nonNegativeInteger" }, { default: 0 }],
+ },
+ simpleTypes: {
+ enum: ["array", "boolean", "integer", "null", "number", "object", "string"],
+ },
+ stringArray: {
+ type: "array",
+ items: { type: "string" },
+ uniqueItems: true,
+ default: [],
+ },
+ },
+ type: ["object", "boolean"],
+ properties: {
+ $id: { type: "string", format: "uri-reference" },
+ $schema: { type: "string", format: "uri" },
+ $ref: { type: "string", format: "uri-reference" },
+ $comment: { type: "string" },
+ title: { type: "string" },
+ description: { type: "string" },
+ default: true,
+ readOnly: { type: "boolean", default: false },
+ writeOnly: { type: "boolean", default: false },
+ examples: { type: "array", items: true },
+ multipleOf: { type: "number", exclusiveMinimum: 0 },
+ maximum: { type: "number" },
+ exclusiveMaximum: { type: "number" },
+ minimum: { type: "number" },
+ exclusiveMinimum: { type: "number" },
+ maxLength: { $ref: "#/definitions/nonNegativeInteger" },
+ minLength: { $ref: "#/definitions/nonNegativeIntegerDefault0" },
+ pattern: { type: "string", format: "regex" },
+ additionalItems: { $ref: "#" },
+ items: { anyOf: [{ $ref: "#" }, { $ref: "#/definitions/schemaArray" }], default: true },
+ maxItems: { $ref: "#/definitions/nonNegativeInteger" },
+ minItems: { $ref: "#/definitions/nonNegativeIntegerDefault0" },
+ uniqueItems: { type: "boolean", default: false },
+ contains: { $ref: "#" },
+ maxProperties: { $ref: "#/definitions/nonNegativeInteger" },
+ minProperties: { $ref: "#/definitions/nonNegativeIntegerDefault0" },
+ required: { $ref: "#/definitions/stringArray" },
+ additionalProperties: { $ref: "#" },
+ definitions: { type: "object", additionalProperties: { $ref: "#" }, default: {} },
+ properties: { type: "object", additionalProperties: { $ref: "#" }, default: {} },
+ patternProperties: {
+ type: "object",
+ additionalProperties: { $ref: "#" },
+ propertyNames: { format: "regex" },
+ default: {},
+ },
+ dependencies: {
+ type: "object",
+ additionalProperties: {
+ anyOf: [{ $ref: "#" }, { $ref: "#/definitions/stringArray" }],
+ },
+ },
+ propertyNames: { $ref: "#" },
+ const: true,
+ enum: { type: "array", items: true },
+ type: {
+ anyOf: [
+ { $ref: "#/definitions/simpleTypes" },
+ {
+ type: "array",
+ items: { $ref: "#/definitions/simpleTypes" },
+ minItems: 1,
+ uniqueItems: true,
+ },
+ ],
+ },
+ format: { type: "string" },
+ contentMediaType: { type: "string" },
+ contentEncoding: { type: "string" },
+ if: { $ref: "#" },
+ // oxlint-disable-next-line unicorn/no-thenable
+ then: { $ref: "#" },
+ else: { $ref: "#" },
+ allOf: { $ref: "#/definitions/schemaArray" },
+ anyOf: { $ref: "#/definitions/schemaArray" },
+ oneOf: { $ref: "#/definitions/schemaArray" },
+ not: { $ref: "#" },
+ },
+ default: true,
+};
diff --git a/packages/core/src/elicit.ts b/packages/core/src/elicit.ts
index 9c1223e64..92d3349c5 100644
--- a/packages/core/src/elicit.ts
+++ b/packages/core/src/elicit.ts
@@ -19,20 +19,13 @@
* retry belongs in visible Markdown control flow.
*/
-import type { ValidateFunction } from "ajv";
import type { Operation } from "effection";
import { Elicitation } from "./elicitation-api.ts";
import type { ElicitationRequest } from "./elicitation-api.ts";
-import {
- ParseSchemaError,
- compileParseSchema,
- readParseSchema,
- validateParsed,
-} from "./components/parse-schema.ts";
+import { prepareResponseValidator } from "./elicitation-schema.ts";
+import type { ResponseValidator } from "./elicitation-schema.ts";
import { parseJson } from "./json.ts";
-import { walkSchema } from "./schema-walk.ts";
-import type { NameKind } from "./schema-walk.ts";
import { SchemaValidationError } from "./validate.ts";
import type { NormalizedIssue } from "./validate.ts";
import type { Json, JsonObject } from "./types.ts";
@@ -48,30 +41,37 @@ export class ElicitValidationError extends SchemaValidationError {
}
}
-/** A compiled question. Nothing has been asked yet. */
+/** A prepared question. Nothing has been asked yet. */
export interface PreparedElicitation {
/** Normalized draft-07, as the provider will receive it. */
schema: JsonObject;
- validate: ValidateFunction;
+ /**
+ * What judges a response against this schema.
+ *
+ * The repository's own contract rather than a validator library's type: the
+ * same judgment runs at every boundary that decides a response, including a
+ * run's owner, and none of them may depend on which library is underneath.
+ */
+ validator: ResponseValidator;
label: string;
}
/**
- * Normalize and compile a question's schema.
+ * Normalize and admit a question's schema.
*
* Synchronous and effect-free: it either produces a question that can be asked
- * or throws, and a caller that has not yet begun anything can still stop.
+ * or throws, and a caller that has not yet begun anything can still stop. The
+ * judgment it prepares is the one every boundary makes — a document's provider
+ * answer here, a workflow answer delivered locally, and a workflow answer
+ * retained by a run's owner somewhere else.
*/
+// deno-lint-ignore require-yield
export function* prepareElicitation(
schema: Json,
label: string = DEFAULT_LABEL,
): Operation {
- const declaration = readParseSchema(label, schema);
-
- refuseUnsupportedNames(label, declaration);
- refuseExternalReferences(label, declaration);
-
- return { schema: declaration, validate: yield* compileParseSchema(label, declaration), label };
+ const validator = prepareResponseValidator(label, schema);
+ return { schema: validator.schema, validator, label };
}
/** Ask the configured provider, and judge what it returns. */
@@ -86,7 +86,7 @@ export function* runPreparedElicitation(
// back is `unknown` until this boundary has walked it.
const response = parseJson(answer);
- const issues = validateParsed(prepared.validate, response);
+ const issues = prepared.validator.judge(response);
if (issues.length > 0) {
throw new ElicitValidationError(prepared.label, issues);
}
@@ -104,68 +104,3 @@ export function* elicit(request: {
request.message,
);
}
-
-/**
- * Refuse `__proto__` where a schema declares it as a name.
- *
- * Two reasons, and either alone would be enough. A validated response binds
- * into the evaluation environment, and a schema is how a document says it
- * expects that name — so the safest moment to say the name is unsupported is
- * before anyone is asked for a value carrying it.
- *
- * The other is that the underlying validator loses it. Ajv builds its internal
- * tables from schema keys by assignment, so `properties: { "__proto__": … }`
- * compiles and then never applies: a response carrying that key is judged as
- * though the property had never been declared, and under
- * `additionalProperties: false` it is rejected outright. `dependencies` compiles
- * and never applies; `required` is refused by strict mode. None of those is a
- * failure a document could see or work around.
- *
- * The same string as *data* — a `const`, an `enum` member, a title, a default —
- * is untouched, because nothing reads it as a key.
- */
-function refuseUnsupportedNames(label: string, schema: JsonObject): void {
- walkSchema(schema, {
- subschema() {},
- declaredName(name: string, kind: NameKind, path: string) {
- if (name !== "__proto__") {
- return;
- }
- throw new ParseSchemaError(
- `<${label} /> schema declares "__proto__" as a ${kind} at ${path}, which is not ` +
- "supported: the underlying validator loses that name, so the rule would " +
- "silently not apply. Rename it, or carry the value under a different key.",
- );
- },
- });
-}
-
-/**
- * Refuse a reference that leaves the document.
- *
- * Ajv reports an unreachable external reference and a mistyped local pointer
- * with the same `can't resolve reference` message, so the two are told apart
- * here — by the shape of the reference itself — rather than by reading an error
- * string. A local pointer that does not resolve is still Ajv's to report, and
- * `compileParseSchema` names it.
- *
- * `$ref` is read only at real schema positions. An object carrying `$ref` inside
- * a `const` or an `enum` member is a JSON value the author wants matched, not a
- * reference, and Ajv never resolves it — so neither does this.
- */
-function refuseExternalReferences(label: string, schema: JsonObject): void {
- walkSchema(schema, {
- subschema(subschema: JsonObject, path: string) {
- const reference = subschema["$ref"];
- if (typeof reference !== "string" || reference.startsWith("#")) {
- return;
- }
- throw new ParseSchemaError(
- `<${label} /> schema references "${reference}" at ${path}, which is outside the ` +
- "supplied schema. Only references contained within it resolve; external file " +
- "and HTTP(S) references are deferred to #192.",
- );
- },
- declaredName() {},
- });
-}
diff --git a/packages/core/src/elicitation-schema.ts b/packages/core/src/elicitation-schema.ts
new file mode 100644
index 000000000..eee99b53c
--- /dev/null
+++ b/packages/core/src/elicitation-schema.ts
@@ -0,0 +1,506 @@
+/**
+ * The one judgment an elicitation response is held to, wherever it is judged.
+ *
+ * A response schema is decided in more places than the document that declared
+ * it: `` judges what a provider returns, `xmd prompt` judges the same,
+ * a workflow answer is judged before it is retained locally, and a run whose
+ * owner is a Cloudflare Durable Object judges it there — inside the transaction
+ * that writes it, because a caller that decides for itself decides nothing.
+ *
+ * That last place is why this exists. A Worker refuses code generation from
+ * strings during a request, and a schema learned from a retained wait cannot be
+ * compiled ahead of time, so a validator that generates code cannot be the one
+ * the owner runs. One that does not generate code can be the one *everybody*
+ * runs, which is the point: the same schema and the same value receive one
+ * verdict, whichever boundary asks.
+ *
+ * ## What preparation refuses, before anything is asked
+ *
+ * Preparation is everything that can fail cheaply, so a schema that cannot be
+ * used fails before a question is rendered and before a provider is contacted:
+ *
+ * - a schema that is not a JSON Schema object, in either accepted form;
+ * - `$async`, `__proto__` as a declared name, a reference that leaves the
+ * supplied schema, and a keyword draft-07 does not define; and
+ * - a schema the draft-07 meta-schema itself refuses.
+ *
+ * ## `format` annotates and constrains nothing
+ *
+ * The compiler this replaces ran with `validateFormats: false`, so a `format`
+ * carried in a schema described the value to whoever answers and never decided
+ * whether an answer was admitted. The validator underneath does apply formats,
+ * so what it is given is a copy with them removed — the schema a provider
+ * receives keeps them, because saying "this is an email" is the point of
+ * writing it.
+ */
+
+import { dereference, validate, Validator } from "@cfworker/json-schema";
+import type { OutputUnit, Schema } from "@cfworker/json-schema";
+import { DRAFT_07_META_SCHEMA } from "./draft-07-meta-schema.ts";
+import { parseJson, parseJsonObject } from "./json.ts";
+import type { NormalizedIssue } from "./validate.ts";
+import { detach, mapSchema, walkSchema } from "./schema-walk.ts";
+import type { NameKind } from "./schema-walk.ts";
+import type { Json, JsonObject } from "./types.ts";
+
+/** A schema that could not be read or admitted. Raised before anything runs. */
+export class ResponseSchemaError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ResponseSchemaError";
+ }
+}
+
+/**
+ * One prepared response schema: what a provider is shown, and what judges it.
+ *
+ * `judge` returns the issues rather than raising them, because both callers
+ * want them — one turns them into an error and one hands them to a document.
+ */
+export interface ResponseValidator {
+ /** Normalized draft-07, as the provider will receive it. */
+ readonly schema: JsonObject;
+ /** Empty when the value satisfies the schema. */
+ judge(value: Json): NormalizedIssue[];
+}
+
+/**
+ * Every keyword draft-07 defines, plus the annotations it allows.
+ *
+ * Closed on purpose. A schema carrying something else is refused rather than
+ * validated with that keyword ignored: an unimplemented constraint that reads
+ * as satisfied is exactly the failure this whole boundary exists to prevent.
+ */
+const DRAFT_07 = new Set([
+ "$id",
+ "$schema",
+ "$ref",
+ "$comment",
+ "title",
+ "description",
+ "default",
+ "readOnly",
+ "writeOnly",
+ "examples",
+ "definitions",
+ "multipleOf",
+ "maximum",
+ "exclusiveMaximum",
+ "minimum",
+ "exclusiveMinimum",
+ "maxLength",
+ "minLength",
+ "pattern",
+ "additionalItems",
+ "items",
+ "maxItems",
+ "minItems",
+ "uniqueItems",
+ "contains",
+ "maxProperties",
+ "minProperties",
+ "required",
+ "additionalProperties",
+ "properties",
+ "patternProperties",
+ "dependencies",
+ "propertyNames",
+ "const",
+ "enum",
+ "type",
+ "format",
+ "contentMediaType",
+ "contentEncoding",
+ "if",
+ "then",
+ "else",
+ "allOf",
+ "anyOf",
+ "oneOf",
+ "not",
+]);
+
+/**
+ * Read and admit one response schema, and hand back what judges values by it.
+ *
+ * Synchronous and effect-free: it either produces a usable judgment or raises,
+ * and a caller that has not begun anything can still stop. Everything that
+ * makes a schema unusable is decided here — including a reference whose target
+ * does not exist, which no value would have to visit to be wrong about.
+ */
+export function prepareResponseValidator(label: string, schema: Json): ResponseValidator {
+ const declaration = readSchema(label, schema);
+
+ refuseUnusable(label, declaration);
+ admitDraft07(label, declaration);
+
+ // The copy the validator is given. Schema positions lose `format`, because a
+ // format annotates and constrains nothing here; data positions and declared
+ // names are carried across untouched, so an object under a `const` and a
+ // property whose authored name is `format` both survive exactly.
+ const judged = mapSchema(declaration, (subschema) => omitFormat(subschema));
+
+ let lookup: Record;
+ try {
+ lookup = dereference(judged);
+ } catch (error) {
+ throw new ResponseSchemaError(
+ `<${label} /> schema could not be read as draft-07: ${bounded(error)}`,
+ );
+ }
+ requireResolvableReferences(label, judged, lookup);
+
+ return {
+ schema: declaration,
+ judge(value: Json): NormalizedIssue[] {
+ // The value the validator sees is own-keyed all the way down, so a name
+ // the language answers for — `toString`, `constructor`, `__proto__` — is
+ // present only when the value actually holds it. The value a caller keeps
+ // is untouched.
+ const outcome = validate(detach(value), judged, "7", lookup, false);
+ return outcome.valid ? [] : normalize(outcome.errors);
+ },
+ };
+}
+
+/** One schema position, without the annotation that constrains nothing. */
+function omitFormat(schema: JsonObject): JsonObject {
+ if (!Object.hasOwn(schema, "format")) {
+ return schema;
+ }
+ const kept: JsonObject = {};
+ for (const [keyword, value] of Object.entries(schema)) {
+ if (keyword !== "format") {
+ kept[keyword] = value;
+ }
+ }
+ return kept;
+}
+
+/**
+ * Refuse a reference whose target is not in the supplied schema.
+ *
+ * Resolved statically, at every real schema position, rather than discovered by
+ * a value that happens to reach it: a branch nothing sampled still has to be
+ * usable, and the settled boundary is that an unusable schema fails before
+ * content expands and before a provider is contacted.
+ *
+ * The lookup is the one the validator itself will use, so what resolves here is
+ * exactly what resolves there. It stays inside this adapter.
+ */
+function requireResolvableReferences(
+ label: string,
+ schema: JsonObject,
+ lookup: Record,
+): void {
+ walkSchema(schema, {
+ subschema(subschema: JsonObject, path: string) {
+ const reference = subschema["$ref"];
+ if (typeof reference !== "string") {
+ return;
+ }
+ // `dereference` records the absolute form it resolved against, which is
+ // the key the validator looks up. Falling back to the written reference
+ // covers a position it did not annotate.
+ const absolute = subschema["__absolute_ref__"];
+ const key = typeof absolute === "string" ? absolute : reference;
+ if (!Object.hasOwn(lookup, key)) {
+ throw new ResponseSchemaError(
+ `<${label} /> schema references "${reference}" at ${path}, which the supplied ` +
+ "schema does not define.",
+ );
+ }
+ },
+ declaredName() {},
+ });
+}
+
+/**
+ * The meta-schema every admitted response schema is itself validated against.
+ *
+ * Built once, because it is one constant document. This is what the compiler
+ * this replaces did with `validateSchema: true`: a schema that is not a
+ * draft-07 schema fails before a question is rendered, rather than being
+ * carried as far as a value nobody can judge.
+ */
+const META = new Validator(metaSchema(), "7", false);
+
+function metaSchema(): Schema {
+ const held: Record = {};
+ for (const [name, value] of Object.entries(parseJsonObject(DRAFT_07_META_SCHEMA))) {
+ held[name] = value;
+ }
+ return held;
+}
+
+function admitDraft07(label: string, schema: JsonObject): void {
+ const outcome = META.validate(schema);
+ if (outcome.valid) {
+ return;
+ }
+ const first = normalize(outcome.errors)[0];
+ const where =
+ first === undefined || first.instancePath === "" ? "the schema" : first.instancePath;
+ throw new ResponseSchemaError(
+ `<${label} /> schema is not a valid draft-07 JSON Schema: ${where} ${first?.message ?? ""}`,
+ );
+}
+
+/**
+ * What one failure says, in this repository's words rather than a library's.
+ *
+ * Every message here is bounded and carries no payload: not the rejected value,
+ * not a threshold, a pattern, an allowed value or an enum. An issue travels
+ * further than the document that produced it — it is bound into the evaluation
+ * environment, printed, and carried across a journal — so what it may say is
+ * where the failure is and which rule was not met.
+ *
+ * `required` is the one that names something, and what it names is the absent
+ * member: the instance location is the object, so without the name there is no
+ * way to say which member is missing. The name is a name the schema declares
+ * and the value does not hold.
+ */
+function described(unit: OutputUnit): string {
+ if (unit.keyword === "required") {
+ const named = /"([^"]*)"/.exec(unit.error);
+ return named === null
+ ? "must have every property this schema requires"
+ : `must have the required property "${named[1]}"`;
+ }
+ return DESCRIPTIONS[unit.keyword] ?? "does not satisfy this schema";
+}
+
+const DESCRIPTIONS: Record = {
+ type: "must be of the type this schema declares",
+ enum: "must be one of the values this schema allows",
+ const: "must be the value this schema requires",
+ minimum: "must not be below the minimum this schema declares",
+ maximum: "must not be above the maximum this schema declares",
+ exclusiveMinimum: "must be above the exclusive minimum this schema declares",
+ exclusiveMaximum: "must be below the exclusive maximum this schema declares",
+ multipleOf: "must be a multiple of the step this schema declares",
+ minLength: "must not be shorter than this schema allows",
+ maxLength: "must not be longer than this schema allows",
+ pattern: "must match the pattern this schema declares",
+ minItems: "must not have fewer items than this schema allows",
+ maxItems: "must not have more items than this schema allows",
+ uniqueItems: "must not repeat an item",
+ contains: "must contain an item this schema admits",
+ minProperties: "must not have fewer properties than this schema allows",
+ maxProperties: "must not have more properties than this schema allows",
+ additionalProperties: "must not have properties this schema does not declare",
+ additionalItems: "must not have items this schema does not declare",
+ propertyNames: "must have property names this schema admits",
+ dependencies: "must satisfy the dependencies this schema declares",
+ false: "is not admitted here",
+ not: "must not be what this schema excludes",
+ oneOf: "must satisfy exactly one of the alternatives this schema allows",
+ anyOf: "must satisfy one of the alternatives this schema allows",
+ allOf: "must satisfy every alternative this schema requires",
+ if: "must satisfy the branch this schema selects",
+ // oxlint-disable-next-line unicorn/no-thenable
+ then: "must satisfy the branch this schema selects",
+ else: "must satisfy the branch this schema selects",
+ $ref: "must satisfy the schema this one references",
+ format: "must match the format this schema declares",
+};
+
+/**
+ * The failures that describe the value, without the ones that only wrap them.
+ *
+ * A keyword that contains another reports its own failure as well as the
+ * failure inside it — `properties` failing because `/a` failed. Those wrappers
+ * are dropped, and only those: a failure is a wrapper of another only when it
+ * is above it in the schema *and* at or above it in the value. An independent
+ * rule at the same position as a wrapper — `minProperties` beside `properties`
+ * — is neither, and survives.
+ */
+function normalize(units: readonly OutputUnit[]): NormalizedIssue[] {
+ const kept = withoutRestatement(
+ units.filter((unit) => !units.some((other) => wraps(unit, other))),
+ );
+ const seen = new Set();
+ const issues: NormalizedIssue[] = [];
+ for (const unit of kept) {
+ const key = JSON.stringify([unit.instanceLocation, unit.keywordLocation, unit.keyword]);
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ issues.push({
+ instancePath: pointerOf(unit.instanceLocation),
+ schemaPath: unit.keywordLocation,
+ keyword: unit.keyword,
+ // Deliberately empty. A library's parameters carry the schema and, for
+ // `const` and `enum`, the values themselves.
+ params: {},
+ message: described(unit),
+ });
+ }
+ return issues;
+}
+
+/**
+ * The same failures, without a member's own failure restated as an extra one.
+ *
+ * A declared member that fails its own rule counts as unevaluated where the
+ * validator tracks that, so `additionalProperties: false` fires on it too and
+ * says the value carries a member the schema does not declare. It does not: the
+ * member is declared and its own failure is already reported. So a boolean
+ * refusal at a location something else explains is dropped, and the
+ * `additionalProperties` above it is dropped when nothing it refused survives.
+ */
+function withoutRestatement(units: readonly OutputUnit[]): OutputUnit[] {
+ const explained = new Set(
+ units
+ .filter((unit) => unit.keyword !== "false" && unit.keyword !== "additionalProperties")
+ .map((unit) => unit.instanceLocation),
+ );
+ const refusals = units.filter(
+ (unit) => unit.keyword === "false" && !explained.has(unit.instanceLocation),
+ );
+ return units.filter((unit) => {
+ if (unit.keyword === "false") {
+ return !explained.has(unit.instanceLocation);
+ }
+ if (unit.keyword === "additionalProperties") {
+ return refusals.some((refusal) => below(unit.instanceLocation, refusal.instanceLocation));
+ }
+ return true;
+ });
+}
+
+/** Whether one failure is only the wrapper of another. */
+function wraps(unit: OutputUnit, other: OutputUnit): boolean {
+ if (unit === other) {
+ return false;
+ }
+ return (
+ below(unit.keywordLocation, other.keywordLocation) &&
+ (unit.instanceLocation === other.instanceLocation ||
+ below(unit.instanceLocation, other.instanceLocation))
+ );
+}
+
+/** Whether `inner` sits beneath `outer` in a location, on a segment boundary. */
+function below(outer: string, inner: string): boolean {
+ return inner.startsWith(`${outer}/`);
+}
+
+/**
+ * One instance location, as the raw JSON pointer this repository reports.
+ *
+ * The validator writes locations as URI fragments: each token is escaped for
+ * JSON Pointer and then encoded for a URI. Undoing the URI encoding token by
+ * token gives the pointer back, with `~0` and `~1` left alone because those are
+ * the pointer's own escapes and a literal `%` decoded because it was encoded.
+ */
+function pointerOf(location: string): string {
+ const withoutFragment = location.startsWith("#") ? location.slice(1) : location;
+ if (withoutFragment === "") {
+ return "";
+ }
+ return withoutFragment
+ .split("/")
+ .map((token) => {
+ try {
+ return decodeURIComponent(token);
+ } catch {
+ return token;
+ }
+ })
+ .join("/");
+}
+
+/** What a public error may say about a failure this adapter did not classify. */
+function bounded(error: unknown): string {
+ const message = error instanceof Error ? error.message : String(error);
+ return message.length > 200 ? `${message.slice(0, 200)}…` : message;
+}
+
+/**
+ * Everything one walk of a schema refuses, in one walk.
+ *
+ * Each of these is a reason a schema cannot judge an answer, and all of them
+ * are decided before a question is rendered or a provider is contacted:
+ *
+ * - `$async` would make validation something to await, and props, returns and
+ * this are judged synchronously;
+ * - `__proto__` declared as a name is a rule no validator applies faithfully,
+ * because a validator reached through an object's own keys loses it — the
+ * same string as *data*, in a `const`, an `enum` member, a title or a
+ * default, is untouched, because nothing reads it as a key;
+ * - a `$ref` that leaves the supplied schema resolves to nothing here, and
+ * external file and HTTP(S) references are deferred to #192 — `$ref` is read
+ * only at real schema positions, so an object carrying one inside a `const`
+ * is the value a document wants matched; and
+ * - a keyword draft-07 does not define constrains nothing, so a document that
+ * wrote one is told rather than quietly given a validation it did not get.
+ */
+function refuseUnusable(label: string, schema: JsonObject): void {
+ walkSchema(schema, {
+ subschema(subschema: JsonObject, path: string) {
+ if (subschema["$async"] === true) {
+ throw new ResponseSchemaError(
+ `<${label} /> does not support an asynchronous schema ($async: true) at ${path}.`,
+ );
+ }
+ const reference = subschema["$ref"];
+ if (typeof reference === "string" && !reference.startsWith("#")) {
+ throw new ResponseSchemaError(
+ `<${label} /> schema references "${reference}" at ${path}, which is outside the ` +
+ "supplied schema. Only references contained within it resolve; external file " +
+ "and HTTP(S) references are deferred to #192.",
+ );
+ }
+ for (const keyword of Object.keys(subschema)) {
+ if (!DRAFT_07.has(keyword)) {
+ throw new ResponseSchemaError(
+ `<${label} /> schema uses "${keyword}" at ${path}, which draft-07 does not ` +
+ "define. An unknown keyword constrains nothing, so it is refused rather than " +
+ "ignored.",
+ );
+ }
+ }
+ },
+ declaredName(name: string, kind: NameKind, path: string) {
+ if (name !== "__proto__") {
+ return;
+ }
+ throw new ResponseSchemaError(
+ `<${label} /> schema declares "__proto__" as a ${kind} at ${path}, which is not ` +
+ "supported: a validator reached through an object's own keys loses that name, so " +
+ "the rule would silently not apply. Rename it, or carry the value under a " +
+ "different key.",
+ );
+ },
+ });
+}
+
+/** The issues as the JSON a document binds, parsed rather than asserted. */
+export function responseIssuesAsJson(issues: readonly NormalizedIssue[]): Json {
+ return parseJson(issues);
+}
+
+function readSchema(label: string, schema: Json): JsonObject {
+ if (typeof schema === "string") {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(schema);
+ } catch (error) {
+ throw new ResponseSchemaError(`<${label} /> schema text is not JSON: ${bounded(error)}`);
+ }
+ return asSchemaObject(label, parsed);
+ }
+ return asSchemaObject(label, schema);
+}
+
+function asSchemaObject(label: string, value: unknown): JsonObject {
+ try {
+ return parseJsonObject(value);
+ } catch {
+ throw new ResponseSchemaError(
+ `<${label} /> schema must be a JSON Schema object or JSON text describing one.`,
+ );
+ }
+}
diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts
index 8ce006d69..793708161 100644
--- a/packages/core/src/execute.ts
+++ b/packages/core/src/execute.ts
@@ -50,6 +50,8 @@ import type {
SourcePosition,
} from "./types.ts";
import { isJsonObject, parseJson, parseJsonObject } from "./json.ts";
+import { attempt, read, recordedRootImport, UNREADABLE } from "./root-selection.ts";
+import type { SelectionOutcome } from "./root-selection.ts";
import {
compilePropsSchema,
compileReturnsSchema,
@@ -58,7 +60,6 @@ import {
} from "./validate.ts";
import { useParseCompiler } from "./components/parse-schema.ts";
import {
- documentOutline,
isFunctionComponentPath,
parseMarkdownDefinition,
parseRootMarkdownDefinition,
@@ -503,20 +504,6 @@ function isFunctionComponent(value: unknown): value is FunctionComponent {
return typeof value === "function";
}
-/**
- * What one run's selector decided: the whole document, one exact section, or a
- * failure that named none.
- *
- * Selection is compared as an outcome rather than as a target string, because a
- * failed selection is an outcome too. Without the third case a journal written
- * by one selector that matched nothing would answer a later request for a
- * section that does exist.
- */
-type SelectionOutcome =
- | { kind: "whole" }
- | { kind: "exact"; target: string }
- | { kind: "failed"; failure: DocumentTargetFailure };
-
/**
* What the fixed diagnostic says when a recorded root import cannot be read,
* and all it says.
@@ -540,174 +527,6 @@ const DIFFERENT_PRE_ROOT_DOCUMENT =
const ROOT_COROUTINE = "root";
-/**
- * What a recorded event turned out to be.
- *
- * "Not the root import" and "the root import, malformed" are deliberately
- * different answers. Collapsing them into one absent value is what would let a
- * corrupted record fall through to the recorded terminal result, which is the
- * failure this distinction exists to prevent.
- */
-type RootImportRecord =
- | { kind: "unrelated" }
- | { kind: "malformed" }
- | { kind: "read"; outline: DocumentOutline; selection: SelectionOutcome };
-
-const UNRELATED: RootImportRecord = { kind: "unrelated" };
-const MALFORMED: RootImportRecord = { kind: "malformed" };
-
-/**
- * Read a value that may refuse to be read.
- *
- * Every value this boundary touches comes from the journal, and a journal is
- * data: a property may be an accessor that throws, a key list may come from a
- * Proxy that refuses, and content may be markdown whose frontmatter no parser
- * accepts. None of those is a failure of this run — they are ways of saying the
- * record cannot be read — so none of them may travel as an error of its own.
- *
- * Synchronous throughout, so nothing an Effection scope owns passes through
- * here: this cannot swallow a cancellation or a durability failure, because
- * neither can arise inside a synchronous parse.
- */
-function attempt(read: () => T): T | undefined {
- try {
- return read();
- } catch {
- return undefined;
- }
-}
-
-/**
- * Parse a recorded root import as a closed protocol.
- *
- * Two selection shapes are supported and nothing else: a repository selection
- * with an optional canonical target, and a failed selection with an exact
- * failure record. An unknown kind, a missing or mistyped member, an extra
- * member, a noncanonical target, and failure data that no selection could have
- * produced are each malformed rather than absent.
- *
- * A result that is not `ok` is left alone. A root import can fail for reasons
- * that have nothing to do with selection — an unreadable file — and those
- * recorded failures are not this protocol's to interpret.
- */
-/**
- * A value the journal refused to produce.
- *
- * Distinct from `undefined`, which is an ordinary absent value. Reading a
- * member and finding nothing there, and reading a member that will not say what
- * is there, are different facts about a record, and one of them is a refusal:
- * conflating them is how "the root import will not say what it settled to"
- * became "this is not the root import" and fell through to terminal-result
- * reuse.
- */
-const UNREADABLE: unique symbol = Symbol("unreadable");
-
-/** One read of journal-controlled data: its value, or a refusal. */
-function read(get: () => T): T | typeof UNREADABLE {
- try {
- return get();
- } catch {
- return UNREADABLE;
- }
-}
-
-/** The settlements the protocol recognizes as an ordinary failed root import. */
-const SETTLED_FAILURES: readonly string[] = ["err", "cancelled"];
-
-function recordedRootImport(event: Yield): RootImportRecord {
- // Identification first. An event that will not say what it is cannot be
- // claimed as the root import, so it stays unrelated.
- const description = read(() => event.description);
- if (description === UNREADABLE) {
- return UNRELATED;
- }
- const type = read(() => description.type);
- const name = read(() => description.name);
- if (type !== "import_component" || name !== "__root__") {
- return UNRELATED;
- }
-
- // Identified. From here the event owes this protocol an answer, and every way
- // of not giving one is malformed — except the ordinary failed settlement,
- // which is a root import that failed for reasons selection knows nothing
- // about.
- const result = read(() => event.result);
- if (result === UNREADABLE || typeof result !== "object" || result === null) {
- return MALFORMED;
- }
- const status = read(() => result.status);
- if (status !== "ok") {
- return typeof status === "string" && SETTLED_FAILURES.includes(status) ? UNRELATED : MALFORMED;
- }
- const value = read(() => ("value" in result ? result.value : undefined));
- if (value === UNREADABLE || value === undefined) {
- return MALFORMED;
- }
- return attempt(() => readRootSelection(value)) ?? MALFORMED;
-}
-
-function readRootSelection(value: unknown): RootImportRecord {
- // Parsed rather than read in place. `parseJson` walks every property once and
- // rebuilds the record, so a trap that throws or a value that is not JSON is
- // discovered here — and every read below is of this run's own copy rather
- // than of an object the journal still controls.
- const record = parseJson(value);
- if (!isJsonObject(record)) {
- return MALFORMED;
- }
- const content = record["content"];
- const path = record["path"];
- if (typeof content !== "string" || typeof path !== "string") {
- return MALFORMED;
- }
- const kind = record["kind"];
- const members = Object.keys(record).length;
- // Parsing the recorded content is part of reading the record, for every
- // shape. It is what the verification below compares against, and doing it
- // here means a later read of the same content cannot be the first to
- // discover that it does not parse.
- const outline = documentOutline(path, content);
-
- if (kind === "repository") {
- const target = record["target"];
- if (target === undefined) {
- return members === 3 ? { kind: "read", outline, selection: { kind: "whole" } } : MALFORMED;
- }
- if (members !== 4 || typeof target !== "string" || !isCanonicalTarget(target)) {
- return MALFORMED;
- }
- // The recorded content is here, so the target is verified against it rather
- // than merely parsed: a well-formed target the recorded document does not
- // offer describes a selection that never happened.
- const resolved = findTarget(outline, target);
- if (!resolved.ok || resolved.value.target !== target) {
- return MALFORMED;
- }
- return { kind: "read", outline, selection: { kind: "exact", target } };
- }
-
- if (kind === "target-failure") {
- const failure = recordedDocumentTargetFailure(record["failure"]);
- if (members !== 4 || failure === undefined) {
- return MALFORMED;
- }
- // Same standard for a failure: the recorded selector must fail against the
- // recorded content in exactly the way the record claims. That verifies the
- // catalog and the matches too, which no amount of shape checking could.
- const rederived = findTarget(outline, failure.selector);
- if (rederived.ok) {
- return MALFORMED;
- }
- const actual = asDocumentTargetError(rederived.error);
- if (actual === undefined || !sameDocumentTargetFailure(actual.data, failure)) {
- return MALFORMED;
- }
- return { kind: "read", outline, selection: { kind: "failed", failure } };
- }
-
- return MALFORMED;
-}
-
/**
* What this run's selector decides against the outline the journal recorded.
*
diff --git a/packages/core/src/root-selection.ts b/packages/core/src/root-selection.ts
new file mode 100644
index 000000000..5af9217ea
--- /dev/null
+++ b/packages/core/src/root-selection.ts
@@ -0,0 +1,225 @@
+/**
+ * The retained root-import protocol (spec §7).
+ *
+ * One recorded event decides what a resumed or replayed run is allowed to be a
+ * continuation of, and reading it is not shape checking. The record has to say
+ * which document was selected, and the recorded document has to agree: markdown
+ * that no parser accepts, a target the document does not offer, and a recorded
+ * failure the same selector would not produce are each a record no execution
+ * wrote, not a record with a missing field.
+ *
+ * It lives on its own because two callers depend on the same answer. Canonical
+ * execution admits a partial history through it, and a host that reads a
+ * retained journal — the workflow package, which decides from the same events
+ * whether a run may publish an outcome or be replayed — reaches it through
+ * `@executablemd/core/host`. A second reading of the same durable value would
+ * be a second, weaker protocol, and the day the two stopped agreeing is the day
+ * a forged selection became executable.
+ *
+ * Everything here is synchronous and pure. Nothing an Effection scope owns
+ * passes through, so no cancellation and no durability failure can be swallowed
+ * by a parse.
+ */
+
+import type { Yield } from "@executablemd/durable-streams";
+import { isJsonObject, parseJson } from "./json.ts";
+import { documentOutline } from "./definition.ts";
+import {
+ asDocumentTargetError,
+ findTarget,
+ isCanonicalTarget,
+ recordedDocumentTargetFailure,
+ sameDocumentTargetFailure,
+} from "./document-targets.ts";
+import type { DocumentOutline, DocumentTargetFailure } from "./document-targets.ts";
+
+/**
+ * What one run's selector decided: the whole document, one exact section, or a
+ * failure that named none.
+ *
+ * Selection is compared as an outcome rather than as a target string, because a
+ * failed selection is an outcome too. Without the third case a journal written
+ * by one selector that matched nothing would answer a later request for a
+ * section that does exist.
+ */
+export type SelectionOutcome =
+ | { kind: "whole" }
+ | { kind: "exact"; target: string }
+ | { kind: "failed"; failure: DocumentTargetFailure };
+
+/**
+ * What a recorded event turned out to be.
+ *
+ * "Not the root import" and "the root import, malformed" are deliberately
+ * different answers. Collapsing them into one absent value is what would let a
+ * corrupted record fall through to the recorded terminal result, which is the
+ * failure this distinction exists to prevent.
+ */
+export type RootImportRecord =
+ | { kind: "unrelated" }
+ | { kind: "malformed" }
+ | {
+ kind: "read";
+ /** The document the record is about, as its own parsed copy. */
+ path: string;
+ content: string;
+ outline: DocumentOutline;
+ selection: SelectionOutcome;
+ };
+
+export const UNRELATED: RootImportRecord = { kind: "unrelated" };
+export const MALFORMED: RootImportRecord = { kind: "malformed" };
+
+/**
+ * Read a value that may refuse to be read.
+ *
+ * Every value this boundary touches comes from the journal, and a journal is
+ * data: a property may be an accessor that throws, a key list may come from a
+ * Proxy that refuses, and content may be markdown whose frontmatter no parser
+ * accepts. None of those is a failure of this run — they are ways of saying the
+ * record cannot be read — so none of them may travel as an error of its own.
+ *
+ * Synchronous throughout, so nothing an Effection scope owns passes through
+ * here: this cannot swallow a cancellation or a durability failure, because
+ * neither can arise inside a synchronous parse.
+ */
+export function attempt(read: () => T): T | undefined {
+ try {
+ return read();
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Parse a recorded root import as a closed protocol.
+ *
+ * Two selection shapes are supported and nothing else: a repository selection
+ * with an optional canonical target, and a failed selection with an exact
+ * failure record. An unknown kind, a missing or mistyped member, an extra
+ * member, a noncanonical target, and failure data that no selection could have
+ * produced are each malformed rather than absent.
+ *
+ * A result that is not `ok` is left alone. A root import can fail for reasons
+ * that have nothing to do with selection — an unreadable file — and those
+ * recorded failures are not this protocol's to interpret.
+ */
+/**
+ * A value the journal refused to produce.
+ *
+ * Distinct from `undefined`, which is an ordinary absent value. Reading a
+ * member and finding nothing there, and reading a member that will not say what
+ * is there, are different facts about a record, and one of them is a refusal:
+ * conflating them is how "the root import will not say what it settled to"
+ * became "this is not the root import" and fell through to terminal-result
+ * reuse.
+ */
+export const UNREADABLE: unique symbol = Symbol("unreadable");
+
+/** One read of journal-controlled data: its value, or a refusal. */
+export function read(get: () => T): T | typeof UNREADABLE {
+ try {
+ return get();
+ } catch {
+ return UNREADABLE;
+ }
+}
+
+/** The settlements the protocol recognizes as an ordinary failed root import. */
+const SETTLED_FAILURES: readonly string[] = ["err", "cancelled"];
+
+export function recordedRootImport(event: Yield): RootImportRecord {
+ // Identification first. An event that will not say what it is cannot be
+ // claimed as the root import, so it stays unrelated.
+ const description = read(() => event.description);
+ if (description === UNREADABLE) {
+ return UNRELATED;
+ }
+ const type = read(() => description.type);
+ const name = read(() => description.name);
+ if (type !== "import_component" || name !== "__root__") {
+ return UNRELATED;
+ }
+
+ // Identified. From here the event owes this protocol an answer, and every way
+ // of not giving one is malformed — except the ordinary failed settlement,
+ // which is a root import that failed for reasons selection knows nothing
+ // about.
+ const result = read(() => event.result);
+ if (result === UNREADABLE || typeof result !== "object" || result === null) {
+ return MALFORMED;
+ }
+ const status = read(() => result.status);
+ if (status !== "ok") {
+ return typeof status === "string" && SETTLED_FAILURES.includes(status) ? UNRELATED : MALFORMED;
+ }
+ const value = read(() => ("value" in result ? result.value : undefined));
+ if (value === UNREADABLE || value === undefined) {
+ return MALFORMED;
+ }
+ return attempt(() => readRootSelection(value)) ?? MALFORMED;
+}
+
+function readRootSelection(value: unknown): RootImportRecord {
+ // Parsed rather than read in place. `parseJson` walks every property once and
+ // rebuilds the record, so a trap that throws or a value that is not JSON is
+ // discovered here — and every read below is of this run's own copy rather
+ // than of an object the journal still controls.
+ const record = parseJson(value);
+ if (!isJsonObject(record)) {
+ return MALFORMED;
+ }
+ const content = record["content"];
+ const path = record["path"];
+ if (typeof content !== "string" || typeof path !== "string") {
+ return MALFORMED;
+ }
+ const kind = record["kind"];
+ const members = Object.keys(record).length;
+ // Parsing the recorded content is part of reading the record, for every
+ // shape. It is what the verification below compares against, and doing it
+ // here means a later read of the same content cannot be the first to
+ // discover that it does not parse.
+ const outline = documentOutline(path, content);
+
+ if (kind === "repository") {
+ const target = record["target"];
+ if (target === undefined) {
+ return members === 3
+ ? { kind: "read", path, content, outline, selection: { kind: "whole" } }
+ : MALFORMED;
+ }
+ if (members !== 4 || typeof target !== "string" || !isCanonicalTarget(target)) {
+ return MALFORMED;
+ }
+ // The recorded content is here, so the target is verified against it rather
+ // than merely parsed: a well-formed target the recorded document does not
+ // offer describes a selection that never happened.
+ const resolved = findTarget(outline, target);
+ if (!resolved.ok || resolved.value.target !== target) {
+ return MALFORMED;
+ }
+ return { kind: "read", path, content, outline, selection: { kind: "exact", target } };
+ }
+
+ if (kind === "target-failure") {
+ const failure = recordedDocumentTargetFailure(record["failure"]);
+ if (members !== 4 || failure === undefined) {
+ return MALFORMED;
+ }
+ // Same standard for a failure: the recorded selector must fail against the
+ // recorded content in exactly the way the record claims. That verifies the
+ // catalog and the matches too, which no amount of shape checking could.
+ const rederived = findTarget(outline, failure.selector);
+ if (rederived.ok) {
+ return MALFORMED;
+ }
+ const actual = asDocumentTargetError(rederived.error);
+ if (actual === undefined || !sameDocumentTargetFailure(actual.data, failure)) {
+ return MALFORMED;
+ }
+ return { kind: "read", path, content, outline, selection: { kind: "failed", failure } };
+ }
+
+ return MALFORMED;
+}
diff --git a/packages/core/src/schema-walk.ts b/packages/core/src/schema-walk.ts
index 8006d5343..ce347c7e6 100644
--- a/packages/core/src/schema-walk.ts
+++ b/packages/core/src/schema-walk.ts
@@ -26,6 +26,16 @@ export type NameKind =
| "required property"
| "property dependency";
+/**
+ * One schema position, rewritten.
+ *
+ * Returns what should stand at this position. Everything the walker knows about
+ * where schemas are and where data is applies, so a transform never reaches a
+ * `const`, an `enum` member, a `default` or an `examples` entry, and never
+ * touches a name a schema declares.
+ */
+export type SchemaTransform = (schema: JsonObject, path: string) => JsonObject;
+
export interface SchemaVisitor {
/** One subschema, at a real schema position. */
subschema(schema: JsonObject, path: string): void;
@@ -155,3 +165,108 @@ export function walkSchema(root: JsonObject, visitor: SchemaVisitor): void {
}
}
}
+
+/**
+ * Rewrite every real schema position, leaving everything else exactly as it is.
+ *
+ * The same position table `walkSchema` visits, used to build a copy rather than
+ * to inspect one. Data keywords are carried across by value, declared names are
+ * carried across as names, and nothing in the input is mutated — a caller can
+ * hand this an authored schema and keep using it afterwards.
+ *
+ * Every object it builds has a null prototype, so a key named `__proto__`
+ * arriving in data is an ordinary member rather than an assignment that
+ * rewrites the object it was copied into.
+ */
+export function mapSchema(root: JsonObject, transform: SchemaTransform): JsonObject {
+ return rewrite(root, "#");
+
+ function rewrite(schema: JsonObject, path: string): JsonObject {
+ const mapped = transform(schema, path);
+ const copy = record();
+ for (const [keyword, value] of Object.entries(mapped)) {
+ copy[keyword] = rewriteKeyword(keyword, value, path);
+ }
+ return copy;
+ }
+
+ function rewriteKeyword(keyword: string, value: Json, path: string): Json {
+ if (SUBSCHEMA.includes(keyword)) {
+ return isJsonObject(value) ? rewrite(value, `${path}/${keyword}`) : detach(value);
+ }
+ if (SUBSCHEMA_LIST.includes(keyword)) {
+ return Array.isArray(value)
+ ? value.map((entry, index) =>
+ isJsonObject(entry) ? rewrite(entry, `${path}/${keyword}/${index}`) : detach(entry),
+ )
+ : detach(value);
+ }
+ if (SUBSCHEMA_MAP.some(([name]) => name === keyword)) {
+ return rewriteMap(value, `${path}/${keyword}`);
+ }
+ if (keyword === "items") {
+ if (isJsonObject(value)) {
+ return rewrite(value, `${path}/items`);
+ }
+ return Array.isArray(value)
+ ? value.map((entry, index) =>
+ isJsonObject(entry) ? rewrite(entry, `${path}/items/${index}`) : detach(entry),
+ )
+ : detach(value);
+ }
+ if (keyword === "dependencies") {
+ return rewriteDependencies(value, `${path}/dependencies`);
+ }
+ // Everything else is data: `const`, `enum`, `default`, `examples`, every
+ // scalar constraint, and every keyword this draft does not define.
+ return detach(value);
+ }
+
+ function rewriteMap(value: Json, path: string): Json {
+ if (!isJsonObject(value)) {
+ return detach(value);
+ }
+ const copy = record();
+ for (const [name, entry] of Object.entries(value)) {
+ copy[name] = isJsonObject(entry) ? rewrite(entry, `${path}/${name}`) : detach(entry);
+ }
+ return copy;
+ }
+
+ function rewriteDependencies(value: Json, path: string): Json {
+ if (!isJsonObject(value)) {
+ return detach(value);
+ }
+ const copy = record();
+ for (const [name, entry] of Object.entries(value)) {
+ copy[name] = isJsonObject(entry) ? rewrite(entry, `${path}/${name}`) : detach(entry);
+ }
+ return copy;
+ }
+}
+
+/**
+ * One JSON value, copied all the way down, with every object own-keyed.
+ *
+ * Data, not schema. A copy so nothing downstream can change what the author
+ * wrote, and null-prototyped so a member named `__proto__`, `toString` or
+ * `constructor` is a member rather than something the language answers for.
+ */
+export function detach(value: Json): Json {
+ if (Array.isArray(value)) {
+ return value.map((entry) => detach(entry));
+ }
+ if (value === null || typeof value !== "object") {
+ return value;
+ }
+ const copy = record();
+ for (const name of Object.getOwnPropertyNames(value)) {
+ copy[name] = detach(Reflect.get(value, name));
+ }
+ return copy;
+}
+
+/** One object with no prototype, so every name it answers is a name it holds. */
+function record(): JsonObject {
+ return Object.create(null);
+}
diff --git a/packages/core/tests/canonicalize.test.ts b/packages/core/tests/canonicalize.test.ts
new file mode 100644
index 000000000..a2d8b2d06
--- /dev/null
+++ b/packages/core/tests/canonicalize.test.ts
@@ -0,0 +1,63 @@
+/**
+ * The pure half of canonicalization, and the host-capable half beside it.
+ *
+ * `canonicalize()` moved into a leaf so a runtime without Node builtins can
+ * reach it — a Cloudflare Worker validating a retained record needs the key
+ * ordering and not the digest. The risk in that move is two implementations
+ * that drift, so what is asserted here is that there is exactly one: the
+ * package root and the subpath answer identically, and the fingerprint that
+ * composes over it is unchanged.
+ */
+
+import { describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import { canonicalize as fromRoot, canonicalFingerprint } from "@executablemd/core";
+import { canonicalize as fromSubpath } from "@executablemd/core/canonicalize";
+import { isComponentName as componentNameFromRoot } from "@executablemd/core";
+import { isComponentName as componentNameFromSubpath } from "@executablemd/core/component-name";
+import { isCanonicalDocumentTarget as targetFromRoot } from "@executablemd/core";
+import { isCanonicalDocumentTarget as targetFromSubpath } from "@executablemd/core/document-target";
+import type { Json } from "@executablemd/core";
+
+/** Values chosen for the properties canonicalization is about. */
+const VALUES: Json[] = [
+ null,
+ 0,
+ "text",
+ [3, 1, 2],
+ { b: 1, a: 2 },
+ { outer: { z: [{ y: 1, x: 2 }], a: null } },
+ // The name whose ordinary assignment would reach `Object.prototype`.
+ { ["__proto__"]: { polluted: true }, after: 1 },
+];
+
+describe("canonicalization through both paths", () => {
+ it("answers identically from the package root and the subpath", function* () {
+ for (const value of VALUES) {
+ expect(JSON.stringify(fromSubpath(value))).toEqual(JSON.stringify(fromRoot(value)));
+ }
+ });
+
+ it("still sorts keys and leaves arrays in order", function* () {
+ expect(JSON.stringify(fromSubpath({ b: 1, a: 2 }))).toEqual('{"a":2,"b":1}');
+ expect(JSON.stringify(fromSubpath([3, 1, 2]))).toEqual("[3,1,2]");
+ });
+
+ it("keeps the fingerprint composing over the same ordering", function* () {
+ // The digest is the half that needs a host; it is unchanged by the split.
+ expect(canonicalFingerprint({ b: 1, a: 2 })).toEqual(canonicalFingerprint({ a: 2, b: 1 }));
+ expect(canonicalFingerprint({ a: 1 })).not.toEqual(canonicalFingerprint({ a: 2 }));
+ expect(canonicalFingerprint({ a: 1 })).toMatch(/^[0-9a-f]{64}$/);
+ });
+});
+
+describe("the other predicates a retained descriptor validates with", () => {
+ it("answers identically from the package root and the subpath", function* () {
+ for (const name of ["Repository", "Ns.Sub", "lower", "", "9Bad", "A_1"]) {
+ expect(componentNameFromSubpath(name)).toEqual(componentNameFromRoot(name));
+ }
+ for (const target of ["Heading", "A/B", "", "a%2Fb", "Lower case", "%2f", "Tab\there"]) {
+ expect(targetFromSubpath(target)).toEqual(targetFromRoot(target));
+ }
+ });
+});
diff --git a/packages/core/tests/elicit-component.test.ts b/packages/core/tests/elicit-component.test.ts
index 74e8c8557..4f6de330d 100644
--- a/packages/core/tests/elicit-component.test.ts
+++ b/packages/core/tests/elicit-component.test.ts
@@ -370,7 +370,13 @@ describe("Elicit: judging the answer", () => {
const result = yield* run(workspace, document("Approve?"), constant({ decision: 7 }));
expect(result.failure?.message).toContain("");
- expect(result.failure?.message).toContain('"/decision" must be string');
+ // Where the value went wrong and which rule it broke — and not the value
+ // itself, nor the type the schema declared, because an issue is printed,
+ // bound and journaled, and neither belongs in all three.
+ expect(result.failure?.message).toContain(
+ '"/decision" must be of the type this schema declares',
+ );
+ expect(result.failure?.message).not.toContain("7");
});
/**
diff --git a/packages/core/tests/elicitation-schema.test.ts b/packages/core/tests/elicitation-schema.test.ts
new file mode 100644
index 000000000..4469b8a4b
--- /dev/null
+++ b/packages/core/tests/elicitation-schema.test.ts
@@ -0,0 +1,327 @@
+/**
+ * The one judgment an elicitation response is held to.
+ *
+ * Everything here is about the difference between a schema and the data inside
+ * one, and about what a failure is allowed to say. A schema carries values —
+ * under `const`, `enum`, `default`, `examples` — and declares names, and a
+ * transform that treated either as a schema would change what a document
+ * asked for. A failure carries a location and a rule, and one that carried the
+ * rejected value would publish it into every place an issue travels: the
+ * evaluation environment, a printed error, a journal.
+ *
+ * The judgment is the same object at every boundary, so these are the claims
+ * every boundary inherits.
+ */
+
+import { describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import type { Json, JsonObject } from "../src/types.ts";
+import { prepareResponseValidator, ResponseSchemaError } from "../src/elicitation-schema.ts";
+
+/** One schema written as JSON, so every declared name survives the parse. */
+function schemaOf(text: string): JsonObject {
+ const parsed: unknown = JSON.parse(text);
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
+ throw new Error("the fixture schema is not an object");
+ }
+ const held: JsonObject = {};
+ for (const name of Object.getOwnPropertyNames(parsed)) {
+ // Re-parsed rather than asserted: what a fixture holds is JSON, and this
+ // walks it as such.
+ held[name] = JSON.parse(JSON.stringify(Reflect.get(parsed, name) ?? null));
+ }
+ return held;
+}
+
+/** Whether one value satisfies one schema, through the production preparation. */
+function admits(schema: Json, value: Json): boolean {
+ return prepareResponseValidator("probe", schema).judge(value).length === 0;
+}
+
+/** What the failures say, as location and rule. */
+function issuesOf(schema: Json, value: Json): { at: string; keyword: string; message: string }[] {
+ return prepareResponseValidator("probe", schema)
+ .judge(value)
+ .map((issue) => ({ at: issue.instancePath, keyword: issue.keyword, message: issue.message }));
+}
+
+describe("what a schema means, and what is data inside it", () => {
+ // deno-lint-ignore require-yield
+ it("omits `format` where it is a keyword, at every schema position", function* () {
+ // A format annotates and never constrains, at the root, beneath a
+ // combinator, and through a local reference.
+ expect(admits({ type: "string", format: "email" }, "not an email")).toBe(true);
+ expect(
+ admits({ anyOf: [{ type: "string", format: "email" }, { type: "number" }] }, "not an email"),
+ ).toBe(true);
+ expect(
+ admits(
+ {
+ definitions: { mail: { type: "string", format: "email" } },
+ $ref: "#/definitions/mail",
+ },
+ "not an email",
+ ),
+ ).toBe(true);
+ // And the declaration a provider is shown keeps it, because saying "this is
+ // an email" is the point of writing it.
+ expect(prepareResponseValidator("probe", { type: "string", format: "email" }).schema).toEqual({
+ type: "string",
+ format: "email",
+ });
+ });
+
+ // deno-lint-ignore require-yield
+ it("keeps a literal that happens to carry `format`, exactly", function* () {
+ const constant = { const: { format: "email", x: 1 } };
+ expect(admits(constant, { format: "email", x: 1 })).toBe(true);
+ expect(admits(constant, { x: 1 })).toBe(false);
+ expect(admits(constant, { format: "other", x: 1 })).toBe(false);
+
+ const enumerated = { enum: [{ format: "email" }, { format: "uri" }] };
+ expect(admits(enumerated, { format: "uri" })).toBe(true);
+ expect(admits(enumerated, {})).toBe(false);
+ });
+
+ // deno-lint-ignore require-yield
+ it("keeps a declared name that happens to be `format`", function* () {
+ const declared = schemaOf(
+ '{"type":"object","properties":{"format":{"type":"string","format":"email"}},' +
+ '"required":["format"],"additionalProperties":false}',
+ );
+
+ // The property exists and its own nested annotation constrains nothing, so
+ // an ordinary string is admitted and the name is not treated as additional.
+ expect(admits(declared, { format: "not-email" })).toBe(true);
+ expect(admits(declared, { format: 1 })).toBe(false);
+ expect(admits(declared, {})).toBe(false);
+
+ // The same through a definition reached by reference.
+ const referenced = schemaOf(
+ '{"definitions":{"format":{"type":"string","format":"email"}},' +
+ '"type":"object","properties":{"a":{"$ref":"#/definitions/format"}}}',
+ );
+ expect(admits(referenced, { a: "not-email" })).toBe(true);
+ expect(admits(referenced, { a: 1 })).toBe(false);
+ });
+
+ // deno-lint-ignore require-yield
+ it("leaves the authored schema and the judged value alone", function* () {
+ const schema = { type: "object", properties: { a: { type: "string", format: "email" } } };
+ const before = JSON.stringify(schema);
+ const value = { a: "x" };
+ const valueBefore = JSON.stringify(value);
+
+ expect(admits(schema, value)).toBe(true);
+
+ expect(JSON.stringify(schema)).toBe(before);
+ expect(JSON.stringify(value)).toBe(valueBefore);
+ });
+});
+
+describe("what preparation refuses, before anything is asked", () => {
+ // deno-lint-ignore require-yield
+ it("refuses a reference whose target the schema does not define", function* () {
+ const dangling: Json[] = [
+ { $ref: "#/definitions/missing" },
+ { type: "object", properties: { a: { $ref: "#/definitions/missing" } } },
+ // Inside a branch no sampled value would visit.
+ { anyOf: [{ type: "string" }, { $ref: "#/definitions/missing" }] },
+ ];
+ for (const schema of dangling) {
+ let refused: unknown;
+ try {
+ prepareResponseValidator("probe", schema);
+ } catch (error) {
+ refused = error;
+ }
+ expect([JSON.stringify(schema), refused instanceof ResponseSchemaError]).toEqual([
+ JSON.stringify(schema),
+ true,
+ ]);
+ expect(String(refused)).toContain("does not define");
+ }
+ });
+
+ // deno-lint-ignore require-yield
+ it("resolves a reference whose pointer token is escaped", function* () {
+ const schema = schemaOf(
+ '{"definitions":{"a/b":{"type":"string"},"c~d":{"type":"number"}},' +
+ '"type":"object","properties":{"x":{"$ref":"#/definitions/a~1b"},' +
+ '"y":{"$ref":"#/definitions/c~0d"}}}',
+ );
+
+ expect(admits(schema, { x: "s", y: 1 })).toBe(true);
+ expect(admits(schema, { x: 1, y: 1 })).toBe(false);
+ });
+
+ // deno-lint-ignore require-yield
+ it("refuses what it always refused, and says so boundedly", function* () {
+ const unusable: { schema: Json; says: string }[] = [
+ { schema: { $ref: "other.json#/x" }, says: "#192" },
+ { schema: schemaOf('{"properties":{"__proto__":{"type":"string"}}}'), says: "__proto__" },
+ { schema: { type: "not-a-type" }, says: "not a valid draft-07" },
+ { schema: { type: "object", nope: 1 }, says: "draft-07 does not" },
+ { schema: { $async: true, type: "object" }, says: "asynchronous" },
+ { schema: "not json at all", says: "not JSON" },
+ { schema: [1, 2], says: "must be a JSON Schema object" },
+ ];
+
+ for (const { schema, says } of unusable) {
+ let refused: unknown;
+ try {
+ prepareResponseValidator("probe", schema);
+ } catch (error) {
+ refused = error;
+ }
+ expect([says, refused instanceof ResponseSchemaError]).toEqual([says, true]);
+ expect(String(refused)).toContain(says);
+ expect(String(refused).length).toBeLessThan(600);
+ }
+ });
+});
+
+describe("a value is judged by what it holds, not what its prototype answers", () => {
+ // deno-lint-ignore require-yield
+ it("treats an inherited name as absent", function* () {
+ for (const name of ["toString", "constructor", "valueOf"]) {
+ const schema = schemaOf(
+ `{"type":"object","properties":{"${name}":{"type":"string"}},` +
+ `"required":["${name}"],"additionalProperties":false}`,
+ );
+
+ // `{}` inherits the name and holds none, so the required member is
+ // missing — and it fails as an ordinary issue rather than by raising.
+ const missing = issuesOf(schema, {});
+ expect([name, missing.map((issue) => issue.keyword)]).toEqual([name, ["required"]]);
+ expect(missing[0]?.message).toContain(name);
+
+ // Holding it is what admits it.
+ expect(admits(schema, JSON.parse(`{"${name}":"held"}`))).toBe(true);
+ expect(admits(schema, JSON.parse(`{"${name}":1}`))).toBe(false);
+ }
+ });
+
+ // deno-lint-ignore require-yield
+ it("judges an ordinary property the same way", function* () {
+ const schema = {
+ type: "object",
+ properties: { note: { type: "string" } },
+ required: ["note"],
+ additionalProperties: false,
+ };
+
+ expect(admits(schema, { note: "x" })).toBe(true);
+ expect(issuesOf(schema, {}).map((issue) => issue.keyword)).toEqual(["required"]);
+ expect(issuesOf(schema, { note: 1 }).map((issue) => issue.keyword)).toEqual(["type"]);
+ });
+});
+
+describe("what a failure reports", () => {
+ // deno-lint-ignore require-yield
+ it("keeps every independent failure, and drops only wrappers", function* () {
+ const issues = issuesOf(
+ { type: "object", minProperties: 2, properties: { a: { type: "string" } } },
+ { a: 1 },
+ );
+
+ // Two rules failed at two depths, and both survive. `properties` failing
+ // because `/a` failed is the wrapper, and it does not.
+ expect(issues.map((issue) => [issue.at, issue.keyword])).toEqual([
+ ["", "minProperties"],
+ ["/a", "type"],
+ ]);
+ });
+
+ // deno-lint-ignore require-yield
+ it("reports a raw JSON pointer, whatever the member is named", function* () {
+ const named = (name: string) =>
+ schemaOf(`{"type":"object","properties":${JSON.stringify({ [name]: { type: "string" } })}}`);
+
+ for (const [name, pointer] of [
+ ["🐲", "/🐲"],
+ ["a/b", "/a~1b"],
+ ["c~d", "/c~0d"],
+ ["100%", "/100%"],
+ ["", "/"],
+ ["a b", "/a b"],
+ ]) {
+ const issues = issuesOf(named(name), JSON.parse(JSON.stringify({ [name]: 1 })));
+ expect([name, issues.map((issue) => issue.at)]).toEqual([name, [pointer]]);
+ }
+ });
+
+ // deno-lint-ignore require-yield
+ it("says which rule failed without repeating the value or the schema", function* () {
+ const cases: { schema: Json; value: Json; keyword: string; absent: string[] }[] = [
+ {
+ schema: { type: "number", minimum: 100 },
+ value: 42,
+ keyword: "minimum",
+ absent: ["42", "100"],
+ },
+ {
+ schema: { type: "number", multipleOf: 0.1 },
+ value: 0.31,
+ keyword: "multipleOf",
+ absent: ["0.31", "0.1"],
+ },
+ {
+ schema: { type: "string", maxLength: 3 },
+ value: "hunter2secret",
+ keyword: "maxLength",
+ absent: ["hunter2secret", "3"],
+ },
+ {
+ schema: { type: "string", pattern: "^[a-z]+$" },
+ value: "hunter2secret",
+ keyword: "pattern",
+ absent: ["hunter2secret", "[a-z]"],
+ },
+ {
+ schema: { enum: ["approve", "reject"] },
+ value: "hunter2secret",
+ keyword: "enum",
+ absent: ["hunter2secret", "approve", "reject"],
+ },
+ {
+ schema: { const: "approve" },
+ value: "hunter2secret",
+ keyword: "const",
+ absent: ["hunter2secret", "approve"],
+ },
+ {
+ schema: { type: "object", maxProperties: 1 },
+ value: { a: 1, secret: "hunter2secret" },
+ keyword: "maxProperties",
+ absent: ["hunter2secret", "1"],
+ },
+ { schema: { type: "string" }, value: 42, keyword: "type", absent: ["42", "string"] },
+ ];
+
+ for (const { schema, value, keyword, absent } of cases) {
+ const issues = issuesOf(schema, value);
+ expect([keyword, issues.map((issue) => issue.keyword)]).toEqual([keyword, [keyword]]);
+ const reported = JSON.stringify(issues);
+ for (const leaked of absent) {
+ expect([keyword, leaked, reported.includes(leaked)]).toEqual([keyword, leaked, false]);
+ }
+ }
+ });
+
+ // deno-lint-ignore require-yield
+ it("carries no library object in what it reports", function* () {
+ const issues = prepareResponseValidator("probe", { type: "string" }).judge(1);
+
+ expect(issues).toHaveLength(1);
+ expect(Object.keys(issues[0] ?? {}).toSorted()).toEqual([
+ "instancePath",
+ "keyword",
+ "message",
+ "params",
+ "schemaPath",
+ ]);
+ expect(issues[0]?.params).toEqual({});
+ expect(issues[0]?.schemaPath).toBe("#/type");
+ });
+});
diff --git a/packages/workflow/cloudflare.ts b/packages/workflow/cloudflare.ts
new file mode 100644
index 000000000..e2ac10924
--- /dev/null
+++ b/packages/workflow/cloudflare.ts
@@ -0,0 +1,84 @@
+/**
+ * @module
+ *
+ * The Cloudflare host's workflow-run owner.
+ *
+ * Keeping this behind its own entrypoint is what lets the shared package stay
+ * provider-neutral, exactly as `./deno` does for the local host. Durable
+ * Objects, the runtime's SQLite, WebSocket acquisition and OIDC admission live
+ * here and nowhere above; `@executablemd/workflow` names none of them, so the
+ * Deno host is unaffected by this module existing and neither host has to know
+ * the other does.
+ *
+ * What an operator assembles is the owner and its policy:
+ *
+ * ```ts
+ * import { WorkflowOwnerObject } from "@executablemd/workflow/cloudflare";
+ *
+ * export class WorkflowOwner extends WorkflowOwnerObject {
+ * protected configuration() {
+ * return { policy: POLICY };
+ * }
+ * }
+ * ```
+ *
+ * Provider endpoints, OIDC tokens, credentials, private message shapes,
+ * storage handles and acquisition evidence are deliberately absent from what
+ * this publishes. They are host closure state, and a value a document or a
+ * runner could name would be authority a document or a runner could hold.
+ */
+
+export { WorkflowOwnerObject, refusalOf } from "./src/cloudflare/owner.ts";
+export type { AdmissionRequest, OwnerConfiguration } from "./src/cloudflare/owner.ts";
+
+export { AdmissionError } from "./src/cloudflare/admission.ts";
+export type { AdmissionPolicy, AdmissionRefusal } from "./src/cloudflare/admission.ts";
+
+export { ReleaseIdentityError } from "./src/cloudflare/release.ts";
+export type { ReleaseRefusal } from "./src/cloudflare/release.ts";
+
+export { admitRunId, ownerFor, RunIdError } from "./src/cloudflare/routing.ts";
+export type { OwnerNamespace, RunIdRefusal } from "./src/cloudflare/routing.ts";
+
+export { WorkflowObjectStorageError } from "./src/cloudflare/recognition.ts";
+export type { RecognitionFailure } from "./src/cloudflare/recognition.ts";
+
+/**
+ * The supported request boundary, for the Worker in front of these owners.
+ *
+ * A gateway routes and forwards; it does not parse a private command, verify a
+ * token, or report that either was already checked. `ownerFor` selects the
+ * object arithmetically from the public run id, and the owner it reaches makes
+ * every decision itself.
+ */
+export { ownerRoute } from "./src/cloudflare/gateway.ts";
+
+/**
+ * One configured client for one run's owner, for a trusted runner.
+ *
+ * The minimum a host must supply is an already-selected run id, one
+ * credential-free endpoint, the exact release identity, a token operation, and
+ * the HTTP and WebSocket I/O to perform. Endpoint, release and token stay in
+ * the client's closure; the private commands, refusal spellings and route
+ * shapes stay inside the adapter.
+ */
+export { remoteOwnerClient } from "./src/cloudflare/configured.ts";
+export type {
+ OwnerHttpRequest,
+ OwnerHttpResponse,
+ OwnerTransport,
+ OwnerUpgrade,
+ OwnerUpgradeRefused,
+ RemoteOwnerClient,
+ RemoteOwnerConfiguration,
+} from "./src/cloudflare/configured.ts";
+export { OwnerEndpointError } from "./src/cloudflare/endpoint.ts";
+export type { EndpointRefusal } from "./src/cloudflare/endpoint.ts";
+
+/**
+ * The socket shape a host's `connect` provides.
+ *
+ * Part of the transport contract rather than of the protocol: a runtime's own
+ * `WebSocket` satisfies it, and what travels over it stays private.
+ */
+export type { OwnerSocket, SocketListener } from "./src/remote/client.ts";
diff --git a/packages/workflow/deno.json b/packages/workflow/deno.json
index ed6dfab38..9be6e877f 100644
--- a/packages/workflow/deno.json
+++ b/packages/workflow/deno.json
@@ -5,9 +5,10 @@
"exports": {
".": "./mod.ts",
"./deno": "./deno.ts",
+ "./software-factory": "./software-factory.ts",
"./credential-helper": "./src/deno/composition/credential-helper.ts"
},
"publish": {
- "exclude": ["!vendor/cloudflare-computer-dofs/generated/**/*.d.ts"]
+ "exclude": ["!vendor/cloudflare-computer-dofs/generated/**/*.d.ts", "!src/cloudflare"]
}
}
diff --git a/packages/workflow/deno.ts b/packages/workflow/deno.ts
index 368e3ea1c..6c044ba78 100644
--- a/packages/workflow/deno.ts
+++ b/packages/workflow/deno.ts
@@ -28,6 +28,15 @@ export { useWorkflowRunStorage } from "./src/deno/provider.ts";
export type { WorkflowRunStorageOptions } from "./src/deno/provider.ts";
export { useWorkflowLifecycle } from "./src/deno/lifecycle.ts";
export { useWorkflowRunHost } from "./src/deno/run-host.ts";
+/**
+ * Re-exported for source compatibility only.
+ *
+ * These are provider-neutral: they describe what any host's lifecycle does, not
+ * what this adapter retains, and `@executablemd/workflow` owns their meaning.
+ * Import them from there. What belongs behind this entrypoint is the
+ * implementation and its retained encoding — SQLite, DOFS, run-id hashing,
+ * filesystem paths — not the shape of a request.
+ */
export type {
WorkflowBeginRequest,
WorkflowExecutionTransitions,
@@ -106,7 +115,7 @@ export type {
AgentSessionResolution,
ProviderAssertion,
} from "./src/deno/workspace/agent-sessions.ts";
-export { transactAgentSessions } from "./src/deno/workspace/private.ts";
+export { transactAgentSessions } from "./src/workspace/effects.ts";
export type { AgentSessions } from "./src/deno/workspace/agent-sessions.ts";
export {
WORKSPACE_GIT_ADD,
@@ -131,3 +140,51 @@ export type {
SuspensionControllerOptions,
SuspensionNotice,
} from "./src/deno/suspension.ts";
+
+/**
+ * One runner for a run whose storage is somewhere else.
+ *
+ * The same four things this entrypoint's local host installs, composed over a
+ * configured owner client instead of a directory: the executor lifecycle and
+ * its transitions, the no-acquisition read and delivery planes, and the
+ * Workspace attachment for a live or partial execution. Native Git, evidence
+ * processes and Agent clients stay on this side, as they do locally; the owner
+ * runs none of them.
+ *
+ * Which owner, which release and which token are the caller's to supply — this
+ * reads no flag, environment variable or prop for any of them.
+ */
+export { useRemoteWorkflowRunner } from "./src/deno/remote-runner.ts";
+export type {
+ RemoteRunnerOwner,
+ RemoteWorkflowRunner,
+ RemoteWorkflowRunnerOptions,
+} from "./src/deno/remote-runner.ts";
+
+/**
+ * One configured client for one run's owner, for a trusted runner.
+ *
+ * Published here as well as from `./cloudflare` because a runner is where one
+ * is constructed and `./cloudflare` is the owner's entrypoint: it names the
+ * Durable Object runtime, so it resolves inside a Worker and nowhere else. The
+ * module is the same one either way.
+ *
+ * The minimum a host supplies is an already-selected run id, one
+ * credential-free endpoint, the exact release identity, an operation that mints
+ * a short-lived token, and the HTTP and WebSocket I/O to perform. Endpoint,
+ * release and token stay in the client's closure; the route shapes, private
+ * commands and refusal spellings stay inside the adapter.
+ */
+export { remoteOwnerClient } from "./src/cloudflare/configured.ts";
+export type {
+ OwnerHttpRequest,
+ OwnerHttpResponse,
+ OwnerTransport,
+ OwnerUpgrade,
+ OwnerUpgradeRefused,
+ RemoteOwnerClient,
+ RemoteOwnerConfiguration,
+} from "./src/cloudflare/configured.ts";
+export { OwnerEndpointError } from "./src/cloudflare/endpoint.ts";
+export type { EndpointRefusal } from "./src/cloudflare/endpoint.ts";
+export type { OwnerSocket, SocketListener } from "./src/remote/client.ts";
diff --git a/packages/workflow/mod.ts b/packages/workflow/mod.ts
index 85f09ea39..4e85f88fc 100644
--- a/packages/workflow/mod.ts
+++ b/packages/workflow/mod.ts
@@ -62,6 +62,9 @@ export {
export type { GitApi, GitObjectFormat } from "./src/git.ts";
export { getWorkflowRun, retainedWorkflowInstallation, workflowInstallation } from "./src/run.ts";
export { workflowBundleInstallation, WorkflowBundleHistoryError } from "./src/bundle.ts";
+export { retainedReplay, WorkflowReplayHistoryError } from "./src/replay.ts";
+export { DOCUMENT_FAILED, retainedFailureReason } from "./src/lifecycle/policy.ts";
+export type { RetainedReplay } from "./src/replay.ts";
export type { WorkflowRun } from "./src/run.ts";
export { useWorkflowServiceDenial, WorkflowServiceDeniedError } from "./src/service-denial.ts";
@@ -314,6 +317,18 @@ export type {
WorkflowLifecycleApi,
WorkflowLifecycleSnapshot,
} from "./src/lifecycle/api.ts";
+// What a trusted host needs to move a run's lifecycle. These describe what any
+// host's lifecycle does rather than what one adapter retains, so this entrypoint
+// owns their meaning; `./deno` re-exports them for source compatibility and a
+// second host implements the same shapes without that module being loaded.
+export type {
+ WorkflowBeginRequest,
+ WorkflowExecutionBegun,
+ WorkflowExecutionTransitions,
+ WorkflowForkRequest,
+ WorkflowForkSelection,
+ WorkflowRunCreation,
+} from "./src/lifecycle/execution.ts";
// The export request, its result and the boundary it names. The retained record
// shapes an artifact also carries are DOFS and SQLite rows, so they are the
// Deno entrypoint's to publish rather than this one's.
diff --git a/packages/workflow/package.json b/packages/workflow/package.json
index bb7e1758e..e7ffd74a5 100644
--- a/packages/workflow/package.json
+++ b/packages/workflow/package.json
@@ -6,6 +6,8 @@
"exports": {
".": "./mod.ts",
"./deno": "./deno.ts",
+ "./cloudflare": "./cloudflare.ts",
+ "./software-factory": "./software-factory.ts",
"./credential-helper": "./src/deno/composition/credential-helper.ts"
},
"dependencies": {
diff --git a/packages/workflow/software-factory.ts b/packages/workflow/software-factory.ts
new file mode 100644
index 000000000..e5d70442c
--- /dev/null
+++ b/packages/workflow/software-factory.ts
@@ -0,0 +1,47 @@
+/**
+ * @module
+ *
+ * The GitHub Actions software factory's public identity rule.
+ *
+ * This subpath is deliberately not the package root. `@executablemd/workflow`
+ * names no provider — that is what lets a second host implement the same
+ * lifecycle — and the derivation here names GitHub in its scheme tag, its
+ * authority rule and its node id, because the software factory is a GitHub
+ * product by definition rather than one adapter of a neutral boundary.
+ *
+ * So the two surfaces are separate on purpose. Anything that needs the factory's
+ * own contract asks for it by name:
+ *
+ * ```ts
+ * import { deriveFactoryRunId } from "@executablemd/workflow/software-factory";
+ *
+ * const runId = yield* deriveFactoryRunId({
+ * authority: "github.com",
+ * issueNodeId: node,
+ * });
+ * ```
+ *
+ * One issue is one durable run, so this is the whole of "one issue, one run":
+ * every host that admits the same issue arrives at the same 52 characters
+ * without asking anybody. It is specified in
+ * `specs/github-actions-software-factory-spec.md` §1.1 and restated in
+ * `specs/workflow-spec.md` §9.1.
+ *
+ * The seam is deliberately small: admit a subject, or derive its id. The scheme
+ * tag, the Base32 alphabet, the authority rule, the preimage layout and the
+ * encoder are implementation, not promises — a caller that could reach them
+ * could also reimplement the hash, and two implementations of an identity that
+ * must agree byte for byte is the failure §1.1 exists to prevent.
+ *
+ * Nothing here is runtime-specific. It uses the cross-runtime Web primitives —
+ * `TextEncoder` and `crypto.subtle` — and names no host, so the provider host
+ * and a GitHub intake reach the same single implementation rather than each
+ * carrying a hash that has to agree byte for byte with the other's.
+ */
+
+export {
+ admitFactoryRunSubject,
+ deriveFactoryRunId,
+ FactoryRunSubjectError,
+} from "./src/software-factory/run-id.ts";
+export type { FactoryRunSubject, FactoryRunSubjectFailure } from "./src/software-factory/run-id.ts";
diff --git a/packages/workflow/src/bundle.ts b/packages/workflow/src/bundle.ts
index 9f91ad99b..4f11c64da 100644
--- a/packages/workflow/src/bundle.ts
+++ b/packages/workflow/src/bundle.ts
@@ -24,6 +24,13 @@
* own exact-origin check, which this neither repeats nor relaxes. And the root
* import stays the root import — a repository selection under `__root__`,
* already held to the run's exact root source by core.
+ *
+ * A completed replay takes the second half without the first, through
+ * `workflowBundleReplayInstallation()`: it imports nothing, so it is handed no
+ * source to import from, and its records are held to the name, path and object
+ * id the immutable definition declares — with the recorded bytes named as a
+ * Git blob and required to *be* that object rather than merely to repeat its
+ * id beside itself.
*/
import type { DurableEvent, Json, Yield } from "@executablemd/durable-streams";
@@ -32,6 +39,9 @@ import type {
JournalAdmission,
WorkflowBundleComponent,
} from "@executablemd/core/host";
+import { definitionComponents } from "./storage/definition.ts";
+import type { WorkflowDefinition } from "./storage/definition.ts";
+import { gitBlobId } from "./git-blob.ts";
/** The root's own import, which is not a bundle member and is admitted elsewhere. */
const ROOT = "__root__";
@@ -62,6 +72,26 @@ const REFUSALS = {
"A retained component import recorded a repository file, which a workflow run resolves none of.",
} as const;
+/**
+ * One component a run's definition declares, as an admission holds a retained
+ * import to it.
+ *
+ * `holds` is where the two halves of this contract differ, and it is the only
+ * place they may. A live or partial execution has already read the pinned
+ * source from the definition's own commit, so a retained record is held to
+ * those exact bytes. A completed replay has no pinned source and no repository
+ * to ask for one, so it does what Git does: it names the recorded bytes as a
+ * blob under the definition's own object format and requires that name to be
+ * the object id the definition declares. Either way the bytes are
+ * authenticated — repeating an object id beside unrelated bytes is not.
+ */
+interface DeclaredComponent {
+ readonly path: string;
+ readonly sourceHash: string;
+ /** Whether these exact bytes are the object this run's definition names. */
+ holds(content: string): boolean;
+}
+
/**
* Read one journal-controlled value, or answer that reading it refused.
*
@@ -130,13 +160,14 @@ function importedValue(event: DurableEvent): { value: unknown } | undefined | ty
*
* Every branch is a decision about what the record *is*, taken before anything
* is replayed from it. A declared name must have been recorded as a bundled
- * component, with this bundle's exact path, hash, and source; an undeclared
+ * component, with the exact path and object id the definition declares, and
+ * with bytes that are that object; an undeclared
* name must not claim to be one; and a repository selection is admitted only
* for the root, which core holds to the run's own root source.
*/
function admitImport(
event: DurableEvent,
- components: ReadonlyMap,
+ components: ReadonlyMap,
): void {
const name = importedName(event);
if (name === undefined || name === ROOT) {
@@ -172,10 +203,14 @@ function admitImport(
}
}
const read = (member: string) => reading(() => (record as Record)[member]);
+ const content = read("content");
+ if (typeof content !== "string") {
+ throw new WorkflowBundleHistoryError(REFUSALS.unreadable);
+ }
if (
read("path") !== declared.path ||
read("sourceHash") !== declared.sourceHash ||
- read("content") !== declared.content
+ !declared.holds(content)
) {
throw new WorkflowBundleHistoryError(REFUSALS.mismatched);
}
@@ -192,7 +227,7 @@ function admitImport(
}
}
-function admits(components: ReadonlyMap): JournalAdmission {
+function admits(components: ReadonlyMap): JournalAdmission {
// deno-lint-ignore require-yield
return function* (retained: readonly DurableEvent[]) {
for (const event of retained) {
@@ -222,19 +257,67 @@ export function workflowBundleInstallation(
// Copied entry by entry at construction, so the authority this installation
// carries is closed over these values rather than over an array the caller
// still holds and could rewrite between installation and import.
- const index = new Map(
- components.map((component) => [
+ const bundled = components.map((component) =>
+ Object.freeze({
+ name: component.name,
+ path: component.path,
+ sourceHash: component.sourceHash,
+ content: component.content,
+ }),
+ );
+ const index = new Map(
+ bundled.map((component) => [
component.name,
Object.freeze({
- name: component.name,
path: component.path,
sourceHash: component.sourceHash,
- content: component.content,
+ // The bytes themselves, because this run has them: they were read from
+ // the definition's own commit before it existed.
+ holds: (content: string) => content === component.content,
}),
]),
);
return {
admissions: [admits(index)],
- bundle: { components: Object.freeze([...index.values()]) },
+ bundle: { components: Object.freeze(bundled) },
};
}
+
+/**
+ * Hold a completed run's retained component imports to the bundle its
+ * definition declares, and grant no authority to import one.
+ *
+ * The other half of `workflowBundleInstallation()`, for the execution that
+ * reuses a terminal instead of running. There is no execution view here because
+ * there is nothing to resolve: a completed replay answers from its recorded
+ * root Close before any name is looked up, so a source read for it would be a
+ * fetch performed for a component nobody imports. What remains is the
+ * admission, and it is exactly as strict — every retained import is held to the
+ * declared name, canonical path and object id, and a member the history never
+ * imported is neither read nor granted anything by being declared.
+ *
+ * ```ts
+ * yield* executeInstalled(options, [
+ * retainedWorkflowInstallation(run),
+ * workflowBundleReplayInstallation(definitionComponents(definition)),
+ * ]);
+ * ```
+ */
+export function workflowBundleReplayInstallation(
+ definition: WorkflowDefinition,
+): ExecutionInstallation {
+ const { objectFormat } = definition;
+ const index = new Map(
+ definitionComponents(definition).map((entry) => [
+ entry.name,
+ Object.freeze({
+ path: entry.path,
+ sourceHash: entry.sourceHash,
+ // Named the way Git names a blob, under this definition's own object
+ // format, and required to be the object the definition declares.
+ holds: (content: string) => gitBlobId(content, objectFormat) === entry.sourceHash,
+ }),
+ ]),
+ );
+ return { admissions: [admits(index)] };
+}
diff --git a/packages/workflow/src/cloudflare/acquisition.ts b/packages/workflow/src/cloudflare/acquisition.ts
new file mode 100644
index 000000000..c1cd862ec
--- /dev/null
+++ b/packages/workflow/src/cloudflare/acquisition.ts
@@ -0,0 +1,173 @@
+/**
+ * Executor ownership, as one authenticated WebSocket.
+ *
+ * The acquisition *is* the connection. There is no lease, expiry, renewal,
+ * heartbeat, alarm, PID or liveness poll: a healthy socket owns the run, and a
+ * socket that closes stops owning it because the runtime stops listing it. That
+ * is the same shape the local host has, where the operating system releases an
+ * advisory lock when the executor exits, and it is why nothing here has to
+ * decide whether an absent executor is slow or gone.
+ *
+ * Hibernation is why ownership cannot live in a field. An idle Durable Object
+ * is evicted while its sockets stay open, so the object that wakes up has no
+ * memory of what it admitted. The runtime hands back the live sockets and the
+ * bounded attachment each was accepted with, and that pair is the authority:
+ * `ctx.getWebSockets()` says which sockets are real, and the attachment says
+ * what one was admitted as.
+ *
+ * Attachment bytes alone are not authority. A copy of them proves nothing,
+ * because the check is not "does this value look right" but "is the socket this
+ * message arrived on the one live socket carrying an acquisition". A second
+ * connection cannot manufacture that by holding a copy.
+ */
+
+import type { OwnerStorage } from "./storage.ts";
+
+/** What one admitted connection carries, and all it carries. */
+export interface AcquisitionAttachment {
+ readonly kind: "executor";
+ readonly runId: string;
+ readonly acquisitionId: string;
+}
+
+/** Why an acquisition was refused. */
+export type AcquisitionRefusal =
+ | "already-running"
+ | "not-acquired"
+ | "foreign-connection"
+ | "wrong-run";
+
+export class AcquisitionError extends Error {
+ override name = "AcquisitionError";
+
+ constructor(readonly refusal: AcquisitionRefusal) {
+ super(`this connection does not own this run's executor (${refusal})`);
+ }
+}
+
+/** The bits of a Durable Object's context this module uses. */
+export interface AcquisitionContext {
+ getWebSockets(tag?: string): WebSocket[];
+ acceptWebSocket(socket: WebSocket, tags?: string[]): void;
+ readonly storage: OwnerStorage;
+}
+
+/** The tag every executor connection is accepted under. */
+export const EXECUTOR_TAG = "executor";
+
+function attachmentOf(socket: WebSocket): AcquisitionAttachment | undefined {
+ const value = socket.deserializeAttachment();
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ return undefined;
+ }
+ const members: Map = new Map(Object.entries(value));
+ if (members.get("kind") !== "executor") {
+ return undefined;
+ }
+ const runId = members.get("runId");
+ const acquisitionId = members.get("acquisitionId");
+ if (typeof runId !== "string" || typeof acquisitionId !== "string") {
+ return undefined;
+ }
+ return { kind: "executor", runId, acquisitionId };
+}
+
+/**
+ * Every live connection currently holding an acquisition.
+ *
+ * A socket the runtime still lists but whose attachment was cleared is not one:
+ * closing releases ownership immediately, while the runtime may take its own
+ * time to stop listing the socket, and ownership must end at the earlier of the
+ * two.
+ */
+export function acquisitionHolders(
+ ctx: AcquisitionContext,
+): { socket: WebSocket; held: AcquisitionAttachment }[] {
+ const found: { socket: WebSocket; held: AcquisitionAttachment }[] = [];
+ for (const socket of ctx.getWebSockets(EXECUTOR_TAG)) {
+ const held = attachmentOf(socket);
+ if (held !== undefined) {
+ found.push({ socket, held });
+ }
+ }
+ return found;
+}
+
+/**
+ * Admit one connection as this run's executor.
+ *
+ * A second healthy executor is refused rather than followed: it cannot advance
+ * the run, and the caller learns that from the refusal rather than from a
+ * mutation that quietly did nothing.
+ */
+export function acquireExecutor(
+ ctx: AcquisitionContext,
+ socket: WebSocket,
+ runId: string,
+ acquisitionId: string,
+ beforeAccept: () => void = () => undefined,
+): AcquisitionAttachment {
+ if (acquisitionHolders(ctx).length > 0) {
+ throw new AcquisitionError("already-running");
+ }
+ beforeAccept();
+ const attachment: AcquisitionAttachment = { kind: "executor", runId, acquisitionId };
+ ctx.acceptWebSocket(socket, [EXECUTOR_TAG]);
+ // Bounded, and only what admission needs to be reconstructed after an
+ // eviction. Nothing here is a credential and nothing here is durable run
+ // state.
+ socket.serializeAttachment(attachment);
+ return attachment;
+}
+
+/**
+ * Prove that a message arrived on the one live acquisition.
+ *
+ * Called before the requested mutation is parsed, and again — by the caller —
+ * inside the transaction that writes, because a socket can close between the
+ * two and the transaction is where the run actually changes.
+ */
+export function requireAcquisition(
+ ctx: AcquisitionContext,
+ socket: WebSocket,
+ runId: string,
+): AcquisitionAttachment {
+ const mine = requireExecutorSocket(ctx, socket);
+ if (mine.runId !== runId) {
+ throw new AcquisitionError("wrong-run");
+ }
+ return mine;
+}
+
+export function requireExecutorSocket(
+ ctx: AcquisitionContext,
+ socket: WebSocket,
+): AcquisitionAttachment {
+ const live = acquisitionHolders(ctx);
+ if (live.length === 0) {
+ throw new AcquisitionError("not-acquired");
+ }
+ const mine = live.find((holder) => holder.socket === socket);
+ if (mine === undefined) {
+ // Either this socket was never admitted, or it was superseded and closed.
+ throw new AcquisitionError("foreign-connection");
+ }
+ if (live.length > 1) {
+ // Two live holders is a state this module refuses to choose between.
+ throw new AcquisitionError("already-running");
+ }
+ return mine.held;
+}
+
+/**
+ * Release ownership when a connection ends.
+ *
+ * The runtime has already stopped listing the socket by the time this runs, so
+ * there is nothing to revoke — this exists to make the absence of a rollback
+ * explicit. A closed connection invalidates the acquisition and changes no
+ * committed state, and it settles no lifecycle: an executor that disappeared
+ * did not decide anything.
+ */
+export function releaseExecutor(socket: WebSocket): void {
+ socket.serializeAttachment(null);
+}
diff --git a/packages/workflow/src/cloudflare/admission.ts b/packages/workflow/src/cloudflare/admission.ts
new file mode 100644
index 000000000..53a7ee687
--- /dev/null
+++ b/packages/workflow/src/cloudflare/admission.ts
@@ -0,0 +1,142 @@
+/**
+ * Who is allowed to become this run's executor.
+ *
+ * A runner authenticates with a GitHub Actions OIDC token, and the owner
+ * validates it before the connection is accepted and before an acquisition
+ * exists. Everything checked here is an identity the deployment configured, and
+ * the checks are on IDs rather than names: a repository can be renamed and an
+ * owner can be renamed, so a check on `repository` would admit whoever holds
+ * the name today.
+ *
+ * The claims reaching this module have already been proved to come from the
+ * issuer — `token.ts` verifies the signature, the algorithm and the temporal
+ * validity first. That order is the whole security property: comparing claim
+ * values a caller could have written is arithmetic, not authentication.
+ *
+ * Nothing about the token survives the check. The raw JWT, the JWKS endpoint,
+ * the claims this contract does not name, and the reason a signature failed are
+ * all provider state: none of them reaches durable storage, a journal event, a
+ * public value or an error message. What a refusal says is which category it
+ * fell into, because that is what an operator can act on and what a test can
+ * assert without pinning provider wording.
+ */
+
+import type { Operation } from "effection";
+import { type TokenVerification, verifyToken } from "./token.ts";
+
+/** What a deployment must state before any runner can be admitted. */
+export interface AdmissionPolicy {
+ readonly issuer: string;
+ readonly audience: string;
+ readonly repositoryId: string;
+ readonly repositoryOwnerId: string;
+ readonly eventName: string;
+ readonly workflowRef: string;
+ readonly workflowSha: string;
+ /** The immutable identity of the workflow allowed to execute this run. */
+ readonly jobWorkflowRef: string;
+ /** The exact build both sides must be. */
+ readonly release: string;
+}
+
+/**
+ * The claims this contract reads.
+ *
+ * Deliberately a closed set. A token carries far more than this, and reading a
+ * claim here is what makes it part of the contract — so anything not named is
+ * not consulted, cannot be depended on, and never leaves the verifier.
+ */
+export interface ActionsClaims {
+ readonly iss: unknown;
+ readonly aud: unknown;
+ readonly repository_id: unknown;
+ readonly repository_owner_id: unknown;
+ readonly event_name: unknown;
+ readonly workflow_ref: unknown;
+ readonly workflow_sha: unknown;
+ readonly job_workflow_ref: unknown;
+}
+
+/** Which part of the admission a token failed. */
+export type AdmissionRefusal =
+ | "token-absent"
+ | "token-malformed"
+ | "issuer"
+ | "audience"
+ | "repository-id"
+ | "repository-owner-id"
+ | "event-name"
+ | "workflow-ref"
+ | "workflow-sha"
+ | "workflow-identity";
+
+export class AdmissionError extends Error {
+ override name = "AdmissionError";
+
+ constructor(readonly refusal: AdmissionRefusal) {
+ super(`this runner is not admitted to execute this run (${refusal})`);
+ }
+}
+
+/** Compare one claim, naming the check rather than the values. */
+function requireClaim(claim: unknown, expected: string, refusal: AdmissionRefusal): void {
+ if (typeof claim !== "string" || claim !== expected) {
+ throw new AdmissionError(refusal);
+ }
+}
+
+/**
+ * Hold verified claims to the configured policy.
+ *
+ * Private to this module's own admission path. It is not exported, because an
+ * exported "check these claims" is exactly the surface that made the previous
+ * revision forgeable: a caller reaching it directly would be a caller choosing
+ * its own identity. Reaching it goes through `admitToken()`, which verifies
+ * first.
+ */
+function admitClaims(policy: AdmissionPolicy, claims: ActionsClaims): void {
+ requireClaim(claims.iss, policy.issuer, "issuer");
+ // `aud` may be a string or an array of them; only the exact configured
+ // audience admits, and an array containing it is that audience.
+ const audience = claims.aud;
+ const audiences = Array.isArray(audience) ? audience : [audience];
+ if (!audiences.some((value) => value === policy.audience)) {
+ throw new AdmissionError("audience");
+ }
+ requireClaim(claims.repository_id, policy.repositoryId, "repository-id");
+ requireClaim(claims.repository_owner_id, policy.repositoryOwnerId, "repository-owner-id");
+ requireClaim(claims.event_name, policy.eventName, "event-name");
+ requireClaim(claims.workflow_ref, policy.workflowRef, "workflow-ref");
+ requireClaim(claims.workflow_sha, policy.workflowSha, "workflow-sha");
+ requireClaim(claims.job_workflow_ref, policy.jobWorkflowRef, "workflow-identity");
+}
+
+/** Read a claim set out of a verified payload. */
+function parseClaims(payload: Map): ActionsClaims {
+ return {
+ iss: payload.get("iss"),
+ aud: payload.get("aud"),
+ repository_id: payload.get("repository_id"),
+ repository_owner_id: payload.get("repository_owner_id"),
+ event_name: payload.get("event_name"),
+ workflow_ref: payload.get("workflow_ref"),
+ workflow_sha: payload.get("workflow_sha"),
+ job_workflow_ref: payload.get("job_workflow_ref"),
+ };
+}
+
+/**
+ * Verify a token and hold what it proved to the configured policy.
+ *
+ * The only way into this module. It takes the bytes a runner presented and the
+ * verification material the deployment configured, and nothing a request can
+ * name reaches either.
+ */
+export function* admitToken(
+ policy: AdmissionPolicy,
+ verification: TokenVerification,
+ token: unknown,
+): Operation {
+ const payload = yield* verifyToken(verification, token);
+ admitClaims(policy, parseClaims(payload));
+}
diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts
new file mode 100644
index 000000000..2a408b9a5
--- /dev/null
+++ b/packages/workflow/src/cloudflare/client.ts
@@ -0,0 +1,1247 @@
+/**
+ * The runner's side of the private protocol.
+ *
+ * This is the only place that knows both languages. Above it, `src/remote/**`
+ * speaks in workflow records and Workspace roots; below it, the connection
+ * carries private commands and a private refusal union. Translating between
+ * them here is what keeps the neutral code neutral, and what keeps the private
+ * shapes private.
+ *
+ * Nothing arrives as a semantic value because the owner said so. A performed
+ * answer is parsed into a record, a manifest or a verified content piece before
+ * anything above can see it, and a refusal is narrowed to the exact union this
+ * release declares. Both sides are the same build — admission proved that — so
+ * a category this build has never heard of is not a new failure to report
+ * upward, it is a channel that is not what it claims to be, and the connection
+ * fails closed.
+ *
+ * Content is verified again on arrival. The owner validated it before sending,
+ * and that says nothing about what happened in between; a digest is cheap and
+ * the alternative is materializing bytes that are not the bytes the root names.
+ *
+ * The journal is reassembled here from anchored pages, and the assembly is
+ * checked rather than assumed: each page must continue the previous one, name
+ * no event twice, and end exactly at the anchor. A page that skipped, repeated
+ * or reordered an event closes the connection before a single event reaches a
+ * caller — half a journal that looks whole is worse than no journal.
+ */
+
+import { Err, Ok, type Operation, type Result } from "effection";
+import { serializeDurableEvent } from "@executablemd/durable-streams";
+import type { JournalEntry } from "../storage/api.ts";
+import { parseMembers, requireMemberNames } from "../storage/members.ts";
+import type { DefinitionRetrieval, WorkflowRunRecord } from "../storage/record.ts";
+import type { CommitIntent, OwnerLink, StartingFrontier } from "../remote/collector.ts";
+import type { CommitDecision } from "../remote/publication.ts";
+import { OwnerLinkError, type OwnerAnswer, type OwnerConnection } from "../remote/client.ts";
+export type { OwnerConnection };
+import {
+ parseRemoteExecution,
+ parseRemoteInvocationSnapshot,
+ parseRemoteJournalEntry,
+ type RemoteInvocationSnapshot,
+ parseRemoteRetrieval,
+ parseRemoteRunRecord,
+ RemoteRecordError,
+} from "../remote/records.ts";
+import {
+ type RemoteContent,
+ type RemoteContentRequest,
+ type RemoteFrontierSnapshot,
+ type RemoteReadLink,
+ startingFrontier,
+} from "../remote/read.ts";
+import {
+ parseWorkspaceRootManifest,
+ SHA256,
+ WORKSPACE_ROOT_DOMAIN,
+ type WorkspaceRootManifest,
+} from "../workspace/root-manifest.ts";
+import { decodeContentManifest } from "../workspace/content-manifest.ts";
+import {
+ EXECUTION_PAGE_BYTES,
+ EXECUTION_PAGE_ENTRIES,
+ executionPageBytes,
+ JOURNAL_PAGE_ENTRIES,
+ MAX_CONTENT_BYTES,
+} from "./commands.ts";
+import type { RemoteRunLink, RemoteWorkspaceLink } from "../remote/database.ts";
+import type { RemoteRetainedAnswer } from "../remote/answer-link.ts";
+import type { CreateWorkflowRunRequest } from "../storage/api.ts";
+import {
+ WorkflowRunConflictError,
+ WorkflowRunIdMismatchError,
+ WorkflowRunNotFoundError,
+} from "../storage/errors.ts";
+import { isSchemaVersion, SCHEMA_VERSION } from "../sqlite/workflow-schema.ts";
+import { canonicalJson } from "../storage/record.ts";
+import { parseJsonValue } from "../storage/members.ts";
+import {
+ WorkflowDatabaseCorruptError,
+ WorkflowDatabaseFormatError,
+ WorkflowSchemaVersionError,
+ WorkflowRecordMalformedError,
+ WorkflowRequestError,
+ WorkflowStorageError,
+ WorkflowTransactionError,
+} from "../storage/errors.ts";
+import type { DocumentExecutionRecord } from "../storage/record.ts";
+import { decodeBase64, encodeBase64, sha256Hex } from "./encoding.ts";
+
+export type PrivateRefusal =
+ | "acquisition:already-running"
+ | "acquisition:not-acquired"
+ | "acquisition:foreign-connection"
+ | "acquisition:wrong-run"
+ | "command:not-an-object"
+ | "command:unknown-command"
+ | "command:unknown-member"
+ | "command:malformed-member"
+ | "command:too-large"
+ | "command:duplicate-conflict"
+ | "command:capacity"
+ | "command:unavailable"
+ | "command:stale-root"
+ | "command:stale-journal"
+ | "command:mapping-conflict"
+ | "command:absent"
+ | "command:wrong-run"
+ | "command:corrupt-journal"
+ | "command:not-forkable"
+ | "command:wrong-execution"
+ | "command:needs-transfer"
+ | "command:not-suspended"
+ | "command:wrong-suspension"
+ | "command:answer-unavailable"
+ | "command:answer-rejected"
+ | "command:unjudgeable-schema"
+ | "command:credential-detected"
+ | "command:answer-unauthorized"
+ | "storage:foreign"
+ | `storage:unsupported-version-v${number}`
+ | "storage:corrupt";
+
+export class CloudflareOwnerRefusalError extends Error {
+ override name = "CloudflareOwnerRefusalError";
+
+ constructor(readonly refusal: PrivateRefusal) {
+ super(`the workflow owner refused the request (${refusal})`);
+ }
+}
+
+interface FrontierHeader {
+ readonly record: WorkflowRunRecord;
+ readonly retrieval: DefinitionRetrieval | undefined;
+ readonly workspaceRootId: string;
+ readonly journalEventId: string | null;
+}
+
+interface JournalPage {
+ readonly anchorEventId: string | null;
+ readonly afterEventId: string | null;
+ readonly entries: readonly {
+ readonly previousEventId: string | null;
+ readonly entry: JournalEntry;
+ }[];
+ readonly done: boolean;
+}
+
+function fail(reason: string): never {
+ throw new RemoteRecordError(`the owner returned a malformed private answer: ${reason}`);
+}
+
+function members(value: unknown, names: readonly string[]): Map {
+ const found = parseMembers(value, "$", (reason) => new RemoteRecordError(reason));
+ requireMemberNames(found, names, "$", (reason) => new RemoteRecordError(reason));
+ if (found.size !== names.length || names.some((name) => !found.has(name))) {
+ return fail("it omitted a declared member");
+ }
+ return found;
+}
+
+function rootId(value: unknown): string {
+ if (typeof value !== "string" || !SHA256.test(value)) {
+ return fail("it did not name a canonical Workspace root");
+ }
+ return value;
+}
+
+function nullableIdentity(value: unknown): string | null {
+ if (value === null) {
+ return null;
+ }
+ if (typeof value !== "string" || value === "") {
+ return fail("it did not name an event identity");
+ }
+ return value;
+}
+
+export function privateRefusal(value: string): PrivateRefusal {
+ switch (value) {
+ case "acquisition:already-running":
+ case "acquisition:not-acquired":
+ case "acquisition:foreign-connection":
+ case "acquisition:wrong-run":
+ case "command:not-an-object":
+ case "command:unknown-command":
+ case "command:unknown-member":
+ case "command:malformed-member":
+ case "command:too-large":
+ case "command:duplicate-conflict":
+ case "command:capacity":
+ case "command:unavailable":
+ case "command:stale-root":
+ case "command:stale-journal":
+ case "command:mapping-conflict":
+ case "command:absent":
+ case "command:wrong-run":
+ case "command:corrupt-journal":
+ case "command:not-forkable":
+ case "command:wrong-execution":
+ case "command:needs-transfer":
+ case "command:not-suspended":
+ case "command:wrong-suspension":
+ case "command:answer-unavailable":
+ case "command:answer-rejected":
+ case "command:unjudgeable-schema":
+ case "command:credential-detected":
+ case "command:answer-unauthorized":
+ case "storage:foreign":
+ case "storage:corrupt":
+ return value;
+ default: {
+ // The one category that carries a value: the schema version the owner
+ // actually read, bounded and parsed rather than guessed.
+ const unsupported = readUnsupportedVersion(value);
+ if (unsupported !== undefined) {
+ return `storage:unsupported-version-v${unsupported}`;
+ }
+ return fail("it named an unknown refusal category");
+ }
+ }
+}
+
+function answer(offered: OwnerAnswer): T {
+ if (offered.outcome === "refused") {
+ throw new CloudflareOwnerRefusalError(privateRefusal(offered.refusal));
+ }
+ return offered.value;
+}
+
+function parseFrontier(value: unknown): FrontierHeader {
+ const found = members(value, ["record", "retrieval", "workspaceRootId", "journalEventId"]);
+ return {
+ record: parseRemoteRunRecord(found.get("record")),
+ retrieval: parseRemoteRetrieval(found.get("retrieval")),
+ workspaceRootId: rootId(found.get("workspaceRootId")),
+ journalEventId: nullableIdentity(found.get("journalEventId")),
+ };
+}
+
+function parseJournalPage(value: unknown): JournalPage {
+ const found = members(value, ["anchorEventId", "afterEventId", "entries", "done"]);
+ const offered = found.get("entries");
+ if (!Array.isArray(offered) || offered.length > JOURNAL_PAGE_ENTRIES) {
+ return fail("it did not contain one bounded journal page");
+ }
+ if (typeof found.get("done") !== "boolean") {
+ return fail("it did not say whether the journal page was terminal");
+ }
+ return {
+ anchorEventId: nullableIdentity(found.get("anchorEventId")),
+ afterEventId: nullableIdentity(found.get("afterEventId")),
+ entries: offered.map((entry) => {
+ const item = members(entry, ["eventId", "previousEventId", "record", "workspaceRootId"]);
+ return {
+ previousEventId: nullableIdentity(item.get("previousEventId")),
+ entry: parseRemoteJournalEntry({
+ eventId: item.get("eventId"),
+ record: item.get("record"),
+ workspaceRootId: item.get("workspaceRootId"),
+ }),
+ };
+ }),
+ done: found.get("done") === true,
+ };
+}
+
+function parseAnchoredJournalPage(
+ value: unknown,
+ anchorEventId: string,
+ afterEventId: string | null,
+ seen: ReadonlySet,
+): JournalPage {
+ const page = parseJournalPage(value);
+ if (
+ page.anchorEventId !== anchorEventId ||
+ page.afterEventId !== afterEventId ||
+ page.entries.length === 0
+ ) {
+ return fail("a journal page did not continue its anchored snapshot");
+ }
+ let previous = afterEventId;
+ const found = new Set(seen);
+ for (const item of page.entries) {
+ if (item.previousEventId !== previous) {
+ return fail("an anchored journal page skipped or reordered an event");
+ }
+ if (found.has(item.entry.eventId)) {
+ return fail("an anchored journal repeated an event");
+ }
+ found.add(item.entry.eventId);
+ previous = item.entry.eventId;
+ }
+ if ((page.done && previous !== anchorEventId) || (!page.done && previous === anchorEventId)) {
+ return fail("an anchored journal page disagreed with its terminal event");
+ }
+ return page;
+}
+
+function parseRoot(value: unknown): { workspaceRootId: string; manifest: WorkspaceRootManifest } {
+ const found = members(value, ["workspaceRootId", "manifest"]);
+ const identity = rootId(found.get("workspaceRootId"));
+ const manifest = found.get("manifest");
+ if (
+ typeof manifest !== "string" ||
+ new TextEncoder().encode(manifest).length > MAX_CONTENT_BYTES
+ ) {
+ return fail("it did not contain one bounded root manifest");
+ }
+ const parsed = parseWorkspaceRootManifest(manifest, fail);
+ if (sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${manifest}`) !== identity) {
+ return fail("the root manifest disagreed with its identity");
+ }
+ return { workspaceRootId: identity, manifest: parsed };
+}
+
+function parseContent(value: unknown): RemoteContent {
+ const found = members(value, ["kind", "digest", "size", "bytes"]);
+ const kind = found.get("kind");
+ if (kind !== "manifest" && kind !== "blob") {
+ return fail("it did not name a content kind");
+ }
+ const digest = rootId(found.get("digest"));
+ const size = found.get("size");
+ const encoded = found.get("bytes");
+ if (
+ typeof size !== "number" ||
+ !Number.isSafeInteger(size) ||
+ size < 1 ||
+ size > MAX_CONTENT_BYTES ||
+ typeof encoded !== "string"
+ ) {
+ return fail("it did not contain one bounded content piece");
+ }
+ const bytes = decodeBase64(encoded);
+ if (bytes.length !== size || sha256Hex(bytes) !== digest) {
+ return fail("the content disagreed with its identity or size");
+ }
+ if (kind === "manifest") {
+ decodeContentManifest(bytes, fail);
+ }
+ return { kind, digest, bytes };
+}
+
+/**
+ * The schema version an unsupported-version refusal names, if it names one.
+ *
+ * The grammar covers exactly the versions the owner can recognize as
+ * unsupported, so a same-release owner and client never disagree about whether
+ * a refusal is readable. Anything else is not this category.
+ */
+function readUnsupportedVersion(refusal: string): number | undefined {
+ const found = /^storage:unsupported-version-v(\d{1,10})$/.exec(refusal);
+ if (found === null) {
+ return undefined;
+ }
+ const version = Number(found[1]);
+ return isSchemaVersion(version) ? version : undefined;
+}
+
+export function cloudflareReadLink(
+ connection: OwnerConnection,
+ nextId: () => string,
+ expectedRunId: string,
+): AnchoringReadLink {
+ const parseHeader = (value: unknown): FrontierHeader => {
+ const header = parseFrontier(value);
+ if (header.record.runId !== expectedRunId) {
+ return fail("an answer named another run");
+ }
+ return header;
+ };
+ function* anchored(header: FrontierHeader): Operation {
+ const entries: JournalEntry[] = [];
+ const seen = new Set();
+ let afterEventId: string | null = null;
+ let done = header.journalEventId === null;
+ while (!done) {
+ const page: JournalPage = answer(
+ yield* connection.ask(
+ nextId(),
+ { command: "journal", anchorEventId: header.journalEventId, afterEventId },
+ (value) =>
+ parseAnchoredJournalPage(value, header.journalEventId ?? "", afterEventId, seen),
+ privateRefusal,
+ ),
+ );
+ for (const item of page.entries) {
+ const entry = item.entry;
+ seen.add(entry.eventId);
+ entries.push(entry);
+ afterEventId = entry.eventId;
+ }
+ done = page.done;
+ }
+ return { ...header, entries };
+ }
+
+ return {
+ parseHeader,
+
+ *invocationSnapshot(): Operation {
+ return answer(
+ yield* connection.ask(
+ nextId(),
+ { command: "mappings" },
+ (value) => parseRemoteInvocationSnapshot(value),
+ privateRefusal,
+ ),
+ );
+ },
+
+ *frontier(): Operation {
+ const header = answer(
+ yield* connection.ask(
+ nextId(),
+ { command: "frontier" },
+ (value) => {
+ const parsed = parseFrontier(value);
+ if (parsed.record.runId !== expectedRunId) {
+ return fail("a frontier answer named another run");
+ }
+ return parsed;
+ },
+ privateRefusal,
+ ),
+ );
+ return yield* anchored(header);
+ },
+
+ /**
+ * One header's complete journal, read as pages anchored to it.
+ *
+ * Separate so the command that opened a run can finish the same coherent
+ * frontier from the header it already has, rather than asking for the
+ * header again and assembling a handle from two observations.
+ */
+ *anchored(header: FrontierHeader): Operation {
+ return yield* anchored(header);
+ },
+ *root(workspaceRootId: string): Operation {
+ const read = answer(
+ yield* connection.ask(
+ nextId(),
+ { command: "root", workspaceRootId },
+ (value) => {
+ const parsed = parseRoot(value);
+ if (parsed.workspaceRootId !== workspaceRootId) {
+ return fail("a root answer named another root");
+ }
+ return parsed;
+ },
+ privateRefusal,
+ ),
+ );
+ return read.manifest;
+ },
+ *content(workspaceRootId, request: RemoteContentRequest): Operation {
+ const read = answer(
+ yield* connection.ask(
+ nextId(),
+ {
+ command: "content",
+ workspaceRootId,
+ kind: request.kind,
+ digest: request.digest,
+ sourceManifest: request.kind === "blob" ? request.manifestDigest : null,
+ },
+ (value) => {
+ const parsed = parseContent(value);
+ if (parsed.kind !== request.kind || parsed.digest !== request.digest) {
+ return fail("a content answer named another piece");
+ }
+ return parsed;
+ },
+ privateRefusal,
+ ),
+ );
+ return read;
+ },
+ };
+}
+
+/**
+ * The runner's production link to its owner.
+ *
+ * `commit()` is the whole publication path: stage the pieces the owner does not
+ * have, encode one closed command, send it, and read the decision. The command
+ * identity is minted once per intent and reused verbatim on a retry, because
+ * the owner recognizes a retry by that identity and a regenerated one would be
+ * a second proposal rather than the same question asked again.
+ */
+export function cloudflareOwnerLink(
+ connection: OwnerConnection,
+ reads: RemoteReadLink,
+ nextId: () => string,
+): OwnerLink {
+ return {
+ *frontier(): Operation {
+ return startingFrontier(yield* reads.frontier());
+ },
+
+ *commit(intent: CommitIntent): Operation> {
+ // Derived from the request rather than counted. The owner recognizes a
+ // retry by this identity, so retrying one proposal has to produce the
+ // identity it already decided — a counter would make the second attempt a
+ // second question, and the owner would apply it again.
+ const request = commitRequest(intent);
+ const id = commandIdentity(request);
+ try {
+ yield* stageMissing(connection, nextId, intent);
+ const answered = yield* ask(connection, id, request, intent);
+ return answered.outcome === "refused"
+ ? Err(new CloudflareOwnerRefusalError(answered.refusal))
+ : Ok(answered.decision);
+ } catch (error) {
+ if (error instanceof OwnerLinkError || error instanceof RemoteRecordError) {
+ // The connection went while the answer was in flight, or the owner
+ // answered in a way this build cannot read. Whether the owner
+ // committed is exactly what cannot be known from either, so the
+ // caller learns the outcome is undecided rather than being told it
+ // failed — retrying this same id is what settles it.
+ return Err(error);
+ }
+ throw error;
+ }
+ },
+ };
+}
+
+/**
+ * The identity one closed command is known by.
+ *
+ * A digest of the exact bytes that will be sent, so two attempts at the same
+ * proposal share an identity and two different proposals cannot. It is bounded
+ * well inside the correlation limit and carries nothing about the run: it is a
+ * name for a request, not a fact about the Workspace.
+ */
+function commandIdentity(request: Record): string {
+ return `commit-${sha256Hex(JSON.stringify(request))}`;
+}
+
+/**
+ * One command sent and one answer read, checked against what was asked.
+ *
+ * A performed answer is not taken on its word. It has to name the root this
+ * proposal selected — the proposed one when there is a publication, the
+ * unchanged expected one when there is not — and one event identity for each
+ * event that was sent. An owner agreeing to something else is not an owner this
+ * runner can go on talking to: it would promote a Workspace nobody proposed, so
+ * the channel fails closed instead.
+ */
+function* ask(
+ connection: OwnerConnection,
+ id: string,
+ request: Record,
+ intent: CommitIntent,
+): Operation<
+ | { outcome: "performed"; decision: CommitDecision }
+ | { outcome: "refused"; refusal: PrivateRefusal }
+> {
+ const selected =
+ intent.publication === null
+ ? intent.expectedWorkspaceRootId
+ : intent.publication.proposedWorkspaceRootId;
+ const offered = yield* connection.ask(
+ id,
+ request,
+ (value): CommitDecision => {
+ const found = members(value, ["workspaceRootId", "journalEventIds"]);
+ const workspaceRootId = rootId(found.get("workspaceRootId"));
+ const ids = found.get("journalEventIds");
+ if (!Array.isArray(ids) || ids.some((entry) => typeof entry !== "string" || entry === "")) {
+ return fail("a commit answer did not name the events it retained");
+ }
+ if (workspaceRootId !== selected) {
+ return fail("a commit answer named a Workspace root this proposal did not select");
+ }
+ if (ids.length !== intent.events.length) {
+ return fail("a commit answer did not retain one identity for each proposed event");
+ }
+ return Object.freeze({ workspaceRootId, journalEventIds: Object.freeze([...ids]) });
+ },
+ privateRefusal,
+ );
+ return offered.outcome === "refused"
+ ? { outcome: "refused", refusal: privateRefusal(offered.refusal) }
+ : { outcome: "performed", decision: offered.value };
+}
+
+/**
+ * Send the pieces the owner does not already hold.
+ *
+ * Staging is idempotent by identity, so a retry after an ambiguous answer
+ * re-offers the same bytes and the owner recognizes them rather than storing
+ * them twice. Anything the owner already has is not sent at all: content is
+ * addressed by what it is, and re-uploading a Workspace it never lost would be
+ * bytes crossing for nothing.
+ */
+function* stageMissing(
+ connection: OwnerConnection,
+ nextId: () => string,
+ intent: CommitIntent,
+): Operation {
+ if (intent.publication === null) {
+ return;
+ }
+ for (const piece of intent.publication.content) {
+ const bytes = intent.bytes.get(piece.digest);
+ if (bytes === undefined) {
+ // The owner is expected to hold this one already. If it does not, the
+ // commit refuses rather than this guessing at bytes it does not have.
+ continue;
+ }
+ // The sealed bytes have to be the piece they were sealed as. Staging
+ // something else would mean the command identity described one proposal and
+ // the content described another.
+ if (bytes.length !== piece.size || sha256Hex(bytes) !== piece.digest) {
+ return fail("a sealed content piece does not match the identity it was proposed under");
+ }
+ yield* stageCloudflareContent(connection, nextId(), piece.kind, bytes);
+ }
+}
+
+/** The one closed command a complete intent becomes. */
+function commitRequest(intent: CommitIntent): Record {
+ return {
+ command: "commit",
+ expectedWorkspaceRootId: intent.expectedWorkspaceRootId,
+ expectedJournalEventId: intent.expectedJournalEventId,
+ publication:
+ intent.publication === null
+ ? null
+ : {
+ proposedWorkspaceRootId: intent.publication.proposedWorkspaceRootId,
+ proposedManifest: intent.publication.proposedManifest,
+ content: intent.publication.content.map((piece) => ({
+ kind: piece.kind,
+ digest: piece.digest,
+ size: piece.size,
+ })),
+ },
+ mappings: intent.mappings.map((mapping) =>
+ mapping.kind === "repository"
+ ? { kind: mapping.kind, record: { ...mapping.record }, locator: mapping.locator }
+ : { kind: mapping.kind, record: { ...mapping.record } },
+ ),
+ // Exactly what the serializer produces, in the order the transaction
+ // appended them. The owner parses each one and requires these same bytes.
+ events: intent.events.map((event) => serializeDurableEvent(event)),
+ // The retained answer this proposal spends, when it spends one. It names
+ // the wait and carries no value: the owner holds the value, and checks the
+ // event above against it before it spends anything.
+ answer:
+ intent.answer === null
+ ? null
+ : {
+ suspensionId: intent.answer.suspensionId,
+ requestEventId: intent.answer.requestEventId,
+ requestFingerprint: intent.answer.requestFingerprint,
+ },
+ };
+}
+
+export function* stageCloudflareContent(
+ connection: OwnerConnection,
+ id: string,
+ kind: RemoteContent["kind"],
+ bytes: Uint8Array,
+): Operation<{ kind: RemoteContent["kind"]; digest: string; size: number }> {
+ if (bytes.length === 0 || bytes.length > MAX_CONTENT_BYTES) {
+ return fail("the staged content is outside the private piece bound");
+ }
+ const digest = sha256Hex(bytes);
+ return answer(
+ yield* connection.ask(
+ id,
+ { command: "stage", kind, digest, bytes: encodeBase64(bytes) },
+ (value) => {
+ const found = members(value, ["kind", "digest", "size"]);
+ if (
+ found.get("kind") !== kind ||
+ found.get("digest") !== digest ||
+ found.get("size") !== bytes.length
+ ) {
+ return fail("a staging answer named another content piece");
+ }
+ return { kind, digest, size: bytes.length };
+ },
+ privateRefusal,
+ ),
+ );
+}
+
+/**
+ * The runner's production link to everything the database asks for.
+ *
+ * Wraps the publication link with the two reads and one mutation the database
+ * needs, so a handle receives one seam rather than assembling the protocol
+ * itself. Every answer is parsed and cross-checked against the request before
+ * it becomes a semantic value, and every failure crosses as a provider-neutral
+ * storage error rather than as a private refusal.
+ */
+/**
+ * One run's whole owner link, from one connection.
+ *
+ * The read link is made here rather than accepted, so the reads a Workspace
+ * invocation is admitted from and the commits it publishes cannot be two
+ * different owners. A caller holding this holds one authority.
+ */
+/** The read link, plus the paging an open answer finishes its frontier with. */
+export interface AnchoringReadLink extends RemoteReadLink {
+ anchored(header: FrontierHeader): Operation;
+ /** One owner answer read as a frontier header, before its journal is walked. */
+ parseHeader(value: unknown): FrontierHeader;
+}
+
+/**
+ * What an owner answers when asked to open a run.
+ *
+ * A conflict is an answer rather than a refusal because it carries something:
+ * the exact immutable fields that differ. The values behind them stay on the
+ * owner — what differs is enough for a caller to act, and what it differs to
+ * is the run's own content.
+ */
+type Opened =
+ | { readonly kind: "open"; readonly header: FrontierHeader }
+ | { readonly kind: "conflict"; readonly fields: readonly string[] };
+
+/** The immutable fields a creation can differ in, in the order they are read. */
+const CONFLICT_FIELDS: readonly string[] = ["run id", "definition", "base", "props"];
+
+function parseOpened(value: unknown, expectedRunId: string, runId: string): Opened {
+ const found = members(value, ["conflict", "frontier"]);
+ const conflict = found.get("conflict");
+ if (conflict !== null) {
+ if (found.get("frontier") !== null) {
+ return fail("an open answer both opened a run and refused one");
+ }
+ return { kind: "conflict", fields: parseConflictFields(conflict) };
+ }
+ const parsed = parseFrontier(found.get("frontier"));
+ if (parsed.record.runId !== expectedRunId || parsed.record.runId !== runId) {
+ return fail("an open answer named another run");
+ }
+ return { kind: "open", header: parsed };
+}
+
+/**
+ * The differing fields, held to the closed set and the canonical order.
+ *
+ * Strict because it decides what a public error says. An unknown name, a
+ * repeat, an empty list or a reordering is an answer this build cannot read,
+ * and reading it leniently would put text in an error that nothing produced.
+ */
+function parseConflictFields(value: unknown): readonly string[] {
+ if (!Array.isArray(value) || value.length === 0) {
+ return fail("an open answer named no differing field");
+ }
+ let previous = -1;
+ const fields: string[] = [];
+ for (const entry of value) {
+ const at = typeof entry === "string" ? CONFLICT_FIELDS.indexOf(entry) : -1;
+ if (at < 0 || at <= previous) {
+ return fail("an open answer named a differing field this build does not read");
+ }
+ previous = at;
+ fields.push(CONFLICT_FIELDS[at] ?? "");
+ }
+ return Object.freeze(fields);
+}
+
+/**
+ * One retained answer, as this build reads an owner's account of it.
+ *
+ * The value arrives as the canonical text the owner retained and is parsed
+ * here: what a later commit spends is compared against those bytes, so a value
+ * this build could not read back the same way is not one it may publish.
+ */
+function parseRetainedAnswer(
+ value: unknown,
+ suspensionId: string,
+): RemoteRetainedAnswer | undefined {
+ if (value === null) {
+ return undefined;
+ }
+ const found = members(value, [
+ "suspensionId",
+ "requestEventId",
+ "requestFingerprint",
+ "answer",
+ "state",
+ ]);
+ const named = found.get("suspensionId");
+ if (named !== suspensionId) {
+ return fail("a retained answer named a different wait");
+ }
+ const state = found.get("state");
+ if (state !== "pending" && state !== "consumed") {
+ return fail("a retained answer named a state this build does not read");
+ }
+ const encoded = found.get("answer");
+ if (typeof encoded !== "string" || encoded === "") {
+ return fail("a retained answer carried no value");
+ }
+ let decoded: unknown;
+ try {
+ decoded = JSON.parse(encoded);
+ } catch {
+ return fail("a retained answer carried a value this build cannot read");
+ }
+ const answer = parseJsonValue(
+ decoded,
+ "$",
+ () => new RemoteRecordError("a retained answer carried a value this build cannot read"),
+ );
+ if (canonicalJson(answer) !== encoded) {
+ return fail("a retained answer was not canonically encoded");
+ }
+ return Object.freeze({
+ suspensionId,
+ requestEventId: text(found.get("requestEventId"), "a retained answer named no request event"),
+ requestFingerprint: text(
+ found.get("requestFingerprint"),
+ "a retained answer named no request fingerprint",
+ ),
+ answer,
+ state,
+ });
+}
+
+function text(value: unknown, reason: string): string {
+ if (typeof value !== "string" || value === "") {
+ return fail(reason);
+ }
+ return value;
+}
+
+export function cloudflareRunLink(
+ connection: OwnerConnection,
+ nextId: () => string,
+ expectedRunId: string,
+): RemoteWorkspaceLink {
+ const reads = cloudflareReadLink(connection, nextId, expectedRunId);
+ const publication = cloudflareOwnerLink(connection, reads, nextId);
+ return {
+ ...reads,
+
+ /**
+ * Find this run, or create it exactly once.
+ *
+ * The answer is one coherent frontier: the header this command returns,
+ * and the journal anchored to it. Nothing here reads the frontier a second
+ * time, so what a handle is built from is one owner observation.
+ */
+ *open(
+ runId: string,
+ creation: CreateWorkflowRunRequest | null,
+ ): Operation> {
+ try {
+ const opened = yield* connection.ask(
+ nextId(),
+ { command: "open", runId, creation },
+ (value) => parseOpened(value, expectedRunId, runId),
+ privateRefusal,
+ );
+ if (opened.outcome === "refused") {
+ // Parsed rather than asserted: the answer's refusal is a string
+ // until this build reads it as one of its own categories.
+ const refusal = privateRefusal(opened.refusal);
+ // The one category this command adds. Nothing is stored here, which
+ // is a different fact from storage this build cannot use.
+ if (refusal === "command:absent") {
+ return Err(new WorkflowRunNotFoundError(runId));
+ }
+ if (refusal === "command:wrong-run") {
+ // Intact storage that belongs to another run. The retained id is
+ // the other run's business and does not travel.
+ return Err(new WorkflowRunIdMismatchError(runId, REMOTE_STORE));
+ }
+ return Err(storageFailure(refusal));
+ }
+ if (opened.value.kind === "conflict") {
+ // The exact fields the owner found differing, and none of their
+ // values. A run wearing this id is not this run.
+ return Err(new WorkflowRunConflictError(runId, opened.value.fields));
+ }
+ return Ok(yield* reads.anchored(opened.value.header));
+ } catch (error) {
+ return Err(translate(error));
+ }
+ },
+
+ /**
+ * Both halves of the publication link, translated.
+ *
+ * The database returns these failures through a provider-neutral interface,
+ * so a private refusal or a transport error must not travel as itself. This
+ * is the one place that translation happens.
+ */
+ *frontier(): Operation {
+ try {
+ return yield* publication.frontier();
+ } catch (error) {
+ throw translate(error);
+ }
+ },
+
+ *commit(intent: CommitIntent): Operation> {
+ try {
+ const committed = yield* publication.commit(intent);
+ return committed.ok ? committed : Err(translate(committed.error));
+ } catch (error) {
+ return Err(translate(error));
+ }
+ },
+
+ *frontierSnapshot(): Operation {
+ try {
+ return yield* reads.frontier();
+ } catch (error) {
+ throw translate(error);
+ }
+ },
+
+ *replaceRetrieval(
+ expectedWorkspaceRootId: string,
+ metadata: string | null,
+ ): Operation> {
+ // One identity per invocation, minted here. Two calls carrying identical
+ // metadata are two replacements and must not collapse into one, so the
+ // identity is not derived from the request's content.
+ const id = nextId();
+ try {
+ const answered = yield* connection.ask(
+ id,
+ { command: "retrieval", expectedWorkspaceRootId, metadata },
+ (value) => {
+ const found = members(value, ["retrieval"]);
+ const held = found.get("retrieval");
+ if (held === null) {
+ if (metadata !== null) {
+ return fail("a retrieval answer cleared a replacement that was not a clear");
+ }
+ return undefined;
+ }
+ const parsed = parseRemoteRetrieval(held);
+ if (parsed === undefined || metadata === null) {
+ return fail("a retrieval answer disagreed with the replacement it answered");
+ }
+ // Compared here, where the answer arrives. An owner that performed
+ // a different replacement than the one asked for is a channel the
+ // two sides disagree on, so it fails closed rather than handing
+ // back a value the caller would have to notice was wrong.
+ if (canonicalJson(parsed.metadata) !== metadata) {
+ return fail("a retrieval answer named metadata the request did not ask for");
+ }
+ return parsed;
+ },
+ privateRefusal,
+ );
+ return answered.outcome === "refused"
+ ? Err(storageFailure(privateRefusal(answered.refusal)))
+ : Ok(answered.value);
+ } catch (error) {
+ return Err(translate(error));
+ }
+ },
+
+ /**
+ * What this run retains for one wait, on this acquisition's authority.
+ *
+ * Answered as the owner retains it, canonical text and all, so the value a
+ * caller publishes is the value the owner will compare its commit against.
+ */
+ *pendingAnswer(
+ suspensionId: string,
+ requestEventId: string,
+ ): Operation> {
+ try {
+ const answered = yield* connection.ask(
+ nextId(),
+ { command: "answer", suspensionId, requestEventId },
+ (value) => parseRetainedAnswer(value, suspensionId),
+ privateRefusal,
+ );
+ return answered.outcome === "refused"
+ ? Err(storageFailure(privateRefusal(answered.refusal)))
+ : Ok(answered.value);
+ } catch (error) {
+ return Err(translate(error));
+ }
+ },
+
+ *readExecutions(): Operation> {
+ try {
+ const found: DocumentExecutionRecord[] = [];
+ let anchor: number | null | undefined;
+ let after: number | null = null;
+ let done = false;
+ while (!done) {
+ const page: ExecutionPage = yield* askPage(
+ connection,
+ nextId(),
+ expectedRunId,
+ anchor ?? null,
+ after,
+ );
+ // The first page chooses the snapshot. Every later one is held to it,
+ // and to the cursor it was asked to continue from.
+ const expected = anchor === undefined ? page.anchor : anchor;
+ anchor = expected;
+ if (page.anchor !== expected || page.after !== after) {
+ return Err(pageFailure("a page did not continue its anchored snapshot"));
+ }
+ if (page.anchor === null) {
+ // An empty snapshot is terminal and carries nothing.
+ if (page.rows.length > 0 || !page.done || after !== null) {
+ return Err(pageFailure("an empty snapshot carried rows or did not terminate"));
+ }
+ break;
+ }
+ if (page.rows.length === 0) {
+ // A page with nothing in it can only be the empty snapshot, which
+ // was handled above. Otherwise the read would never advance.
+ return Err(pageFailure("a page of an anchored snapshot carried no rows"));
+ }
+ let previous: number = after ?? 0;
+ for (const row of page.rows) {
+ if (row.sequence !== previous + 1) {
+ // Exactly adjacent: a gap would be retained history omitted from
+ // a snapshot that claims to be complete.
+ return Err(pageFailure("a page skipped, repeated or reordered a row"));
+ }
+ if (row.sequence > page.anchor) {
+ return Err(pageFailure("a page carried a row outside its snapshot"));
+ }
+ previous = row.sequence;
+ found.push(row.record);
+ }
+ if (page.done !== (previous === page.anchor)) {
+ // Terminal exactly at the anchor, and only there.
+ return Err(pageFailure("a page disagreed with its terminal row"));
+ }
+ after = previous;
+ done = page.done;
+ }
+ return Ok(found);
+ } catch (error) {
+ return Err(translate(error));
+ }
+ },
+ };
+}
+
+/** What a page that does not describe the snapshot it claims becomes. */
+function pageFailure(reason: string): WorkflowStorageError {
+ return new WorkflowRecordMalformedError("document executions", reason);
+}
+
+/** One execution page, with the private ordering the runner checks adjacency by. */
+interface ExecutionPage {
+ readonly anchor: number | null;
+ readonly after: number | null;
+ readonly rows: readonly { readonly sequence: number; readonly record: DocumentExecutionRecord }[];
+ readonly done: boolean;
+}
+
+function* askPage(
+ connection: OwnerConnection,
+ id: string,
+ expectedRunId: string,
+ anchor: number | null,
+ after: number | null,
+): Operation {
+ const answered = yield* connection.ask(
+ id,
+ { command: "executions", anchor, after },
+ (value): ExecutionPage => {
+ const found = members(value, ["runId", "anchor", "after", "rows", "done"]);
+ if (found.get("runId") !== expectedRunId) {
+ // Another run's retained history is not this run's, however well formed.
+ return fail("an execution page named another run");
+ }
+ const offered = found.get("rows");
+ if (!Array.isArray(offered) || offered.length > EXECUTION_PAGE_ENTRIES) {
+ return fail("an execution page was not one bounded page");
+ }
+ if (executionPageBytes(offered) > EXECUTION_PAGE_BYTES) {
+ // The page bound, not the message envelope. A page that ignored it
+ // would make the number of requests depend on how large one row is.
+ return fail("an execution page carried more than one page of rows");
+ }
+ if (typeof found.get("done") !== "boolean") {
+ return fail("an execution page did not say whether it was terminal");
+ }
+ const rows = offered.map((entry) => {
+ const item = members(entry, ["sequence", "record"]);
+ const sequence = item.get("sequence");
+ if (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence < 1) {
+ return fail("an execution row did not carry a position");
+ }
+ return { sequence, record: parseRemoteExecution(item.get("record")) };
+ });
+ return {
+ anchor: nullableSequence(found.get("anchor")),
+ after: nullableSequence(found.get("after")),
+ rows,
+ done: found.get("done") === true,
+ };
+ },
+ privateRefusal,
+ );
+ if (answered.outcome === "refused") {
+ throw new CloudflareOwnerRefusalError(privateRefusal(answered.refusal));
+ }
+ return answered.value;
+}
+
+function nullableSequence(value: unknown): number | null {
+ if (value === null) {
+ return null;
+ }
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
+ return fail("an execution page did not name a position");
+ }
+ return value;
+}
+
+/**
+ * The provider-neutral failure one private refusal becomes.
+ *
+ * A caller learns the category the local host would have reported for the same
+ * condition. Command names, refusal spellings, rows and cursors stay below this
+ * line: they describe a protocol nobody above here is party to.
+ */
+export function storageFailure(refusal: PrivateRefusal): WorkflowStorageError {
+ // A host acts on these differently: storage belonging to something else may
+ // not be written, a version this build does not implement may not be
+ // migrated, and damage may not be repaired. Collapsing them would make all
+ // three look like the one that says "restore from a backup".
+ if (refusal === "storage:foreign") {
+ return new WorkflowDatabaseFormatError(REMOTE_STORE, "it belongs to something else");
+ }
+ const unsupported = readUnsupportedVersion(refusal);
+ if (unsupported !== undefined) {
+ return new WorkflowSchemaVersionError(REMOTE_STORE, unsupported, SCHEMA_VERSION);
+ }
+ if (refusal === "storage:corrupt") {
+ return new WorkflowDatabaseCorruptError(REMOTE_STORE, "its retained records do not agree");
+ }
+ if (refusal === "command:stale-root" || refusal === "command:stale-journal") {
+ return new WorkflowTransactionError(
+ "this run has moved since the operation read it, so the change was not applied.",
+ );
+ }
+ if (refusal === "command:wrong-execution") {
+ // A request about somebody else's work rather than a failure of this one:
+ // the caller named an execution its own acquisition never began.
+ return new WorkflowRequestError(
+ "this executor lock did not begin the document execution it is settling.",
+ );
+ }
+ if (refusal === "command:capacity") {
+ return new WorkflowRequestError("this run's owner cannot accept more work on this connection.");
+ }
+ if (refusal === "command:not-suspended") {
+ return new WorkflowRequestError(
+ "this workflow run is not waiting for an answer, and only a suspended run is.",
+ );
+ }
+ if (refusal === "command:wrong-suspension") {
+ return new WorkflowRequestError(
+ "this workflow run is not waiting at that suspension. A run waits at one at a time.",
+ );
+ }
+ if (refusal === "command:answer-rejected") {
+ return new WorkflowRequestError(
+ "the value offered to this wait does not satisfy the response schema that wait retained.",
+ );
+ }
+ if (refusal === "command:unjudgeable-schema") {
+ return new WorkflowRequestError(
+ "the response schema this wait retained is not one an answer can be judged against, so " +
+ "no value can be delivered to it.",
+ );
+ }
+ if (refusal === "command:credential-detected") {
+ // What was matched never travels: a diagnostic quoting it would publish
+ // exactly what the gate exists to keep out of retained state.
+ return new WorkflowRequestError(
+ "this answer was not retained because credential detection matched it. Neither the value " +
+ "nor the match is recorded. Deliver with secret detection disabled only when the value " +
+ "is known not to be a credential.",
+ );
+ }
+ if (refusal === "command:answer-unavailable") {
+ // Nothing retained, already published, or delivered against a different
+ // request. All three are facts about the wait rather than failures to
+ // reach the run.
+ return new WorkflowRequestError("there is no delivered answer this wait may be ended with.");
+ }
+ return new WorkflowTransactionError("this run's owner refused the operation.");
+}
+
+/**
+ * What a public error names instead of a path.
+ *
+ * A remote run has no file, and naming one would be an invitation to look for
+ * it. The store is named as what it is.
+ */
+const REMOTE_STORE = "this run's remote storage";
+
+/**
+ * Any failure from the private protocol, as a provider-neutral one.
+ *
+ * Nothing private crosses: not a refusal class, not a refusal spelling, not the
+ * message a parser wrote about a value it refused. A record this build cannot
+ * read is a malformed record rather than an unreachable owner, because those
+ * are different facts and a caller acts on them differently.
+ */
+export function translate(error: unknown): WorkflowStorageError {
+ if (error instanceof CloudflareOwnerRefusalError) {
+ return storageFailure(error.refusal);
+ }
+ if (error instanceof WorkflowStorageError) {
+ return error;
+ }
+ if (error instanceof RemoteRecordError) {
+ return new WorkflowRecordMalformedError(
+ "record this run's owner returned",
+ "it is not a record this build can read",
+ );
+ }
+ if (error instanceof OwnerLinkError) {
+ if (error.refusal === "too-large") {
+ // The channel measured the whole request and never sent it. That is a
+ // request this caller cannot make, not an owner it could not reach, and
+ // the two lead a host to do different things.
+ return new WorkflowRequestError(
+ "this request is larger than one message may carry, so it was not sent.",
+ );
+ }
+ return new WorkflowTransactionError("this run's owner could not be reached.");
+ }
+ return new WorkflowTransactionError("this run's owner could not answer the operation.");
+}
diff --git a/packages/workflow/src/cloudflare/commands.ts b/packages/workflow/src/cloudflare/commands.ts
new file mode 100644
index 000000000..2371fc6ea
--- /dev/null
+++ b/packages/workflow/src/cloudflare/commands.ts
@@ -0,0 +1,1132 @@
+import {
+ type DocumentExecutionCompletion,
+ parseDocumentExecutionCompletion,
+} from "../storage/record.ts";
+import {
+ type DurableEvent,
+ parseDurableEvent,
+ serializeDurableEvent,
+} from "@executablemd/durable-streams";
+import { SHA256 } from "../workspace/root-manifest.ts";
+import { admitLocator, locatorFingerprintOf } from "../composition/locator.ts";
+import {
+ parseRepositoryRecord,
+ parseWorktreeRecord,
+ type RepositoryRecord,
+ type WorktreeRecord,
+} from "../composition/records.ts";
+import { type AgentSessionRecord, parseAgentSessionRecord } from "../storage/agent-session.ts";
+import { parseCreateRequest } from "../storage/create-request.ts";
+import type { CreateWorkflowRunRequest } from "../storage/api.ts";
+import { parseJsonValue } from "../storage/members.ts";
+import { canonicalJson } from "../storage/record.ts";
+
+/** The most characters a public run id may carry. */
+const MAX_RUN_ID = 128;
+import { MAX_MESSAGE_BYTES } from "../remote/client.ts";
+
+export { MAX_MESSAGE_BYTES };
+
+export const MAX_CONTENT_BYTES = 1024 * 1024;
+export const MAX_STAGED_BYTES = 2 * 1024 * 1024;
+export const MAX_COMMANDS = 256;
+export const MAX_LEDGER_BYTES = 2 * 1024 * 1024;
+export const JOURNAL_PAGE_ENTRIES = 128;
+export const JOURNAL_PAGE_BYTES = 512 * 1024;
+/** The most document-execution rows one private page carries. */
+export const EXECUTION_PAGE_ENTRIES = 128;
+/** The most serialized bytes of retained execution rows one page carries. */
+export const EXECUTION_PAGE_BYTES = 512 * 1024;
+
+/**
+ * How both ends measure one execution page.
+ *
+ * One function rather than two similar sums: the owner decides what fits and
+ * the runner checks it, and if they measured different things an honest page
+ * near the bound would be sent by one and refused by the other. What is
+ * measured is the exact `rows` member as it crosses, wrappers and punctuation
+ * included, because that is what the bound is about.
+ */
+export function executionPageBytes(rows: readonly unknown[]): number {
+ return new TextEncoder().encode(JSON.stringify(rows)).length;
+}
+/** The most content identities one proposal may name. */
+export const MAX_PROPOSED_PIECES = 8192;
+/** The most retained mapping changes one proposal may carry. */
+export const MAX_MAPPINGS = 256;
+/** The longest canonical root manifest this owner reads. */
+export const MAX_ROOT_MANIFEST_BYTES = MAX_CONTENT_BYTES;
+
+export type CommandName =
+ | "frontier"
+ | "journal"
+ | "root"
+ | "content"
+ | "stage"
+ | "commit"
+ | "retrieval"
+ | "executions"
+ | "mappings"
+ | "open"
+ | "begin"
+ | "cancel"
+ | "settle"
+ | "fork-stage"
+ | "fork"
+ | "fork-continue"
+ | "answer";
+
+export type CommandRefusal =
+ | "not-an-object"
+ | "unknown-command"
+ | "unknown-member"
+ | "malformed-member"
+ | "too-large"
+ | "duplicate-conflict"
+ | "capacity"
+ | "unavailable"
+ // The frontier moved under the proposal. Not malformed and not a conflict of
+ // identity: the request was true when it was built and is not true now.
+ | "stale-root"
+ | "stale-journal"
+ // A retained mapping already exists and describes something else. Creation
+ // identity is immutable, so this is refused rather than rewritten.
+ | "mapping-conflict"
+ /** No run is stored here at all. A lookup found nothing, and made nothing. */
+ | "absent"
+ /**
+ * A run is stored here, and it is not the run this request addresses.
+ *
+ * A retained record that parses and names another run. Distinct from damage:
+ * the storage is intact and this is simply not its run, and a caller that
+ * conflated them would go looking for a backup.
+ */
+ | "wrong-run"
+ /** Retained journal history this owner cannot read. */
+ | "corrupt-journal"
+ /** The selected prefix is not one a fork could inherit. */
+ | "not-forkable"
+ /**
+ * This acquisition did not begin the execution it is asking about.
+ *
+ * The run is intact and the execution may well exist; it belongs to a
+ * different acquisition, and a live executor does not get to finish an
+ * earlier executor's work by naming its id.
+ */
+ | "wrong-execution"
+ /**
+ * A fork was asked to commit a transfer this connection never offered.
+ *
+ * Distinct from a malformed request: the request is well formed, the
+ * destination holds nothing, and the parts it names are not here — so the
+ * caller's next move is to copy the source again rather than to give up.
+ */
+ | "needs-transfer"
+ /**
+ * A value was offered to a run that is not waiting for one.
+ *
+ * The run is intact and this owner holds it; it is running, finished,
+ * cancelled, or stopped for something other than a durable wait. A caller
+ * acts on that rather than retrying.
+ */
+ | "not-suspended"
+ /**
+ * A value was offered to a wait this run is not standing at.
+ *
+ * A run waits at one suspension at a time. The identifier names another one,
+ * or names a request published elsewhere in this run's history.
+ */
+ | "wrong-suspension"
+ /**
+ * There is no retained answer this commit may spend.
+ *
+ * Nothing was delivered, or it was already published, or it was delivered
+ * against a different request, or the event this commit appends is not the
+ * one that answer would become. The commit is refused whole.
+ */
+ | "answer-unavailable"
+ /**
+ * The offered value is not one this wait's schema admits.
+ *
+ * The value itself never travels back with the refusal, and neither does
+ * what was wrong with it beyond this category: a diagnostic quoting either
+ * would publish, where nothing filters it, what the judgment refused.
+ */
+ | "answer-rejected"
+ /**
+ * The wait retained a schema this build cannot judge an answer against.
+ *
+ * Distinct from a rejected value: nothing is wrong with what was offered,
+ * and this owner will not retain a value it could not check.
+ */
+ | "unjudgeable-schema"
+ /**
+ * The offered value crossed the credential gate and did not pass it.
+ *
+ * What was matched is never reported, and neither is the value.
+ */
+ | "credential-detected"
+ /**
+ * A proposal's answer events are not the ones its consumption authorizes.
+ *
+ * One retained answer ends one wait with one event. A proposal appending an
+ * answer event that no consumption authorizes is forging history, and one
+ * appending more than one is ending more waits than it spends.
+ */
+ | "answer-unauthorized";
+
+export class CommandError extends Error {
+ override name = "CommandError";
+
+ constructor(readonly refusal: CommandRefusal) {
+ super(`this owner refused a runner command (${refusal})`);
+ }
+}
+
+export interface CommandEnvelope {
+ readonly id: string;
+ readonly command: CommandName;
+}
+
+export interface FrontierCommand extends CommandEnvelope {
+ readonly command: "frontier";
+}
+
+export interface JournalCommand extends CommandEnvelope {
+ readonly command: "journal";
+ readonly anchorEventId: string | null;
+ readonly afterEventId: string | null;
+}
+
+export interface RootCommand extends CommandEnvelope {
+ readonly command: "root";
+ readonly workspaceRootId: string;
+}
+
+export type ContentKind = "manifest" | "blob";
+
+export interface ContentCommand extends CommandEnvelope {
+ readonly command: "content";
+ readonly workspaceRootId: string;
+ readonly kind: ContentKind;
+ readonly digest: string;
+ readonly sourceManifest: string | null;
+}
+
+export interface StageCommand extends CommandEnvelope {
+ readonly command: "stage";
+ readonly kind: ContentKind;
+ readonly digest: string;
+ readonly bytes: string;
+}
+
+/**
+ * One closed proposal, and everything the owner needs to decide it.
+ *
+ * The earlier shape carried a proposed root identity and nothing that could
+ * justify it — an identity with no manifest and no content closure is a name,
+ * not a proposal, and an owner adopting one would be taking the runner's word
+ * for what a root contains. This carries the whole thing: what the runner
+ * started from, what it proposes, the canonical manifest that identity is the
+ * digest of, the exact content that manifest closes over, the retained mappings
+ * the same operation produced, and the filtered events to append.
+ *
+ * `publication` is absent for a transaction that only appended to the journal.
+ * That is a real case rather than a degenerate one, and inventing a Workspace
+ * change to fill it would publish a root nothing asked for.
+ */
+export interface CommitCommand extends CommandEnvelope {
+ readonly command: "commit";
+ readonly expectedWorkspaceRootId: string;
+ readonly expectedJournalEventId: string | null;
+ readonly publication: ProposedPublication | null;
+ readonly mappings: readonly ProposedMapping[];
+ /** Exactly what `serializeDurableEvent` produced, terminating newline included. */
+ readonly events: readonly string[];
+ /**
+ * The retained answer this proposal spends, when it spends one.
+ *
+ * `null` for every ordinary commit. It names a wait, the event its request
+ * was published as and the fingerprint it was delivered against, and carries
+ * no value: the owner holds the value already, and one arriving here would be
+ * the runner saying what it is owed.
+ */
+ readonly answer: ProposedAnswerConsumption | null;
+}
+
+/** Which retained answer one proposal spends. */
+export interface ProposedAnswerConsumption {
+ readonly suspensionId: string;
+ readonly requestEventId: string;
+ readonly requestFingerprint: string;
+}
+
+/**
+ * What one run retains for a wait, asked for by the acquisition that may spend
+ * it.
+ *
+ * A read rather than a mutation, and on the executor plane rather than the
+ * delivery plane, because it is read in order to be published: only the
+ * executor publishes.
+ */
+export interface AnswerCommand extends CommandEnvelope {
+ readonly command: "answer";
+ readonly suspensionId: string;
+ /**
+ * The exact journal event this claim says the wait's request was published as.
+ *
+ * Named because a suspension identifier is derivable and this is not: the
+ * owner compares it with what the run is actually standing at, so a caller
+ * that guessed an identifier is asking about a wait rather than claiming one.
+ */
+ readonly requestEventId: string;
+}
+
+/** The Workspace half of a proposal, when there is one. */
+export interface ProposedPublication {
+ readonly proposedWorkspaceRootId: string;
+ readonly proposedManifest: string;
+ readonly content: readonly ProposedPiece[];
+}
+
+/** One content identity the proposed root closes over. */
+export interface ProposedPiece {
+ readonly kind: ContentKind;
+ readonly digest: string;
+ readonly size: number;
+}
+
+/** One retained mapping the proposal carries, already parsed. */
+export type ProposedMapping =
+ | { readonly kind: "repository"; readonly record: RepositoryRecord; readonly locator: string }
+ | { readonly kind: "worktree"; readonly record: WorktreeRecord }
+ | { readonly kind: "agent-session"; readonly record: AgentSessionRecord };
+
+/**
+ * Replace or clear where the definition can be fetched from.
+ *
+ * Its own mutation rather than a degenerate commit: it appends no journal
+ * event, publishes no root, and its revision is authoritative rather than
+ * proposed. `metadata` is `null` to clear, which is a different act from
+ * writing an empty object — clearing removes the row and the next replacement
+ * starts counting again.
+ *
+ * The expected root travels with it so the owner can refuse a replacement
+ * proposed against a frontier that has moved, the same way a commit is refused.
+ */
+export interface RetrievalCommand extends CommandEnvelope {
+ readonly command: "retrieval";
+ readonly expectedWorkspaceRootId: string;
+ /** Canonical JSON, already encoded by the runner, or `null` to clear. */
+ readonly metadata: string | null;
+}
+
+/**
+ * One page of the document executions this run has begun.
+ *
+ * Anchored like the journal: the first page fixes the last execution that
+ * existed when the read began, and every later page is constrained to it, so an
+ * execution started while the read is in flight cannot appear halfway through.
+ */
+export interface ExecutionsCommand extends CommandEnvelope {
+ readonly command: "executions";
+ /** The terminal sequence this snapshot is anchored to, or `null` for empty. */
+ readonly anchor: number | null;
+ /** The sequence the previous page ended at, or `null` for the first page. */
+ readonly after: number | null;
+}
+
+/** One coherent admitted state, asked for exactly once per invocation. */
+export interface MappingsCommand extends CommandEnvelope {
+ readonly command: "mappings";
+}
+
+/**
+ * Find this run, or create it exactly once.
+ *
+ * `creation` absent is a lookup and makes nothing. Present, it is the run's
+ * complete immutable identity, and repeating it is how a caller addresses the
+ * same run again rather than a second attempt at making one.
+ */
+export interface OpenCommand extends CommandEnvelope {
+ readonly command: "open";
+ readonly runId: string;
+ readonly creation: CreateWorkflowRunRequest | null;
+}
+
+/** Begin one document execution under the live acquisition. */
+export interface BeginCommand extends CommandEnvelope {
+ readonly command: "begin";
+ readonly runId: string;
+ readonly action: "start" | "resume";
+ readonly creation: CreateWorkflowRunRequest | null;
+ /**
+ * Where this run's definition can be fetched from again, when it is being
+ * created.
+ *
+ * Replaceable state rather than identity, so it travels beside the creation
+ * instead of inside it: a run is not a different run for having been fetched
+ * from somewhere else.
+ */
+ readonly retrieval: string | null;
+ /**
+ * The execution's identity, minted by the runner.
+ *
+ * Minted there rather than here so the command is the same bytes on a retry:
+ * an owner that invented one would begin a second execution for a request it
+ * had already answered.
+ */
+ readonly executionId: string;
+}
+
+/** Make one run terminal, following what it retains. */
+export interface CancelCommand extends CommandEnvelope {
+ readonly command: "cancel";
+ readonly runId: string;
+}
+
+/** The sections a fork's parts arrive in, each in its own order. */
+export type ForkSection = "inherited" | "roots" | "manifests" | "blobs" | "checkouts";
+
+/** One part of a fork, as the runner offers it. */
+export interface ForkPart {
+ readonly section: ForkSection;
+ readonly position: number;
+ readonly part: Record;
+}
+
+/** What the final command says the staged selection should add up to. */
+export interface ForkCounts {
+ readonly inherited: number;
+ readonly roots: number;
+ readonly manifests: number;
+ readonly blobs: number;
+ readonly checkouts: number;
+}
+
+/** Which committed checkpoint of which run this fork continues. */
+export interface ForkOrigin {
+ readonly sourceRunId: string;
+ readonly checkpointEventId: string;
+ readonly checkpointWorkspaceRootId: string;
+ readonly runRecordWorkspaceRootId: string;
+ readonly rootImportWorkspaceRootId: string;
+ /** The source selection's own anchor, kept with the lineage's evidence. */
+ readonly anchor: string;
+}
+
+/**
+ * Offer one part of a fork's source, before any of it is a run.
+ *
+ * Scratch belonging to this connection. A part says where it stands in its
+ * section so the final command can tell a complete transfer from a partial one.
+ */
+export interface ForkStageCommand extends CommandEnvelope {
+ readonly command: "fork-stage";
+ readonly section: ForkSection;
+ readonly position: number;
+ readonly part: Record;
+}
+
+/** Commit the offered parts as one destination run and its first execution. */
+export interface ForkCommand extends CommandEnvelope {
+ readonly command: "fork";
+ readonly runId: string;
+ readonly creation: CreateWorkflowRunRequest;
+ readonly retrieval: string | null;
+ readonly origin: ForkOrigin;
+ readonly counts: ForkCounts;
+ readonly runRecord: DurableEvent;
+ readonly rootImport: DurableEvent;
+ readonly executionId: string;
+}
+
+/**
+ * Take up a destination that already holds this fork.
+ *
+ * No origin and no counts: a committed fork is independent of the run it was
+ * copied from, so continuing one asks only what the destination itself
+ * retains.
+ */
+export interface ForkContinueCommand extends CommandEnvelope {
+ readonly command: "fork-continue";
+ readonly runId: string;
+ readonly creation: CreateWorkflowRunRequest;
+ /** Which fork this claims to be, as the destination retains it. */
+ readonly origin: ForkContinuationOrigin;
+ readonly runRecord: DurableEvent;
+ readonly rootImport: DurableEvent;
+ readonly executionId: string;
+}
+
+/**
+ * The identity a continuation claims, compared against retained state.
+ *
+ * No anchor and no counts: those describe a copy in flight. What a destination
+ * that already holds the fork can be held to is where it came from and what it
+ * wrote for itself.
+ */
+export interface ForkContinuationOrigin {
+ readonly sourceRunId: string;
+ readonly checkpointEventId: string;
+}
+
+export interface SettleCommand extends CommandEnvelope {
+ readonly command: "settle";
+ readonly completion: DocumentExecutionCompletion;
+ readonly expectedWorkspaceRootId: string;
+}
+
+export type RunnerCommand =
+ | FrontierCommand
+ | JournalCommand
+ | RootCommand
+ | ContentCommand
+ | StageCommand
+ | CommitCommand
+ | RetrievalCommand
+ | ExecutionsCommand
+ | MappingsCommand
+ | OpenCommand
+ | BeginCommand
+ | CancelCommand
+ | SettleCommand
+ | ForkStageCommand
+ | ForkCommand
+ | ForkContinueCommand
+ | AnswerCommand;
+
+export type CommandResult =
+ | { readonly id: string; readonly outcome: "performed"; readonly value: unknown }
+ | { readonly id: string; readonly outcome: "refused"; readonly refusal: string };
+
+const MAX_ID = 128;
+const MAX_EVENTS = 4096;
+const ENVELOPE = ["id", "command"];
+const MEMBERS: Record = {
+ frontier: ENVELOPE,
+ journal: [...ENVELOPE, "anchorEventId", "afterEventId"],
+ root: [...ENVELOPE, "workspaceRootId"],
+ content: [...ENVELOPE, "workspaceRootId", "kind", "digest", "sourceManifest"],
+ stage: [...ENVELOPE, "kind", "digest", "bytes"],
+ commit: [
+ ...ENVELOPE,
+ "expectedWorkspaceRootId",
+ "expectedJournalEventId",
+ "publication",
+ "mappings",
+ "events",
+ "answer",
+ ],
+ answer: [...ENVELOPE, "suspensionId", "requestEventId"],
+ retrieval: [...ENVELOPE, "expectedWorkspaceRootId", "metadata"],
+ executions: [...ENVELOPE, "anchor", "after"],
+ mappings: ENVELOPE,
+ open: [...ENVELOPE, "runId", "creation"],
+ begin: [...ENVELOPE, "runId", "action", "creation", "retrieval", "executionId"],
+ cancel: [...ENVELOPE, "runId"],
+ settle: [...ENVELOPE, "completion", "expectedWorkspaceRootId"],
+ "fork-stage": [...ENVELOPE, "section", "position", "part"],
+ "fork-continue": [
+ ...ENVELOPE,
+ "runId",
+ "creation",
+ "origin",
+ "runRecord",
+ "rootImport",
+ "executionId",
+ ],
+ fork: [
+ ...ENVELOPE,
+ "runId",
+ "creation",
+ "retrieval",
+ "origin",
+ "counts",
+ "runRecord",
+ "rootImport",
+ "executionId",
+ ],
+};
+
+function object(value: unknown): Map {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ throw new CommandError("not-an-object");
+ }
+ return new Map(Object.entries(value));
+}
+
+function closed(members: Map, allowed: readonly string[]): void {
+ for (const key of members.keys()) {
+ if (!allowed.includes(key)) {
+ throw new CommandError("unknown-member");
+ }
+ }
+ if (members.size !== allowed.length) {
+ throw new CommandError("malformed-member");
+ }
+}
+
+function text(
+ members: Map,
+ key: string,
+ maximum = Number.MAX_SAFE_INTEGER,
+): string {
+ const value = members.get(key);
+ if (typeof value !== "string" || value === "" || value.length > maximum) {
+ throw new CommandError(
+ value !== "" && typeof value === "string" ? "too-large" : "malformed-member",
+ );
+ }
+ return value;
+}
+
+function nullableText(members: Map, key: string): string | null {
+ const value = members.get(key);
+ if (value === null) {
+ return null;
+ }
+ if (typeof value !== "string" || value === "") {
+ throw new CommandError("malformed-member");
+ }
+ return value;
+}
+
+function digest(members: Map, key: string): string {
+ const value = members.get(key);
+ if (typeof value !== "string" || !SHA256.test(value)) {
+ throw new CommandError("malformed-member");
+ }
+ return value;
+}
+
+function kind(members: Map): ContentKind {
+ const value = members.get("kind");
+ if (value !== "manifest" && value !== "blob") {
+ throw new CommandError("malformed-member");
+ }
+ return value;
+}
+
+/**
+ * The exact serialized events a proposal appends.
+ *
+ * A record is not admitted because it is a non-empty string, and not because
+ * SQLite will accept it as JSON. It is parsed with the authoritative durable
+ * event parser and then serialized again, and the result must be the same bytes
+ * that arrived, terminating newline included.
+ *
+ * That round trip is the point. Retaining something that parses as JSON but not
+ * as an event would create history a later read cannot understand, and the run
+ * would become unreplayable at exactly the moment it was told it had committed.
+ * Re-encoding a nearly-right record would be worse: the owner would retain
+ * something the runner never proposed.
+ */
+/** A physical sequence, which is a positive whole number or nothing. */
+function sequence(members: Map, key: string): number | null {
+ const value = members.get(key);
+ if (value === null) {
+ return null;
+ }
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
+ throw new CommandError("malformed-member");
+ }
+ return value;
+}
+
+function eventRecords(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ throw new CommandError("malformed-member");
+ }
+ if (value.length > MAX_EVENTS) {
+ throw new CommandError("too-large");
+ }
+ return value.map((entry) => {
+ if (typeof entry !== "string" || entry === "") {
+ throw new CommandError("malformed-member");
+ }
+ const parsed = parseDurableEvent(entry);
+ if (!parsed.ok || serializeDurableEvent(parsed.value) !== entry) {
+ throw new CommandError("malformed-member");
+ }
+ return entry;
+ });
+}
+
+export function parseCommand(raw: string): RunnerCommand {
+ if (new TextEncoder().encode(raw).length > MAX_MESSAGE_BYTES) {
+ throw new CommandError("too-large");
+ }
+ let decoded: unknown;
+ try {
+ decoded = JSON.parse(raw);
+ } catch {
+ throw new CommandError("not-an-object");
+ }
+ const members = object(decoded);
+ const id = text(members, "id", MAX_ID);
+ const command = members.get("command");
+ if (
+ command !== "frontier" &&
+ command !== "journal" &&
+ command !== "root" &&
+ command !== "content" &&
+ command !== "stage" &&
+ command !== "commit" &&
+ command !== "retrieval" &&
+ command !== "executions" &&
+ command !== "mappings" &&
+ command !== "open" &&
+ command !== "begin" &&
+ command !== "cancel" &&
+ command !== "settle" &&
+ command !== "fork-stage" &&
+ command !== "fork" &&
+ command !== "fork-continue" &&
+ command !== "answer"
+ ) {
+ throw new CommandError("unknown-command");
+ }
+ closed(members, MEMBERS[command]);
+
+ if (command === "frontier") {
+ return { id, command };
+ }
+ if (command === "journal") {
+ return {
+ id,
+ command,
+ anchorEventId: nullableText(members, "anchorEventId"),
+ afterEventId: nullableText(members, "afterEventId"),
+ };
+ }
+ if (command === "root") {
+ return { id, command, workspaceRootId: digest(members, "workspaceRootId") };
+ }
+ if (command === "content") {
+ const contentKind = kind(members);
+ if (contentKind === "manifest" && members.get("sourceManifest") !== null) {
+ throw new CommandError("malformed-member");
+ }
+ const sourceManifest = contentKind === "manifest" ? null : digest(members, "sourceManifest");
+ return {
+ id,
+ command,
+ workspaceRootId: digest(members, "workspaceRootId"),
+ kind: contentKind,
+ digest: digest(members, "digest"),
+ sourceManifest,
+ };
+ }
+ if (command === "stage") {
+ return {
+ id,
+ command,
+ kind: kind(members),
+ digest: digest(members, "digest"),
+ bytes: text(members, "bytes", Math.ceil((MAX_CONTENT_BYTES * 4) / 3) + 4),
+ };
+ }
+ if (command === "retrieval") {
+ const metadata = members.get("metadata");
+ if (metadata !== null && (typeof metadata !== "string" || metadata === "")) {
+ throw new CommandError("malformed-member");
+ }
+ if (metadata !== null && new TextEncoder().encode(metadata).length > MAX_MESSAGE_BYTES) {
+ throw new CommandError("too-large");
+ }
+ return {
+ id,
+ command,
+ expectedWorkspaceRootId: digest(members, "expectedWorkspaceRootId"),
+ metadata,
+ };
+ }
+ if (command === "mappings") {
+ return { id, command };
+ }
+ if (command === "answer") {
+ return {
+ id,
+ command,
+ suspensionId: text(members, "suspensionId", MAX_ID),
+ requestEventId: text(members, "requestEventId", MAX_ID),
+ };
+ }
+ if (command === "open") {
+ const runId = text(members, "runId", MAX_RUN_ID);
+ const offered = members.get("creation");
+ if (offered === null) {
+ return { id, command, runId, creation: null };
+ }
+ // Parsed through the shared request parser, so what the owner will retain
+ // is what this build calls a creation request rather than an object that
+ // resembles one.
+ const creation = parseCreateRequest(offered);
+ if (!creation.ok || creation.value.runId !== runId) {
+ throw new CommandError("malformed-member");
+ }
+ return { id, command, runId, creation: creation.value };
+ }
+ if (command === "begin") {
+ const runId = text(members, "runId", MAX_RUN_ID);
+ const action = members.get("action");
+ if (action !== "start" && action !== "resume") {
+ throw new CommandError("malformed-member");
+ }
+ const offered = members.get("creation");
+ let creation: CreateWorkflowRunRequest | null = null;
+ if (offered !== null) {
+ const parsed = parseCreateRequest(offered);
+ if (!parsed.ok || parsed.value.runId !== runId) {
+ throw new CommandError("malformed-member");
+ }
+ creation = parsed.value;
+ }
+ // A start that creates carries its creation; a resume never does.
+ if (action === "resume" && creation !== null) {
+ throw new CommandError("malformed-member");
+ }
+ return {
+ id,
+ command,
+ runId,
+ action,
+ creation,
+ retrieval: retrieval(members.get("retrieval"), creation),
+ executionId: text(members, "executionId", MAX_RUN_ID),
+ };
+ }
+ if (command === "cancel") {
+ return { id, command, runId: text(members, "runId", MAX_RUN_ID) };
+ }
+ if (command === "fork-stage") {
+ const section = members.get("section");
+ if (
+ section !== "inherited" &&
+ section !== "roots" &&
+ section !== "manifests" &&
+ section !== "blobs" &&
+ section !== "checkouts"
+ ) {
+ throw new CommandError("malformed-member");
+ }
+ const part = members.get("part");
+ if (part === null || typeof part !== "object" || Array.isArray(part)) {
+ throw new CommandError("malformed-member");
+ }
+ return {
+ id,
+ command,
+ section,
+ position: whole(members.get("position")),
+ part: Object.fromEntries(Object.entries(part)),
+ };
+ }
+ if (command === "fork-continue") {
+ const runId = text(members, "runId", MAX_RUN_ID);
+ const creation = parseCreateRequest(members.get("creation"));
+ if (!creation.ok || creation.value.runId !== runId) {
+ throw new CommandError("malformed-member");
+ }
+ return {
+ id,
+ command,
+ runId,
+ creation: creation.value,
+ origin: continuationOrigin(members.get("origin")),
+ runRecord: forkEvent(members.get("runRecord")),
+ rootImport: forkEvent(members.get("rootImport")),
+ executionId: text(members, "executionId", MAX_RUN_ID),
+ };
+ }
+ if (command === "fork") {
+ const runId = text(members, "runId", MAX_RUN_ID);
+ const creation = parseCreateRequest(members.get("creation"));
+ if (!creation.ok || creation.value.runId !== runId) {
+ throw new CommandError("malformed-member");
+ }
+ return {
+ id,
+ command,
+ runId,
+ creation: creation.value,
+ retrieval: retrieval(members.get("retrieval"), creation.value),
+ origin: origin(members.get("origin")),
+ counts: counts(members.get("counts")),
+ runRecord: forkEvent(members.get("runRecord")),
+ rootImport: forkEvent(members.get("rootImport")),
+ executionId: text(members, "executionId", MAX_RUN_ID),
+ };
+ }
+ if (command === "executions") {
+ const anchor = sequence(members, "anchor");
+ const after = sequence(members, "after");
+ if (anchor === null && after !== null) {
+ // An empty snapshot has nothing to continue from.
+ throw new CommandError("malformed-member");
+ }
+ if (anchor !== null && after !== null && after >= anchor) {
+ throw new CommandError("malformed-member");
+ }
+ return { id, command, anchor, after };
+ }
+ if (command === "settle") {
+ const completion = parseDocumentExecutionCompletion(members.get("completion"));
+ if (!completion.ok) {
+ throw new CommandError("malformed-member");
+ }
+ return {
+ id,
+ command,
+ completion: completion.value,
+ expectedWorkspaceRootId: digest(members, "expectedWorkspaceRootId"),
+ };
+ }
+ return {
+ id,
+ command,
+ expectedWorkspaceRootId: digest(members, "expectedWorkspaceRootId"),
+ expectedJournalEventId: nullableText(members, "expectedJournalEventId"),
+ publication: publication(members.get("publication")),
+ mappings: mappings(members.get("mappings")),
+ events: eventRecords(members.get("events")),
+ answer: answerConsumption(members.get("answer")),
+ };
+}
+
+/**
+ * The retained answer a proposal spends, or its absence.
+ *
+ * `null` is every ordinary commit. Everything else names one wait, the exact
+ * journal event its request was published as, and the fingerprint the value was
+ * delivered against — and nothing else, because a value here would be a runner
+ * telling the owner what it retained.
+ */
+function answerConsumption(value: unknown): ProposedAnswerConsumption | null {
+ if (value === null) {
+ return null;
+ }
+ const members = object(value);
+ closed(members, ["suspensionId", "requestEventId", "requestFingerprint"]);
+ return {
+ suspensionId: text(members, "suspensionId", MAX_ID),
+ requestEventId: text(members, "requestEventId", MAX_ID),
+ requestFingerprint: digest(members, "requestFingerprint"),
+ };
+}
+
+/**
+ * The retrieval metadata a creation carries, canonically encoded.
+ *
+ * Only a creating request may carry one: a resume is not creating anything for
+ * it to belong to. The value is held to the same JSON rules every retained
+ * record is, and to the same bound one message is.
+ */
+function retrieval(value: unknown, creation: CreateWorkflowRunRequest | null): string | null {
+ if (value === null) {
+ return null;
+ }
+ if (creation === null) {
+ throw new CommandError("malformed-member");
+ }
+ const encoded = canonicalJson(
+ parseJsonValue(value, "$.retrieval", () => new CommandError("malformed-member")),
+ );
+ if (new TextEncoder().encode(encoded).length > MAX_MESSAGE_BYTES) {
+ throw new CommandError("too-large");
+ }
+ return encoded;
+}
+
+/** A whole count, as a member rather than a column. */
+function whole(value: unknown): number {
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
+ throw new CommandError("malformed-member");
+ }
+ return value;
+}
+
+/** Where a fork came from: one source, one checkpoint, three head roots. */
+function origin(value: unknown): ForkOrigin {
+ const found = object(value);
+ closed(found, [
+ "sourceRunId",
+ "checkpointEventId",
+ "checkpointWorkspaceRootId",
+ "runRecordWorkspaceRootId",
+ "rootImportWorkspaceRootId",
+ "anchor",
+ ]);
+ return {
+ sourceRunId: text(found, "sourceRunId", MAX_RUN_ID),
+ checkpointEventId: text(found, "checkpointEventId", MAX_ID),
+ checkpointWorkspaceRootId: digest(found, "checkpointWorkspaceRootId"),
+ runRecordWorkspaceRootId: digest(found, "runRecordWorkspaceRootId"),
+ rootImportWorkspaceRootId: digest(found, "rootImportWorkspaceRootId"),
+ anchor: digest(found, "anchor"),
+ };
+}
+
+/** Which fork a continuation claims to be continuing. */
+function continuationOrigin(value: unknown): ForkContinuationOrigin {
+ const found = object(value);
+ closed(found, ["sourceRunId", "checkpointEventId"]);
+ return {
+ sourceRunId: text(found, "sourceRunId", MAX_RUN_ID),
+ checkpointEventId: text(found, "checkpointEventId", MAX_ID),
+ };
+}
+
+/** How many parts each section should have arrived in. */
+function counts(value: unknown): ForkCounts {
+ const found = object(value);
+ closed(found, ["inherited", "roots", "manifests", "blobs", "checkouts"]);
+ return {
+ inherited: whole(found.get("inherited")),
+ roots: whole(found.get("roots")),
+ manifests: whole(found.get("manifests")),
+ blobs: whole(found.get("blobs")),
+ checkouts: whole(found.get("checkouts")),
+ };
+}
+
+/** One of the two records a fork writes for itself. */
+function forkEvent(value: unknown): DurableEvent {
+ if (typeof value !== "string" || value === "") {
+ throw new CommandError("malformed-member");
+ }
+ if (new TextEncoder().encode(value).length > MAX_MESSAGE_BYTES) {
+ throw new CommandError("too-large");
+ }
+ const parsed = parseDurableEvent(value);
+ if (!parsed.ok) {
+ throw new CommandError("malformed-member");
+ }
+ return parsed.value;
+}
+
+/**
+ * The Workspace half of a proposal, or its absence.
+ *
+ * `null` is a journal-only transaction and is admitted as such. Everything else
+ * must be a complete proposal: an identity, the canonical manifest that
+ * identity is supposed to be the digest of, and the exact inventory. Whether
+ * the identity really is that digest, and whether the inventory really is the
+ * closure, is the owner's to recompute — this only decides whether the request
+ * is shaped like a proposal at all.
+ */
+function publication(value: unknown): ProposedPublication | null {
+ if (value === null) {
+ return null;
+ }
+ const members = object(value);
+ closed(members, ["proposedWorkspaceRootId", "proposedManifest", "content"]);
+ const manifest = members.get("proposedManifest");
+ if (typeof manifest !== "string" || manifest === "") {
+ throw new CommandError("malformed-member");
+ }
+ if (new TextEncoder().encode(manifest).length > MAX_ROOT_MANIFEST_BYTES) {
+ throw new CommandError("too-large");
+ }
+ return {
+ proposedWorkspaceRootId: digest(members, "proposedWorkspaceRootId"),
+ proposedManifest: manifest,
+ content: pieces(members.get("content")),
+ };
+}
+
+/**
+ * The inventory, in the order it must arrive.
+ *
+ * Canonical order and no repeats, checked here rather than sorted into shape: a
+ * proposal that named one piece twice, or named them in an order this build did
+ * not produce, is not the proposal the runner computed its identity over.
+ */
+function pieces(value: unknown): ProposedPiece[] {
+ if (!Array.isArray(value)) {
+ throw new CommandError("malformed-member");
+ }
+ if (value.length > MAX_PROPOSED_PIECES) {
+ throw new CommandError("too-large");
+ }
+ const found: ProposedPiece[] = [];
+ let previous: string | undefined;
+ for (const entry of value) {
+ const members = object(entry);
+ closed(members, ["kind", "digest", "size"]);
+ const size = members.get("size");
+ if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0) {
+ throw new CommandError("malformed-member");
+ }
+ if (size > MAX_CONTENT_BYTES) {
+ throw new CommandError("too-large");
+ }
+ const piece: ProposedPiece = {
+ kind: kind(members),
+ digest: digest(members, "digest"),
+ size,
+ };
+ const ordering = `${piece.kind}:${piece.digest}`;
+ if (previous !== undefined && ordering <= previous) {
+ throw new CommandError("malformed-member");
+ }
+ previous = ordering;
+ found.push(piece);
+ }
+ return found;
+}
+
+/**
+ * The retained mappings a proposal carries, read through the shared parsers.
+ *
+ * The parsers are the ones the local host holds its own rows to. A private
+ * approximation here would be the two hosts disagreeing about what a retained
+ * Repository is, and the owner would be the one that found out.
+ */
+function mappings(value: unknown): ProposedMapping[] {
+ if (!Array.isArray(value)) {
+ throw new CommandError("malformed-member");
+ }
+ if (value.length > MAX_MAPPINGS) {
+ throw new CommandError("too-large");
+ }
+ return value.map((entry) => {
+ const members = object(entry);
+ const which = members.get("kind");
+ closed(members, which === "repository" ? ["kind", "record", "locator"] : ["kind", "record"]);
+ const offered = members.get("record");
+ if (which === "repository") {
+ const record = parseRepositoryRecord(offered);
+ const offeredLocator = members.get("locator");
+ if (record === undefined || typeof offeredLocator !== "string") {
+ throw new CommandError("malformed-member");
+ }
+ // Admitted first, by the same closed allowlist the local host uses. A
+ // matching fingerprint says the two values agree with each other; it says
+ // nothing about whether the locator is one this system will ever hand to
+ // Git, and an authenticated proposal must not be able to retain a
+ // credential-bearing URL or an executable transport form.
+ const locator = admitLocator(offeredLocator);
+ if (locator === undefined || locatorFingerprintOf(locator) !== record.locatorFingerprint) {
+ throw new CommandError("malformed-member");
+ }
+ return { kind: which, record, locator };
+ }
+ if (which === "worktree") {
+ const record = parseWorktreeRecord(offered);
+ if (record === undefined) {
+ throw new CommandError("malformed-member");
+ }
+ return { kind: which, record };
+ }
+ if (which === "agent-session") {
+ const record = parseAgentSessionRecord(offered);
+ if (record === undefined) {
+ throw new CommandError("malformed-member");
+ }
+ return { kind: which, record };
+ }
+ throw new CommandError("malformed-member");
+ });
+}
diff --git a/packages/workflow/src/cloudflare/configured.ts b/packages/workflow/src/cloudflare/configured.ts
new file mode 100644
index 000000000..636a7f5c1
--- /dev/null
+++ b/packages/workflow/src/cloudflare/configured.ts
@@ -0,0 +1,250 @@
+/**
+ * One configured client for one run's owner.
+ *
+ * This is the supported way a trusted runner reaches a deployment: an
+ * already-selected run id, one credential-free endpoint, the exact release this
+ * build talks to, and an operation that mints a short-lived token when a plane
+ * needs one. Nothing here reads a flag, an environment variable, a document
+ * prop or a global; a caller that has not been given these values cannot
+ * construct one, which is the point.
+ *
+ * It is bound to one run. Every plane requires the configured id before a token
+ * is minted, before a URL is built and before any I/O happens, so a client
+ * cannot be walked across a namespace and there is nothing here that could
+ * enumerate one.
+ *
+ * The three planes are three requests, not three protocols this publishes. What
+ * crosses on each of them — the paths, the header names, the private commands
+ * and the refusal spellings — stays inside this adapter, and the endpoint,
+ * release and token stay in its closure: no workflow record, journal event,
+ * public error or document-visible value carries any of them.
+ */
+
+import { Err, Ok, type Operation, type Result } from "effection";
+import type { RemoteExecutorConnection } from "../remote/lifecycle-link.ts";
+import type { OwnerSocket } from "../remote/client.ts";
+import type { RemoteDeliveryLink } from "../remote/answer-link.ts";
+import type { RemoteReadPlane } from "../remote/read.ts";
+import { WorkflowRequestError } from "../storage/errors.ts";
+import { cloudflareDeliveryLink } from "./delivery-client.ts";
+import { cloudflareReadPlane } from "./read-client.ts";
+import { useExecutorConnection } from "./executor-connection.ts";
+import { parseOwnerEndpoint, type OwnerEndpoint } from "./endpoint.ts";
+import { admitRunId } from "./routing.ts";
+import { storageFailure } from "./client.ts";
+import { RELEASE_HEADER, upgradeProtocols } from "./routes.ts";
+
+/** One ordinary request to an owner, as a host performs it. */
+export interface OwnerHttpRequest {
+ readonly url: string;
+ readonly headers: Readonly>;
+ readonly body: string;
+}
+
+/** What an owner answered an ordinary request with. */
+export interface OwnerHttpResponse {
+ readonly status: number;
+ readonly body: string;
+}
+
+/** One upgrade request, as a host performs it. */
+export interface OwnerUpgrade {
+ readonly url: string;
+ /** The subprotocols to offer, in the order this build offers them. */
+ readonly protocols: readonly string[];
+}
+
+/** An upgrade the owner refused, with the category it refused under. */
+export interface OwnerUpgradeRefused {
+ readonly refusal: string;
+}
+
+/**
+ * The I/O a host performs on this client's behalf.
+ *
+ * Explicit because performing it is the one thing a runtime has to supply and
+ * this package will not reach for: `fetch` and `WebSocket` are the runner's,
+ * named where the runner is assembled. Nothing here decides anything about a
+ * run — a transport that answered on its own would be an owner.
+ */
+export interface OwnerTransport {
+ /** Perform one request and answer with what came back. */
+ request(request: OwnerHttpRequest): Operation;
+ /**
+ * Open one socket, or answer with the category the owner refused under.
+ *
+ * The socket must be open when this returns: the first command goes out
+ * immediately, and a client that sent into a connecting socket would lose it.
+ * The returned socket belongs to the calling scope.
+ */
+ connect(upgrade: OwnerUpgrade): Operation;
+}
+
+/** What a trusted host supplies to reach one run's owner. */
+export interface RemoteOwnerConfiguration {
+ /** The run this client is bound to. Selected by the caller, never derived. */
+ readonly runId: string;
+ /** Where this deployment's owners are. Credential-free, and parsed once. */
+ readonly endpoint: string;
+ /** The exact immutable release identity both sides must agree on. */
+ readonly release: string;
+ /** A fresh short-lived token for the immediate request, minted per request. */
+ token(): Operation;
+ /** The HTTP and WebSocket I/O this client performs through. */
+ readonly transport: OwnerTransport;
+}
+
+/** One run's owner, reached over its three planes. */
+export interface RemoteOwnerClient {
+ /** The run this client is bound to, as it was configured. */
+ readonly runId: string;
+ /**
+ * Admit one executor connection for this run, owned by the calling scope.
+ *
+ * `already-running` is the owner's answer that another live executor holds
+ * the run — a fact about the run rather than a failure of this call.
+ */
+ admit(runId: string): Operation>;
+ /** The no-acquisition read plane for this run. */
+ reads(runId: string): Operation>;
+ /** The no-acquisition delivery plane for this run. */
+ readonly delivery: RemoteDeliveryLink;
+}
+
+/** The one upgrade refusal that is a fact about the run rather than about the connection. */
+const ALREADY_RUNNING = "acquisition:already-running";
+
+/** What a caller is told when it addresses a run this client is not bound to. */
+function foreign(runId: string, bound: string): Error {
+ // Neither id is quoted. What went wrong is that a client bound to one run was
+ // asked about another, and a diagnostic naming them would put a caller's own
+ // addressing mistake into a message this run may keep.
+ return new WorkflowRequestError(
+ runId === bound
+ ? "this workflow run id cannot address a workflow owner."
+ : "this client is bound to one workflow run and was asked about another.",
+ );
+}
+
+/**
+ * Build one client for one run.
+ *
+ * The endpoint is parsed here, so an operator learns about a credential, a
+ * fragment, a query or a scheme this build does not speak before a token is
+ * minted or anything is opened.
+ */
+export function remoteOwnerClient(configuration: RemoteOwnerConfiguration): RemoteOwnerClient {
+ const bound = admitRunId(configuration.runId);
+ const endpoint: OwnerEndpoint = parseOwnerEndpoint(configuration.endpoint);
+ const { release, transport } = configuration;
+
+ /** Hold every plane to the one run this client was configured for. */
+ function admitted(runId: string): boolean {
+ return runId === bound;
+ }
+
+ /**
+ * Carry one plane's request, with the admission that plane already built.
+ *
+ * The token comes from the caller rather than from here: a plane mints one
+ * for the request it is about to make, and minting a second would be two
+ * credentials for one question.
+ */
+ function* post(
+ admission: { readonly release: string; readonly token: string; readonly runId: string },
+ plane: "read" | "delivery",
+ body: string,
+ ): Operation {
+ // The bound run decides before anything is built or sent.
+ if (!admitted(admission.runId)) {
+ throw foreign(admission.runId, bound);
+ }
+ const answered = yield* transport.request({
+ url: endpoint.planeUrl(admission.runId, plane),
+ headers: {
+ [RELEASE_HEADER]: admission.release,
+ authorization: `Bearer ${admission.token}`,
+ "content-type": "application/json",
+ },
+ body,
+ });
+ if (answered.status !== 200) {
+ // The owner answers both request planes with an envelope, so any other
+ // status is the request never having reached one. Nothing about the
+ // response travels: a status is not a refusal category.
+ throw storageFailure("command:unavailable");
+ }
+ return answered.body;
+ }
+
+ return {
+ runId: bound,
+
+ *admit(runId: string): Operation> {
+ if (!admitted(runId)) {
+ return Err(foreign(runId, bound));
+ }
+ return yield* useExecutorConnection(
+ {
+ *open(forRun: string): Operation> {
+ if (!admitted(forRun)) {
+ return Err(foreign(forRun, bound));
+ }
+ const token = yield* configuration.token();
+ const opened = yield* transport.connect({
+ url: endpoint.planeUrl(forRun, "executor"),
+ protocols: upgradeProtocols(release, token),
+ });
+ if (!("refusal" in opened)) {
+ return Ok(opened);
+ }
+ // The owner refused the connection. One category is a fact about
+ // the run and is reported as one; every other refusal an admission
+ // can produce — a release, a token, a run id — is this connection
+ // not being admitted, and the word for it stays here rather than
+ // becoming a public compatibility surface.
+ return opened.refusal === ALREADY_RUNNING
+ ? Ok("already-running")
+ : Err(storageFailure("command:unavailable"));
+ },
+ ids: () => {
+ let command = 0;
+ return () => `command-${(command += 1)}`;
+ },
+ },
+ runId,
+ );
+ },
+
+ // deno-lint-ignore require-yield
+ *reads(runId: string): Operation> {
+ if (!admitted(runId)) {
+ return Err(foreign(runId, bound));
+ }
+ return Ok(
+ cloudflareReadPlane(
+ { send: (admission, body) => post(admission, "read", body) },
+ release,
+ configuration.token,
+ runId,
+ ),
+ );
+ },
+
+ delivery: cloudflareDeliveryLink(
+ { send: (admission, body) => post(admission, "delivery", body) },
+ {
+ release,
+ // The delivery plane is told which run each request is for, so the
+ // binding is checked here — before a token exists for a run this
+ // client was never configured to answer for.
+ *token(runId: string): Operation {
+ if (!admitted(runId)) {
+ throw foreign(runId, bound);
+ }
+ return yield* configuration.token();
+ },
+ },
+ ),
+ };
+}
diff --git a/packages/workflow/src/cloudflare/delivery-client.ts b/packages/workflow/src/cloudflare/delivery-client.ts
new file mode 100644
index 000000000..7af96f430
--- /dev/null
+++ b/packages/workflow/src/cloudflare/delivery-client.ts
@@ -0,0 +1,214 @@
+/**
+ * Answering a run on a Cloudflare owner, from wherever the value came from.
+ *
+ * The transport is narrow on purpose: one request out, one response back, and
+ * it knows nothing about runs, waits or authority. A host wires it to an
+ * ordinary HTTPS request; a test wires it to the object directly. Delivery
+ * never opens a socket, so there is no connection here to hold and nothing that
+ * could be mistaken for an acquisition.
+ *
+ * Every answer is parsed before it is believed. A wait this build cannot read
+ * back the way it was described is not a wait to judge a value against, and a
+ * retention naming a different run or wait is an owner disagreeing with the
+ * question rather than an accepted delivery.
+ */
+
+import { Err, Ok, type Operation, type Result } from "effection";
+import type { Json } from "@executablemd/durable-streams";
+import type {
+ RemoteAnswerRetained,
+ RemoteAnswerRetention,
+ RemoteDeliveryLink,
+ RemoteRetainedWaitRecord,
+} from "../remote/answer-link.ts";
+import { RemoteRecordError } from "../remote/records.ts";
+import { parseJsonValue, parseMembers, requireMemberNames } from "../storage/members.ts";
+import { WorkflowRunNotFoundError } from "../storage/errors.ts";
+import { canonicalJson } from "../storage/record.ts";
+import { privateRefusal, storageFailure } from "./client.ts";
+import { DELIVERY_REQUEST_BYTES } from "./delivery-plane.ts";
+
+/**
+ * One request out, one response back.
+ *
+ * The admission travels beside the body rather than inside it, because the
+ * owner decides on the release before it decodes anything.
+ */
+export interface DeliveryTransport {
+ send(admission: DeliveryAdmission, body: string): Operation;
+}
+
+/** What a request carries outside its body. */
+export interface DeliveryAdmission {
+ readonly release: string;
+ readonly token: string;
+ readonly runId: string;
+}
+
+/** How a host supplies one delivery's admission. */
+export interface DeliveryAdmissionSource {
+ /** The build this deployment agreed to talk to. */
+ readonly release: string;
+ /** A short-lived token, minted per delivery rather than retained. */
+ token(runId: string): Operation;
+}
+
+/**
+ * The most serialized bytes one delivery answer may carry.
+ *
+ * A `wait` answer carries a retained request and its response schema, which are
+ * journal values, so what bounds it is what bounds a request carrying one.
+ */
+const ANSWER_BYTES = DELIVERY_REQUEST_BYTES + 4096;
+
+function fail(reason: string): never {
+ throw new RemoteRecordError(`the owner returned a malformed delivery answer: ${reason}`);
+}
+
+/** Reach one Cloudflare owner's delivery plane. */
+export function cloudflareDeliveryLink(
+ transport: DeliveryTransport,
+ admission: DeliveryAdmissionSource,
+): RemoteDeliveryLink {
+ function* ask(runId: string, body: Record): Operation> {
+ const encoded = JSON.stringify(body);
+ if (new TextEncoder().encode(encoded).length > DELIVERY_REQUEST_BYTES) {
+ return Err(storageFailure("command:too-large"));
+ }
+ let raw: string;
+ try {
+ const token = yield* admission.token(runId);
+ raw = yield* transport.send({ release: admission.release, token, runId }, encoded);
+ } catch {
+ // Whatever the transport raised, the owner was not reached and nothing
+ // was decided. What went wrong underneath is the host's to log; a public
+ // error carrying it would carry an endpoint or a token with it.
+ return Err(storageFailure("command:unavailable"));
+ }
+ if (new TextEncoder().encode(raw).length > ANSWER_BYTES) {
+ return Err(storageFailure("command:too-large"));
+ }
+ let decoded: unknown;
+ try {
+ decoded = JSON.parse(raw);
+ } catch {
+ return Err(storageFailure("command:malformed-member"));
+ }
+ const answered = parseMembers(decoded, "$", (reason) => new RemoteRecordError(reason));
+ const outcome = answered.get("outcome");
+ if (outcome === "refused") {
+ const refusal = answered.get("refusal");
+ if (typeof refusal !== "string") {
+ return Err(storageFailure("command:malformed-member"));
+ }
+ const named = privateRefusal(refusal);
+ // The one category that is a fact about the run rather than a failure.
+ if (named === "command:absent") {
+ return Err(new WorkflowRunNotFoundError(runId));
+ }
+ return Err(storageFailure(named));
+ }
+ if (outcome !== "performed") {
+ return Err(storageFailure("command:malformed-member"));
+ }
+ return Ok(answered.get("value"));
+ }
+
+ return {
+ *wait(runId: string, suspensionId: string): Operation> {
+ const answered = yield* ask(runId, { operation: "wait", suspensionId });
+ if (!answered.ok) {
+ return answered;
+ }
+ try {
+ return Ok(parseWait(answered.value));
+ } catch (error) {
+ return Err(
+ error instanceof RemoteRecordError ? error : storageFailure("command:malformed-member"),
+ );
+ }
+ },
+
+ *retain(retention: RemoteAnswerRetention): Operation> {
+ const answered = yield* ask(retention.runId, {
+ operation: "deliver",
+ suspensionId: retention.suspensionId,
+ // Canonically encoded here, once, so the bytes the owner retains are
+ // the bytes a later commit is compared against. Everything the owner
+ // decides about this value, it decides from the value.
+ answer: canonicalJson(retention.answer),
+ secretDetection: retention.secretDetection,
+ });
+ if (!answered.ok) {
+ return answered;
+ }
+ try {
+ const retained = parseRetained(answered.value);
+ if (
+ retained.runId !== retention.runId ||
+ retained.suspensionId !== retention.suspensionId
+ ) {
+ fail("a retention named a different run or wait");
+ }
+ return Ok(retained);
+ } catch (error) {
+ return Err(
+ error instanceof RemoteRecordError ? error : storageFailure("command:malformed-member"),
+ );
+ }
+ },
+ };
+}
+
+function parseWait(value: unknown): RemoteRetainedWaitRecord {
+ const found = parseMembers(value, "$", (reason) => new RemoteRecordError(reason));
+ requireMemberNames(
+ found,
+ ["runId", "suspensionId", "requestEventId", "request", "responseSchema", "requestFingerprint"],
+ "$",
+ (reason) => new RemoteRecordError(reason),
+ );
+ return Object.freeze({
+ runId: text(found.get("runId"), "a wait named no run"),
+ suspensionId: text(found.get("suspensionId"), "a wait named no suspension"),
+ requestEventId: text(found.get("requestEventId"), "a wait named no request event"),
+ request: json(found.get("request"), "a wait carried a request this build cannot read"),
+ responseSchema: json(
+ found.get("responseSchema"),
+ "a wait carried a response schema this build cannot read",
+ ),
+ requestFingerprint: digest(found.get("requestFingerprint")),
+ });
+}
+
+function parseRetained(value: unknown): RemoteAnswerRetained {
+ const found = parseMembers(value, "$", (reason) => new RemoteRecordError(reason));
+ requireMemberNames(
+ found,
+ ["runId", "suspensionId"],
+ "$",
+ (reason) => new RemoteRecordError(reason),
+ );
+ return Object.freeze({
+ runId: text(found.get("runId"), "a retention named no run"),
+ suspensionId: text(found.get("suspensionId"), "a retention named no wait"),
+ });
+}
+
+function text(value: unknown, reason: string): string {
+ if (typeof value !== "string" || value === "") {
+ return fail(reason);
+ }
+ return value;
+}
+
+function digest(value: unknown): string {
+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) {
+ return fail("a wait named no request fingerprint");
+ }
+ return value;
+}
+
+function json(value: unknown, reason: string): Json {
+ return parseJsonValue(value, "$", () => new RemoteRecordError(reason));
+}
diff --git a/packages/workflow/src/cloudflare/delivery-plane.ts b/packages/workflow/src/cloudflare/delivery-plane.ts
new file mode 100644
index 000000000..81f59a84b
--- /dev/null
+++ b/packages/workflow/src/cloudflare/delivery-plane.ts
@@ -0,0 +1,233 @@
+/**
+ * Answering a run's owner without taking the run.
+ *
+ * A third plane, and it is a third plane for the same reason the read plane is
+ * a second one: what it does cannot be done over the executor socket. A run
+ * that is waiting has no executor, and it must be answerable while another
+ * executor is live — so this accepts no socket, mints no acquisition, and
+ * cannot move a lifecycle. What separates it from the read plane is that it
+ * writes exactly one row, in one transaction, and nothing else.
+ *
+ * It writes no journal event, no execution row, no status, no root, no mapping
+ * and no acquisition state. What a delivery leaves behind is a pending answer
+ * correlated to the wait it answers, which the next acquired execution to reach
+ * that wait spends.
+ *
+ * What crosses is closed and private to this release, and there is exactly one
+ * operation that writes. It carries a value and a gate decision and nothing
+ * else: no request identity, no fingerprint, no claim that anything was
+ * checked. The owner resolves the wait itself, judges the value against the
+ * schema that wait retained, applies the gate the request selected, and only
+ * then writes — inside one transaction, having read every one of those facts
+ * again. There is no lower operation to select instead.
+ */
+
+import { parseMembers, requireMemberNames } from "../storage/members.ts";
+import { WorkflowRecordMalformedError } from "../storage/errors.ts";
+import { canonicalJson } from "../storage/record.ts";
+import { CommandError } from "./commands.ts";
+import { READ_PAGE_BYTES, READ_REQUEST_ENVELOPE } from "./read-plane.ts";
+import { answerFramings, fingerprintOf, readRetainedWait, retainAnswer } from "./owner-answers.ts";
+import type { OwnerStorage } from "./storage.ts";
+
+/** What a delivery answered, or why it would not. */
+export type DeliveryAnswer =
+ | { readonly outcome: "performed"; readonly value: unknown }
+ | { readonly outcome: "refused"; readonly refusal: string };
+
+/** The most characters one wait's identifier may carry. */
+const MAX_SUSPENSION_ID = 256;
+
+/**
+ * The most serialized bytes one delivery request may carry.
+ *
+ * Derived rather than picked. A retained answer is a value this owner will hand
+ * back through the read plane once it is an event, so what bounds it is what
+ * one page of that plane may carry; the envelope around it — a wait, an event
+ * id, a fingerprint and the punctuation between them — is the same fixed
+ * envelope a read request carries.
+ */
+export const DELIVERY_REQUEST_BYTES = READ_PAGE_BYTES + READ_REQUEST_ENVELOPE;
+
+/** What a caller may ask this plane for. */
+export type DeliveryOperation =
+ | { readonly operation: "wait"; readonly suspensionId: string }
+ | {
+ readonly operation: "deliver";
+ readonly suspensionId: string;
+ /** The canonical encoding of the value being offered. */
+ readonly answer: string;
+ /**
+ * Whether this value crosses the credential gate before it is retained.
+ *
+ * Required, with no default, because the choice is the caller's and
+ * omitting it must not be a way of making it. `false` is the documented
+ * opt-out and is the only way past the gate.
+ */
+ readonly secretDetection: boolean;
+ };
+
+function failure(reason: string, path: string): Error {
+ return new WorkflowRecordMalformedError("workflow delivery request", `${reason} at ${path}`);
+}
+
+/**
+ * The whole request, parsed as a closed shape before any member is read.
+ *
+ * Nothing here reaches storage. A request that is not one of the two shapes
+ * this plane implements is refused as malformed, before a run is recognized,
+ * before a wait is read, and before anything could be written.
+ */
+export function parseDeliveryOperation(raw: string): DeliveryOperation {
+ if (new TextEncoder().encode(raw).length > DELIVERY_REQUEST_BYTES) {
+ throw failure("expected a bounded request", "$");
+ }
+ let decoded: unknown;
+ try {
+ decoded = JSON.parse(raw);
+ } catch {
+ throw failure("expected one JSON object", "$");
+ }
+ const read = parseMembers(decoded, "$", failure);
+ const operation = read.get("operation");
+
+ if (operation === "wait") {
+ requireMemberNames(read, ["operation", "suspensionId"], "$", failure);
+ return { operation, suspensionId: identifier(read.get("suspensionId"), "$.suspensionId") };
+ }
+ if (operation === "deliver") {
+ requireMemberNames(
+ read,
+ ["operation", "suspensionId", "answer", "secretDetection"],
+ "$",
+ failure,
+ );
+ const secretDetection = read.get("secretDetection");
+ if (typeof secretDetection !== "boolean") {
+ throw failure("expected a secret-gate decision", "$.secretDetection");
+ }
+ const answer = read.get("answer");
+ if (typeof answer !== "string" || answer === "") {
+ throw failure("expected the canonical encoding of one value", "$.answer");
+ }
+ // Held to the same rules the journal holds a value to, and to the exact
+ // canonical spelling: two encodings of one value would be two answers, and
+ // the row is compared as text when a lost response is delivered again.
+ let value: unknown;
+ try {
+ value = JSON.parse(answer);
+ } catch {
+ throw failure("expected the canonical encoding of one value", "$.answer");
+ }
+ if (canonicalJson(readValue(value)) !== answer) {
+ throw failure("expected the canonical encoding of one value", "$.answer");
+ }
+ return {
+ operation,
+ suspensionId: identifier(read.get("suspensionId"), "$.suspensionId"),
+ answer,
+ secretDetection,
+ };
+ }
+ throw failure("expected an operation this owner implements", "$.operation");
+}
+
+/** What one wait retains, and the framings a value offered to it would take. */
+export interface DeliverySubject {
+ readonly requestFingerprint: string;
+ /** The retained row and the durable event, as the gate will read them. */
+ readonly framings: readonly string[];
+}
+
+/** Answer one `wait` read, which writes nothing. */
+export function answerRetainedWait(
+ storage: OwnerStorage,
+ runId: string,
+ suspensionId: string,
+): Record {
+ const waiting = readRetainedWait(storage, runId, suspensionId);
+ return {
+ runId: waiting.runId,
+ suspensionId: waiting.suspensionId,
+ requestEventId: waiting.requestEventId,
+ request: waiting.request,
+ responseSchema: waiting.responseSchema,
+ requestFingerprint: fingerprintOf(waiting),
+ };
+}
+
+/**
+ * What the credential gate reads, before the transaction that writes.
+ *
+ * The gate is asynchronous and a Durable Object transaction cannot wait, so it
+ * runs here — over the framings this exact value would be stored in, built from
+ * the wait as it stands now. The identity those framings were built under
+ * travels into the transaction, which requires it to still be the one retained
+ * before it writes anything.
+ */
+export function deliverySubject(
+ storage: OwnerStorage,
+ runId: string,
+ request: { readonly suspensionId: string; readonly answer: string },
+): DeliverySubject {
+ const waiting = readRetainedWait(storage, runId, request.suspensionId);
+ const fingerprint = fingerprintOf(waiting);
+ return {
+ requestFingerprint: fingerprint,
+ framings: answerFramings(waiting, fingerprint, request.answer),
+ };
+}
+
+/** Retain one delivered answer, inside the caller's own owner transaction. */
+export function retainDeliveredAnswer(
+ storage: OwnerStorage,
+ runId: string,
+ request: { readonly suspensionId: string; readonly answer: string },
+ gatedFingerprint: string,
+ now: string,
+): Record {
+ return retainAnswer(
+ storage,
+ runId,
+ {
+ suspensionId: request.suspensionId,
+ answer: request.answer,
+ gatedFingerprint,
+ },
+ now,
+ );
+}
+
+function identifier(value: unknown, path: string): string {
+ if (typeof value !== "string" || value === "") {
+ throw failure("expected a non-empty identifier", path);
+ }
+ if (value.length > MAX_SUSPENSION_ID) {
+ throw failure("expected a bounded identifier", path);
+ }
+ return value;
+}
+
+/** One offered value, held to the JSON rules a retained value is held to. */
+function readValue(value: unknown): Parameters[0] {
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
+ return value;
+ }
+ if (typeof value === "number") {
+ if (!Number.isFinite(value)) {
+ throw new CommandError("malformed-member");
+ }
+ return value;
+ }
+ if (Array.isArray(value)) {
+ return value.map((entry) => readValue(entry));
+ }
+ if (typeof value === "object") {
+ const held: Record[0]> = {};
+ for (const [name, member] of Object.entries(value)) {
+ held[name] = readValue(member);
+ }
+ return held;
+ }
+ throw new CommandError("malformed-member");
+}
diff --git a/packages/workflow/src/cloudflare/dispatcher.ts b/packages/workflow/src/cloudflare/dispatcher.ts
new file mode 100644
index 000000000..3d8d90d14
--- /dev/null
+++ b/packages/workflow/src/cloudflare/dispatcher.ts
@@ -0,0 +1,665 @@
+/**
+ * Deciding one command, once.
+ *
+ * A runner that does not hear an answer cannot tell a lost question from a lost
+ * answer, so it asks again. That is only safe if asking twice is the same as
+ * asking once — which is what this arranges. Each command ID is decided once
+ * within one acquisition, and the decision is retained beside the acquisition
+ * that made it.
+ *
+ * Two requests are the same request when their *parsed* commands are equal.
+ * Member order and equivalent encodings are not differences; a different value
+ * is. Reusing an ID for a different request is not a retry, and it is refused
+ * rather than answered, because answering it would mean one identifier named
+ * two decisions.
+ *
+ * What is retained is the decision, not always the response. A read whose
+ * answer is fixed by immutable state and a snapshot anchor the request already
+ * carries is remembered as a decision to read again, and re-reading returns the
+ * same bytes because the request names what to read. The frontier is the
+ * exception and is kept whole: it is the one read whose answer would otherwise
+ * move, and a retry that returned a later frontier would hand a runner a
+ * snapshot it never asked for.
+ *
+ * The ledger is bounded and never evicts. Dropping an older ID would make a
+ * retry of it look like a new command, which for a mutation is the difference
+ * between doing something once and doing it twice — so a full ledger refuses
+ * the new command and fails the connection closed instead.
+ *
+ * Everything happens inside one short synchronous transaction, and the exact
+ * live acquisition is proved twice: before parsing, and again inside the
+ * transaction, because a socket can close between the two and the transaction
+ * is where the object actually changes.
+ */
+
+import type { AcquisitionContext } from "./acquisition.ts";
+import { requireAcquisition } from "./acquisition.ts";
+import {
+ type CommandResult,
+ CommandError,
+ MAX_COMMANDS,
+ MAX_CONTENT_BYTES,
+ MAX_LEDGER_BYTES,
+ MAX_STAGED_BYTES,
+ type RunnerCommand,
+} from "./commands.ts";
+import { bytesOf, decodeBase64, sha256Hex } from "./encoding.ts";
+import { readClaimableAnswer } from "./owner-answers.ts";
+import {
+ readContent,
+ readExecutions,
+ readInvocationSnapshot,
+ readFrontier,
+ readJournalPage,
+ readRoot,
+} from "./owner-reads.ts";
+import type { OwnerTransaction, OwnerTransactions } from "./owner-transaction.ts";
+import {
+ adoptExecution,
+ recordedExecution,
+ COMMAND_TABLE,
+ initializePrivateSchema,
+ MUTATION_TABLE,
+ STAGING_TABLE,
+} from "./private-schema.ts";
+import { applyCommit, applyRetrieval } from "./publish.ts";
+import { holdsNoRun, recognizeObject } from "./recognition.ts";
+import { openRun } from "./owner-open.ts";
+import { beginRun, cancelRunOnOwner, settleRun } from "./owner-lifecycle.ts";
+import { commitFork, continueFork, discardForkParts, stageForkPart } from "./owner-fork.ts";
+
+function requestFingerprint(command: RunnerCommand): string {
+ // The command name is part of the fingerprint, so one textual id used for a
+ // commit and for a retrieval replacement is two different requests rather
+ // than one recognized retry.
+ return sha256Hex(JSON.stringify({ kind: command.command, command }));
+}
+
+/**
+ * Whether this command changes the run, and therefore whether its decision has
+ * to outlive the connection that asked for it.
+ *
+ * A read can be asked again; a mutation cannot, so its answer is retained where
+ * the next connection can find it.
+ */
+/**
+ * Whether this command may find nothing and make the run anyway.
+ *
+ * A starting `begin` carries the run's whole immutable identity, and a
+ * committed fork carries the destination's. Both create the schema, the run and
+ * their first execution in one transaction, so both have to be allowed to
+ * arrive at a store that holds nothing.
+ */
+function initializes(command: RunnerCommand): boolean {
+ return (
+ command.command === "fork" ||
+ (command.command === "begin" && command.action === "start" && command.creation !== null)
+ );
+}
+
+/**
+ * Whether this command only offers scratch.
+ *
+ * A fork's parts and its content are offered to a destination that does not
+ * exist yet — that is the whole point of offering them — so these have to reach
+ * a store holding no run. They write nothing a reader can see: the scratch
+ * tables are this adapter's own, and the command that adopts them is what makes
+ * a run.
+ */
+function offersScratch(command: RunnerCommand): boolean {
+ return command.command === "stage" || command.command === "fork-stage";
+}
+
+function mutating(command: RunnerCommand): boolean {
+ return (
+ command.command === "commit" ||
+ command.command === "retrieval" ||
+ // Each of these changes the run's own lifecycle, and each can commit
+ // before its answer is observed. A retry has to find the first decision
+ // rather than apply the transition again.
+ command.command === "begin" ||
+ command.command === "cancel" ||
+ command.command === "settle" ||
+ // A committed fork creates a destination run. Its decision has to outlive
+ // the connection for the same reason a begin's does.
+ command.command === "fork" ||
+ command.command === "fork-continue"
+ );
+}
+
+function integer(value: unknown): number {
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
+ throw new Error("private protocol storage holds a malformed count");
+ }
+ return value;
+}
+
+/**
+ * Which execution one performed mutation began, if it began one.
+ *
+ * Read from the command rather than from the answer: what the runner asked to
+ * begin is what the owner began, and a decision that refused began nothing.
+ */
+function begunExecution(command: RunnerCommand, result: CommandResult): string | null {
+ if (result.outcome !== "performed") {
+ return null;
+ }
+ if (
+ command.command !== "begin" &&
+ command.command !== "fork" &&
+ command.command !== "fork-continue"
+ ) {
+ return null;
+ }
+ // Performed is not the same as begun: a conflict and a lifecycle refusal are
+ // both answers this command performed, and neither began anything. What
+ // decides is whether the answer carries a value.
+ const value = result.value;
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ return null;
+ }
+ return Reflect.get(value, "value") === null ? null : command.executionId;
+}
+
+/** What this acquisition has already spent of its own ledger. */
+function ledgerUsage(
+ storage: AcquisitionContext["storage"],
+ acquisitionId: string,
+): { commands: number; bytes: number } {
+ const row = storage.sql
+ .exec(
+ `SELECT count(*) AS commands, coalesce(sum(response_bytes), 0) AS bytes
+ FROM ${COMMAND_TABLE} WHERE acquisition_id = ?`,
+ acquisitionId,
+ )
+ .toArray()[0];
+ return { commands: integer(row?.["commands"]), bytes: integer(row?.["bytes"]) };
+}
+
+function storedDecision(value: unknown, id: string): CommandResult | "reconstruct" {
+ if (typeof value !== "string") {
+ throw new Error("private protocol storage holds a malformed result");
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(value);
+ } catch {
+ throw new Error("private protocol storage holds a malformed result");
+ }
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
+ throw new Error("private protocol storage holds a malformed result");
+ }
+ const members = new Map(Object.entries(parsed));
+ if (members.get("id") !== id) {
+ throw new Error("private protocol storage holds a result for another command");
+ }
+ const outcome = members.get("outcome");
+ if (outcome === "reconstruct" && members.size === 2) {
+ return "reconstruct";
+ }
+ if (outcome === "performed" && members.size === 3 && members.has("value")) {
+ return { id, outcome, value: members.get("value") };
+ }
+ const refusal = members.get("refusal");
+ if (outcome === "refused" && members.size === 3 && typeof refusal === "string") {
+ return { id, outcome, refusal };
+ }
+ throw new Error("private protocol storage holds a malformed result");
+}
+
+/**
+ * A fresh opaque identity for one retained event.
+ *
+ * Minted by the owner inside the transaction that writes the row. An id the
+ * runner chose would be a runner deciding what a retained event is called, and
+ * two runners could choose the same one.
+ */
+function mintEventId(): string {
+ return crypto.randomUUID();
+}
+
+/**
+ * The moment the owner records against a mutation it just made.
+ *
+ * The owner's clock, not the runner's. A time a runner supplied would be a
+ * caller deciding when the run's history happened.
+ */
+function ownerTime(): string {
+ return new Date().toISOString();
+}
+
+function retainedDecision(command: RunnerCommand, result: CommandResult): string {
+ if (
+ result.outcome === "performed" &&
+ (command.command === "journal" ||
+ command.command === "root" ||
+ command.command === "content" ||
+ command.command === "executions" ||
+ command.command === "mappings")
+ ) {
+ return JSON.stringify({ id: command.id, outcome: "reconstruct" });
+ }
+ return JSON.stringify(result);
+}
+
+function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
+ if (left.length !== right.length) {
+ return false;
+ }
+ let difference = 0;
+ for (let index = 0; index < left.length; index += 1) {
+ difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
+ }
+ return difference === 0;
+}
+
+function stage(
+ ctx: AcquisitionContext,
+ acquisitionId: string,
+ command: Extract,
+): { kind: "manifest" | "blob"; digest: string; size: number } {
+ const bytes = decodeBase64(command.bytes);
+ if (bytes.length === 0 || bytes.length > MAX_CONTENT_BYTES) {
+ throw new CommandError(bytes.length === 0 ? "malformed-member" : "too-large");
+ }
+ if (sha256Hex(bytes) !== command.digest) {
+ throw new CommandError("malformed-member");
+ }
+ const existing = ctx.storage.sql
+ .exec(
+ `SELECT size, bytes FROM ${STAGING_TABLE}
+ WHERE acquisition_id = ? AND kind = ? AND digest = ?`,
+ acquisitionId,
+ command.kind,
+ command.digest,
+ )
+ .toArray()[0];
+ if (existing !== undefined) {
+ const retained = bytesOf(existing["bytes"]);
+ if (!sameBytes(retained, bytes)) {
+ throw new Error("private staging disagrees with its content identity");
+ }
+ return { kind: command.kind, digest: command.digest, size: bytes.length };
+ }
+ const total = ctx.storage.sql
+ .exec(
+ `SELECT coalesce(sum(size), 0) AS total FROM ${STAGING_TABLE} WHERE acquisition_id = ?`,
+ acquisitionId,
+ )
+ .toArray()[0];
+ if (integer(total?.["total"]) + bytes.length > MAX_STAGED_BYTES) {
+ throw new CommandError("capacity");
+ }
+ ctx.storage.sql.exec(
+ `INSERT INTO ${STAGING_TABLE} (acquisition_id, kind, digest, size, bytes)
+ VALUES (?, ?, ?, ?, ?)`,
+ acquisitionId,
+ command.kind,
+ command.digest,
+ bytes.length,
+ new Uint8Array(bytes),
+ );
+ return { kind: command.kind, digest: command.digest, size: bytes.length };
+}
+
+function perform(
+ ctx: AcquisitionContext,
+ runId: string,
+ acquisitionId: string,
+ command: RunnerCommand,
+ transaction: OwnerTransaction,
+): CommandResult {
+ if (command.command === "frontier") {
+ return { id: command.id, outcome: "performed", value: readFrontier(ctx.storage, runId) };
+ }
+ if (command.command === "journal") {
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: readJournalPage(ctx.storage, command.anchorEventId, command.afterEventId),
+ };
+ }
+ if (command.command === "root") {
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: readRoot(ctx.storage, command.workspaceRootId),
+ };
+ }
+ if (command.command === "content") {
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: readContent(
+ ctx.storage,
+ command.workspaceRootId,
+ command.kind,
+ command.digest,
+ command.sourceManifest,
+ ),
+ };
+ }
+ if (command.command === "stage") {
+ return { id: command.id, outcome: "performed", value: stage(ctx, acquisitionId, command) };
+ }
+ if (command.command === "commit") {
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: applyCommit(ctx.storage, acquisitionId, command, mintEventId, ownerTime()),
+ };
+ }
+ if (command.command === "answer") {
+ // Read on the authority that ends a wait, not on the authority that opened
+ // a socket: this acquisition's own open execution, and the wait this run is
+ // actually standing at.
+ const retained = readClaimableAnswer(ctx.storage, runId, acquisitionId, {
+ suspensionId: command.suspensionId,
+ requestEventId: command.requestEventId,
+ });
+ return {
+ id: command.id,
+ outcome: "performed",
+ // The value travels as the canonical text this owner retained, so a
+ // runner comparing what it publishes with what was delivered compares the
+ // same bytes this owner will.
+ value:
+ retained === undefined
+ ? null
+ : {
+ suspensionId: retained.suspensionId,
+ requestEventId: retained.requestEventId,
+ requestFingerprint: retained.requestFingerprint,
+ answer: retained.answer,
+ state: retained.state,
+ },
+ };
+ }
+ if (command.command === "retrieval") {
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: applyRetrieval(ctx.storage, command, ownerTime),
+ };
+ }
+ if (command.command === "executions") {
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: readExecutions(ctx.storage, runId, command.anchor, command.after),
+ };
+ }
+ if (command.command === "begin") {
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: beginRun(
+ ctx.storage,
+ transaction,
+ acquisitionId,
+ command.runId,
+ command.action,
+ command.creation,
+ command.retrieval,
+ command.executionId,
+ ownerTime,
+ ),
+ };
+ }
+ if (command.command === "fork-stage") {
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: stageForkPart(ctx.storage, acquisitionId, {
+ section: command.section,
+ position: command.position,
+ part: command.part,
+ }),
+ };
+ }
+ if (command.command === "fork") {
+ const forked = commitFork(
+ ctx.storage,
+ transaction,
+ acquisitionId,
+ {
+ runId: command.runId,
+ creation: command.creation,
+ retrieval: command.retrieval,
+ origin: command.origin,
+ counts: command.counts,
+ runRecord: command.runRecord,
+ rootImport: command.rootImport,
+ executionId: command.executionId,
+ },
+ mintEventId,
+ ownerTime,
+ );
+ if (forked.value !== null) {
+ // Adopted, so the parts are no longer anything. What they described is
+ // the run now.
+ discardForkParts(ctx.storage, acquisitionId);
+ }
+ return { id: command.id, outcome: "performed", value: forked };
+ }
+ if (command.command === "fork-continue") {
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: continueFork(
+ ctx.storage,
+ transaction,
+ acquisitionId,
+ {
+ runId: command.runId,
+ creation: command.creation,
+ origin: command.origin,
+ runRecord: command.runRecord,
+ rootImport: command.rootImport,
+ executionId: command.executionId,
+ },
+ ownerTime,
+ ),
+ };
+ }
+ if (command.command === "cancel") {
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: cancelRunOnOwner(ctx.storage, command.runId, ownerTime),
+ };
+ }
+ if (command.command === "settle") {
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: settleRun(
+ ctx.storage,
+ acquisitionId,
+ runId,
+ command.completion,
+ command.expectedWorkspaceRootId,
+ ownerTime,
+ ),
+ };
+ }
+ if (command.command === "mappings") {
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: readInvocationSnapshot(ctx.storage, runId),
+ };
+ }
+ // `settle` is a later checkpoint's. It parses strictly and is declined,
+ // because a placeholder that reported success is the one answer a runner
+ // cannot recover from.
+ return { id: command.id, outcome: "refused", refusal: "command:unavailable" };
+}
+
+export function dispatchCommand(
+ ctx: AcquisitionContext,
+ transactions: OwnerTransactions,
+ socket: WebSocket,
+ runId: string,
+ command: RunnerCommand,
+): CommandResult {
+ const held = requireAcquisition(ctx, socket, runId);
+ if (command.command === "open") {
+ // Outside the dispatcher's transaction, because creating owns one of its
+ // own: initialization writes the schema, the run and the starting
+ // Workspace together, and nesting that inside another transaction would
+ // be a second one on the same storage.
+ //
+ // It needs no retained decision either. A repeat finds the run the first
+ // call created and compares immutable identity, which is the same answer;
+ // there is no pristine store left to fill twice.
+ return {
+ id: command.id,
+ outcome: "performed",
+ value: openRun(ctx.storage, transactions, command.runId, command.creation, ownerTime),
+ };
+ }
+ const fingerprint = requestFingerprint(command);
+ return transactions.run(ctx.storage, (transaction) => {
+ const inside = requireAcquisition(ctx, socket, runId);
+ if (inside.acquisitionId !== held.acquisitionId) {
+ throw new CommandError("duplicate-conflict");
+ }
+ // Almost every command that reaches here is asked of a run that already
+ // exists, so the store is held to this build's schema before it is read.
+ // The exceptions are the two that create one: a starting `begin` and a
+ // committed `fork` reach pristine storage on purpose and initialize it
+ // inside this same transaction. Recognizing first would refuse them for
+ // holding nothing at all, and there would be no way to start a remote run.
+ const empty = holdsNoRun(ctx.storage);
+ const creating = initializes(command) && empty;
+ const offering = offersScratch(command) && empty;
+ // Asking whether a destination already holds a fork is a question a store
+ // with no run can answer: the answer is that it does not.
+ const asking = command.command === "fork-continue" && empty;
+ if (offering) {
+ // The scratch this command needs, and nothing else: no schema, no run, no
+ // marker. What is here after it is still a store holding no run.
+ initializePrivateSchema(ctx.storage);
+ }
+ if (!creating && !offering && !asking) {
+ recognizeObject(ctx.storage);
+ }
+
+ // A mutation's decision is looked for by the run, not by the connection.
+ // The case this exists for is the one where the connection that asked is
+ // gone: the owner committed, the answer never arrived, and the runner
+ // reconnected to ask the same question again. Pristine storage retains no
+ // decision, and its private substrate does not exist yet to be asked.
+ if (mutating(command) && !creating && !asking) {
+ const decided = ctx.storage.sql
+ .exec(
+ `SELECT request_fingerprint, response FROM ${MUTATION_TABLE} WHERE command_id = ?`,
+ command.id,
+ )
+ .toArray()[0];
+ if (decided !== undefined) {
+ if (decided.request_fingerprint !== fingerprint) {
+ throw new CommandError("duplicate-conflict");
+ }
+ const decision = storedDecision(decided.response, command.id);
+ if (decision === "reconstruct") {
+ // A mutation's decision is always retained whole. Reconstructing one
+ // would mean applying it again.
+ throw new Error("private protocol storage holds a malformed result");
+ }
+ // The answer was lost, not the fact. What the answer *is* decides
+ // what re-observing it means, and the retained answer is the thing
+ // that says so: an answer carrying a begun value grants execution
+ // authority, and a conflict or a lifecycle refusal grants none.
+ if (begunExecution(command, decision) !== null) {
+ // Authority is only re-observable once that exact execution is this
+ // acquisition's. Anything else — it was recovered, settled, held by
+ // somebody live, or the ledger never recorded it at all — is history
+ // rather than authority, and returning it would hand back a database
+ // nobody may settle.
+ if (adoptExecution(ctx.storage, held.acquisitionId, command.id) !== "adopted") {
+ throw new CommandError("stale-journal");
+ }
+ } else if (recordedExecution(ctx.storage, command.id) !== undefined) {
+ // The ledger says this decision began an execution and the decision
+ // itself grants none. They cannot both be right, and neither is
+ // authority to hand back.
+ throw new CommandError("stale-journal");
+ }
+ return decision;
+ }
+ }
+
+ const previous =
+ (creating || asking) && !offering
+ ? undefined
+ : ctx.storage.sql
+ .exec(
+ `SELECT request_fingerprint, response FROM ${COMMAND_TABLE}
+ WHERE acquisition_id = ? AND command_id = ?`,
+ held.acquisitionId,
+ command.id,
+ )
+ .toArray()[0];
+ if (previous !== undefined) {
+ if (previous.request_fingerprint !== fingerprint) {
+ throw new CommandError("duplicate-conflict");
+ }
+ const decision = storedDecision(previous.response, command.id);
+ return decision === "reconstruct"
+ ? perform(ctx, runId, held.acquisitionId, command, transaction)
+ : decision;
+ }
+ // A store with no run has spent nothing this ledger knows about, and until
+ // the scratch exists there is nothing to ask.
+ const usage =
+ (creating || asking) && !offering
+ ? { commands: 0, bytes: 0 }
+ : ledgerUsage(ctx.storage, held.acquisitionId);
+ if (usage.commands >= MAX_COMMANDS || usage.bytes >= MAX_LEDGER_BYTES) {
+ throw new CommandError("capacity");
+ }
+ const result = perform(ctx, runId, held.acquisitionId, command, transaction);
+ const encoded = retainedDecision(command, result);
+ const responseBytes = new TextEncoder().encode(encoded).length;
+ if (usage.bytes + responseBytes > MAX_LEDGER_BYTES) {
+ throw new CommandError("capacity");
+ }
+ ctx.storage.sql.exec(
+ `INSERT INTO ${COMMAND_TABLE}
+ (acquisition_id, command_id, request_fingerprint, response, response_bytes)
+ VALUES (?, ?, ?, ?, ?)`,
+ held.acquisitionId,
+ command.id,
+ fingerprint,
+ encoded,
+ responseBytes,
+ );
+ if (mutating(command)) {
+ // Recorded in this same transaction as the mutation it describes, so a
+ // crash cannot leave one without the other.
+ const mutations =
+ creating || asking
+ ? undefined
+ : ctx.storage.sql.exec(`SELECT count(*) AS decided FROM ${MUTATION_TABLE}`).toArray()[0];
+ if (mutations !== undefined && integer(mutations["decided"]) >= MAX_COMMANDS) {
+ throw new CommandError("capacity");
+ }
+ ctx.storage.sql.exec(
+ `INSERT INTO ${MUTATION_TABLE}
+ (command_id, request_fingerprint, response, response_bytes, execution_id)
+ VALUES (?, ?, ?, ?, ?)`,
+ command.id,
+ fingerprint,
+ encoded,
+ responseBytes,
+ // Which execution this decision began, when it began one, so a
+ // replacement acquisition re-observing it can adopt the run rather
+ // than being told about an execution it may not touch.
+ begunExecution(command, result),
+ );
+ }
+ return result;
+ });
+}
diff --git a/packages/workflow/src/cloudflare/encoding.ts b/packages/workflow/src/cloudflare/encoding.ts
new file mode 100644
index 000000000..b99308f3a
--- /dev/null
+++ b/packages/workflow/src/cloudflare/encoding.ts
@@ -0,0 +1,57 @@
+/**
+ * The two encodings the private protocol carries bytes and identities in.
+ *
+ * A WebSocket text frame carries text, and content-addressed bytes are not
+ * text, so base64 is what the private protocol uses. It is canonical in both
+ * directions: a value that decodes and then re-encodes to something else is
+ * refused rather than accepted as though the difference did not matter, because
+ * a digest is taken over bytes and two spellings of one byte sequence would be
+ * two names for one piece of content.
+ *
+ * `bytesOf` is the storage side of the same question. SQLite hands back a blob
+ * as whatever the runtime models one as, and a column that is not bytes at all
+ * is damage rather than something to coerce.
+ */
+
+import { CommandError } from "./commands.ts";
+export { sha256Hex } from "../workspace/sha256.ts";
+
+export function encodeBase64(bytes: Uint8Array): string {
+ let binary = "";
+ const stride = 32 * 1024;
+ for (let offset = 0; offset < bytes.length; offset += stride) {
+ binary += String.fromCharCode(...bytes.slice(offset, offset + stride));
+ }
+ return btoa(binary);
+}
+
+export function decodeBase64(value: string): Uint8Array {
+ if (value === "" || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
+ throw new CommandError("malformed-member");
+ }
+ let binary: string;
+ try {
+ binary = atob(value);
+ } catch {
+ throw new CommandError("malformed-member");
+ }
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
+ if (encodeBase64(bytes) !== value) {
+ throw new CommandError("malformed-member");
+ }
+ return bytes;
+}
+
+export function bytesOf(value: unknown): Uint8Array {
+ if (value instanceof Uint8Array) {
+ return new Uint8Array(value);
+ }
+ if (value instanceof ArrayBuffer) {
+ return new Uint8Array(value.slice(0));
+ }
+ throw new Error("stored bytes are not a byte sequence");
+}
+
+export function hex(bytes: Uint8Array): string {
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
+}
diff --git a/packages/workflow/src/cloudflare/endpoint.ts b/packages/workflow/src/cloudflare/endpoint.ts
new file mode 100644
index 000000000..4408b145c
--- /dev/null
+++ b/packages/workflow/src/cloudflare/endpoint.ts
@@ -0,0 +1,83 @@
+/**
+ * Where this deployment's owners are, parsed once and then never re-read.
+ *
+ * One value from trusted configuration, held to what the supported routes can
+ * mean before a token is minted or a socket is opened. A credential in it, a
+ * fragment, a query, a scheme this build does not speak or a path it never
+ * writes are configuration mistakes, and an operator learns about them at
+ * construction rather than in the middle of a run.
+ *
+ * It never travels. The endpoint is host closure state: no workflow record, no
+ * journal event, no diagnostic and no document-visible value carries it, so a
+ * run cannot report where its owner was reached and a document cannot ask.
+ */
+
+import { planePath, type OwnerPlane } from "./routes.ts";
+
+/** Why an endpoint cannot address this deployment's owners. */
+export type EndpointRefusal =
+ | "endpoint-absent"
+ | "endpoint-unparseable"
+ | "endpoint-scheme"
+ | "endpoint-credentials"
+ | "endpoint-query"
+ | "endpoint-fragment";
+
+export class OwnerEndpointError extends Error {
+ override name = "OwnerEndpointError";
+
+ constructor(readonly refusal: EndpointRefusal) {
+ super(`this workflow owner endpoint cannot be used (${refusal})`);
+ }
+}
+
+/** One deployment's owner endpoint, normalized. */
+export interface OwnerEndpoint {
+ /** Where one run's plane is, as an absolute URL. */
+ planeUrl(runId: string, plane: OwnerPlane): string;
+}
+
+/** The schemes an owner is reached over. */
+const SCHEMES: readonly string[] = ["https:", "http:"];
+
+/**
+ * Parse one endpoint, or refuse it.
+ *
+ * `http:` is admitted beside `https:` because a local owner — `workerd` on a
+ * loopback address — is how this is exercised without a deployment. Which
+ * scheme an operator may configure is deployment policy above this, and
+ * nothing here weakens transport security on its own.
+ */
+export function parseOwnerEndpoint(value: unknown): OwnerEndpoint {
+ if (typeof value !== "string" || value === "") {
+ throw new OwnerEndpointError("endpoint-absent");
+ }
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ throw new OwnerEndpointError("endpoint-unparseable");
+ }
+ if (!SCHEMES.includes(url.protocol)) {
+ throw new OwnerEndpointError("endpoint-scheme");
+ }
+ // A credential in a configured endpoint would be a credential this client
+ // sends on every request without ever having been given one to hold.
+ if (url.username !== "" || url.password !== "") {
+ throw new OwnerEndpointError("endpoint-credentials");
+ }
+ if (url.search !== "") {
+ throw new OwnerEndpointError("endpoint-query");
+ }
+ if (url.hash !== "") {
+ throw new OwnerEndpointError("endpoint-fragment");
+ }
+ // The base path, without a trailing separator, so the plane path below is the
+ // only thing that decides the shape of what follows.
+ const base = `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
+ return {
+ planeUrl(runId: string, plane: OwnerPlane): string {
+ return `${base}${planePath(runId, plane)}`;
+ },
+ };
+}
diff --git a/packages/workflow/src/cloudflare/executor-connection.ts b/packages/workflow/src/cloudflare/executor-connection.ts
new file mode 100644
index 000000000..468f5f496
--- /dev/null
+++ b/packages/workflow/src/cloudflare/executor-connection.ts
@@ -0,0 +1,69 @@
+/**
+ * One admitted executor connection, assembled for this owner.
+ *
+ * The provider asks its host for an acquisition; this is what a Cloudflare host
+ * gives it. Both halves come from the same socket — the link that reads and
+ * commits, and the lifecycle commands that move the run — because they are the
+ * same authority. Reaching the socket is the host's business and stays behind
+ * the `open` it is handed, so nothing here knows about tokens, releases or
+ * upgrade headers.
+ */
+
+import { Err, Ok, type Operation, type Result } from "effection";
+import type { RemoteExecutorConnection } from "../remote/lifecycle-link.ts";
+import { useOwnerConnection, type OwnerSocket } from "../remote/client.ts";
+import { cloudflareLifecycleLink } from "./lifecycle-link.ts";
+import { cloudflareReadLink, cloudflareRunLink, translate } from "./client.ts";
+
+/**
+ * How a host reaches one run's executor socket.
+ *
+ * Answering `already-running` is a fact about the run: another live executor
+ * holds it, and that is not an error to translate but an outcome to report.
+ */
+export interface ExecutorAdmission {
+ open(runId: string): Operation>;
+ /** Fresh correlation identities for this connection's commands. */
+ ids(): () => string;
+}
+
+/**
+ * Admit one connection and build both halves over it.
+ *
+ * The connection is a resource of the calling scope, so it closes exactly when
+ * that scope ends — which is what makes the acquisition's lifetime the
+ * connection's lifetime rather than a duration.
+ */
+export function* useExecutorConnection(
+ admission: ExecutorAdmission,
+ runId: string,
+): Operation> {
+ const socket = yield* admission.open(runId);
+ if (!socket.ok) {
+ return socket;
+ }
+ if (socket.value === "already-running") {
+ return Ok("already-running");
+ }
+ try {
+ const connection = yield* useOwnerConnection(socket.value);
+ const nextId = admission.ids();
+ const reads = cloudflareReadLink(connection, nextId, runId);
+ return Ok({
+ link: cloudflareRunLink(connection, nextId, runId),
+ lifecycle: cloudflareLifecycleLink(connection, reads, nextId),
+ // deno-lint-ignore require-yield
+ *close(): Operation {
+ // The socket is the acquisition. Ending the connection is how this
+ // runner stops being the run's executor before its scope ends, and it
+ // is the same teardown scope exit would reach, so the scope ending
+ // afterwards finds nothing left to do.
+ connection.close();
+ },
+ });
+ } catch (error) {
+ // Whatever went wrong reaching or building the connection, a caller learns
+ // it as a storage failure rather than as this adapter's own vocabulary.
+ return Err(translate(error));
+ }
+}
diff --git a/packages/workflow/src/cloudflare/fork-anchor.ts b/packages/workflow/src/cloudflare/fork-anchor.ts
new file mode 100644
index 000000000..10be3288f
--- /dev/null
+++ b/packages/workflow/src/cloudflare/fork-anchor.ts
@@ -0,0 +1,121 @@
+/**
+ * What a fork's source selection is, said once.
+ *
+ * A source owner computes this over rows it reads out of its own storage; a
+ * destination owner computes it over the parts it was offered. They have to
+ * agree exactly, or the anchor proves nothing — so the ordered logical value
+ * and the digest over it live here, in one place, and both sides build the same
+ * shape rather than each spelling their own.
+ *
+ * What goes in is everything a destination copies that a content identity does
+ * not already imply: the checkpoint and the three head roots, the inherited
+ * rows with their exact retained bytes and their root associations, each root's
+ * record and its ordered reference arrays, each manifest's and blob's retained
+ * metadata *including its watermark* — a digest stands for bytes and for the
+ * size derived from them, never for a watermark, which is copied and can move
+ * while the content stands still — and every selected checkout in the owner's
+ * own order.
+ */
+
+import { sha256Hex } from "../workspace/sha256.ts";
+
+/** One inherited row, as both sides describe it. */
+export interface AnchorRow {
+ readonly eventId: string;
+ readonly record: string;
+ readonly workspaceRootId: string;
+}
+
+/** One Workspace root's retained record and its ordered references. */
+export interface AnchorRoot {
+ readonly rootId: string;
+ readonly formatVersion: number;
+ readonly manifest: string;
+ readonly manifestHashes: readonly string[];
+ readonly blobHashes: readonly string[];
+}
+
+/** One content manifest's retained metadata and bytes. */
+export interface AnchorManifest {
+ readonly hash: string;
+ readonly size: number;
+ readonly lastSeen: number;
+ /** The encoded manifest, base64 as the private protocol carries it. */
+ readonly encoded: string;
+}
+
+/** One blob's retained metadata, without its bytes. */
+export interface AnchorBlob {
+ readonly hash: string;
+ readonly size: number;
+ readonly lastSeen: number;
+}
+
+/** One checkout, by the key it is paged under and the record it is. */
+export interface AnchorCheckout {
+ readonly key: string;
+ readonly value: Record;
+}
+
+/** The whole selection, in the order it is hashed. */
+export interface ForkSelection {
+ readonly checkpointEventId: string;
+ readonly checkpointWorkspaceRootId: string;
+ readonly runRecordWorkspaceRootId: string;
+ readonly rootImportWorkspaceRootId: string;
+ readonly inherited: readonly AnchorRow[];
+ readonly roots: readonly AnchorRoot[];
+ readonly manifests: readonly AnchorManifest[];
+ readonly blobs: readonly AnchorBlob[];
+ readonly checkouts: readonly AnchorCheckout[];
+}
+
+/**
+ * The identity of one committed selection.
+ *
+ * Ordered throughout: the members are hashed in the order the owner selected
+ * them, so a reordering is a different selection rather than the same one
+ * described differently.
+ */
+export function forkSelectionAnchor(selection: ForkSelection): string {
+ return sha256Hex(
+ JSON.stringify({
+ checkpointEventId: selection.checkpointEventId,
+ checkpointWorkspaceRootId: selection.checkpointWorkspaceRootId,
+ runRecordWorkspaceRootId: selection.runRecordWorkspaceRootId,
+ rootImportWorkspaceRootId: selection.rootImportWorkspaceRootId,
+ inherited: selection.inherited.map((row) => [row.eventId, row.record, row.workspaceRootId]),
+ roots: selection.roots.map((root) => ({
+ rootId: root.rootId,
+ formatVersion: root.formatVersion,
+ manifest: root.manifest,
+ manifestHashes: [...root.manifestHashes],
+ blobHashes: [...root.blobHashes],
+ })),
+ manifests: selection.manifests.map((manifest) => ({
+ hash: manifest.hash,
+ size: manifest.size,
+ lastSeen: manifest.lastSeen,
+ encoded: manifest.encoded,
+ })),
+ blobs: selection.blobs.map((blob) => ({
+ hash: blob.hash,
+ size: blob.size,
+ lastSeen: blob.lastSeen,
+ })),
+ checkouts: selection.checkouts.map((checkout) => [checkout.key, checkout.value]),
+ }),
+ );
+}
+
+/**
+ * One checkout's identity, as a key nothing else can spell.
+ *
+ * A Repository name and a Worktree name are retained text and may hold any
+ * character, so joining them with a separator is not an identity: `("a:b", "c")`
+ * and `("a", "b:c")` are two retained Worktrees that would join to one string.
+ * A JSON array of the parts escapes what it must and separates what it must.
+ */
+export function checkoutKey(parts: readonly string[]): string {
+ return JSON.stringify(parts);
+}
diff --git a/packages/workflow/src/cloudflare/gateway.ts b/packages/workflow/src/cloudflare/gateway.ts
new file mode 100644
index 000000000..d252b9071
--- /dev/null
+++ b/packages/workflow/src/cloudflare/gateway.ts
@@ -0,0 +1,45 @@
+/**
+ * The Worker in front of these owners, as far as this package decides it.
+ *
+ * One job: find which run a request is for, and hand the request to that run's
+ * object. The run id comes out of the path, is admitted before it reaches
+ * `idFromName` — that call answers with an object for any string, so a
+ * mistyped id would otherwise address a fresh, empty owner rather than fail —
+ * and the request travels on unopened.
+ *
+ * Nothing else happens here. The body is not read, the headers are not
+ * inspected, no token is verified and no state is touched: an owner that
+ * trusted a gateway's account of any of those would have moved its own
+ * admission outside itself.
+ */
+
+import { admitRunId, ownerFor, type OwnerNamespace } from "./routing.ts";
+import { routeOf } from "./routes.ts";
+
+/** What a stub has to offer for a request to be forwarded to it. */
+export interface OwnerStub {
+ fetch(request: Request): Promise;
+}
+
+/**
+ * Forward one request to the owner of the run it names.
+ *
+ * Answers `404` for a path this build does not write and `400` for a run id
+ * that cannot address an owner — neither of which reaches an object at all.
+ */
+export async function ownerRoute(
+ namespace: OwnerNamespace,
+ request: Request,
+): Promise {
+ const route = routeOf(new URL(request.url).pathname);
+ if (route === undefined) {
+ return new Response("route", { status: 404 });
+ }
+ let runId: string;
+ try {
+ runId = admitRunId(route.runId);
+ } catch {
+ return new Response("run-id", { status: 400 });
+ }
+ return await ownerFor(namespace, runId).fetch(request);
+}
diff --git a/packages/workflow/src/cloudflare/lifecycle-link.ts b/packages/workflow/src/cloudflare/lifecycle-link.ts
new file mode 100644
index 000000000..052262e16
--- /dev/null
+++ b/packages/workflow/src/cloudflare/lifecycle-link.ts
@@ -0,0 +1,371 @@
+/**
+ * The lifecycle commands, spelled for this owner.
+ *
+ * The same connection that reads a run and commits to it is the one that
+ * begins, settles, cancels and forks it: the authority is the socket, and
+ * splitting the lifecycle onto a second link would be a second authority. So
+ * this is built from the same connection as the Workspace half and composed
+ * with it, never paired from somewhere else.
+ *
+ * Everything an owner answers is parsed before it becomes a value. A begin that
+ * says it began an execution has to say which one, against a frontier that
+ * describes this run; a refusal has to be one of the three conditions this
+ * build knows; and anything else is an answer this build cannot read, reported
+ * as damage rather than guessed at.
+ */
+
+import { Err, Ok, type Operation, type Result } from "effection";
+import type { DurableEvent } from "@executablemd/durable-streams";
+import { serializeDurableEvent } from "@executablemd/durable-streams";
+import type {
+ DocumentExecutionCompletion,
+ DocumentExecutionRecord,
+ WorkflowRunRecord,
+} from "../storage/record.ts";
+import type { CreateWorkflowRunRequest } from "../storage/api.ts";
+import type { RemoteFrontierSnapshot } from "../remote/read.ts";
+import type {
+ RemoteBeginCommand,
+ RemoteBegun,
+ RemoteForkCommit,
+ RemoteForkContinuation,
+ RemoteForkPart,
+ RemoteLifecycleAnswer,
+ RemoteLifecycleLink,
+} from "../remote/lifecycle-link.ts";
+import type { RemoteLifecycleRefusal } from "../remote/lifecycle-link.ts";
+import { WorkflowRecordMalformedError, WorkflowRunConflictError } from "../storage/errors.ts";
+import { parseRemoteExecution } from "../remote/records.ts";
+import type { AnchoringReadLink, OwnerConnection } from "./client.ts";
+import { privateRefusal, storageFailure, translate } from "./client.ts";
+
+/** The conditions an owner may name, and the only ones this build reads. */
+const REFUSALS: readonly RemoteLifecycleRefusal[] = [
+ "cancelled",
+ "resume-failed",
+ "terminal",
+ "damaged-terminal",
+];
+
+function refusalOf(value: unknown): RemoteLifecycleRefusal | undefined {
+ return REFUSALS.find((refusal) => refusal === value);
+}
+
+function fail(reason: string): never {
+ throw new WorkflowRecordMalformedError("lifecycle answer this run's owner returned", reason);
+}
+
+function members(value: unknown, names: readonly string[]): Map {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ return fail("it was not one object");
+ }
+ const found = new Map(Object.entries(value));
+ if (found.size !== names.length || names.some((name) => !found.has(name))) {
+ return fail("it did not carry the members this build reads");
+ }
+ return found;
+}
+
+/** The three fields a lifecycle answer is one of, and never two of. */
+function answered(
+ value: unknown,
+ runId: string,
+ read: (value: unknown) => T,
+): RemoteLifecycleAnswer {
+ const found = members(value, ["conflict", "refusal", "value"]);
+ const conflict = found.get("conflict");
+ const refusal = found.get("refusal");
+ const held = found.get("value");
+ const present = [conflict, refusal, held].filter((member) => member !== null).length;
+ if (present !== 1) {
+ return fail("it did not answer exactly one way");
+ }
+ if (conflict !== null) {
+ if (!Array.isArray(conflict) || conflict.length === 0) {
+ return fail("it named no differing field");
+ }
+ throw new WorkflowRunConflictError(
+ runId,
+ conflict.map((field) => (typeof field === "string" ? field : fail("it named no field"))),
+ );
+ }
+ if (refusal !== null) {
+ const named = refusalOf(refusal);
+ if (named === undefined) {
+ return fail("it named no condition this build reads");
+ }
+ return { kind: "refused", refusal: named };
+ }
+ return { kind: "performed", value: read(held) };
+}
+
+function execution(value: unknown): DocumentExecutionRecord {
+ return parseRemoteExecution(value);
+}
+
+/**
+ * One lifecycle link over an admitted connection.
+ *
+ * `reads` is the same anchoring read link the Workspace half uses, so a
+ * frontier this returns is assembled exactly the way every other frontier is.
+ */
+export function cloudflareLifecycleLink(
+ connection: OwnerConnection,
+ reads: AnchoringReadLink,
+ nextId: () => string,
+): RemoteLifecycleLink {
+ function* frontierOf(value: unknown): Operation {
+ return yield* reads.anchored(reads.parseHeader(value));
+ }
+
+ function* begun(value: unknown): Operation {
+ const found = members(value, ["frontier", "execution", "replay", "recovered"]);
+ if (typeof found.get("replay") !== "boolean") {
+ return fail("it did not say whether the run replayed");
+ }
+ const recovered = found.get("recovered");
+ return {
+ frontier: yield* frontierOf(found.get("frontier")),
+ execution: execution(found.get("execution")),
+ replay: found.get("replay") === true,
+ recovered: recovered === null ? null : execution(recovered),
+ };
+ }
+
+ return {
+ *begin(request: RemoteBeginCommand): Operation>> {
+ try {
+ const offered = yield* connection.ask(
+ request.commandId,
+ {
+ command: "begin",
+ runId: request.runId,
+ action: request.action,
+ creation: request.creation,
+ retrieval: request.retrieval ?? null,
+ executionId: request.executionId,
+ },
+ (value: unknown) => value,
+ privateRefusal,
+ );
+ if (offered.outcome === "refused") {
+ return Err(storageFailure(privateRefusal(offered.refusal)));
+ }
+ // Parsed outside the answer callback because assembling a frontier is
+ // more owner reads, and those belong to this operation rather than to
+ // the one message that carried the header.
+ const decided = answered(offered.value, request.runId, (value) => value);
+ if (decided.kind === "refused") {
+ return Ok(decided);
+ }
+ return Ok({ kind: "performed", value: yield* begun(decided.value) });
+ } catch (error) {
+ return Err(translate(error));
+ }
+ },
+
+ *settle(
+ commandId: string,
+ completion: DocumentExecutionCompletion,
+ expectedWorkspaceRootId: string,
+ ): Operation> {
+ try {
+ const offered = yield* connection.ask(
+ commandId,
+ { command: "settle", completion, expectedWorkspaceRootId },
+ (value: unknown) => value,
+ privateRefusal,
+ );
+ if (offered.outcome === "refused") {
+ return Err(storageFailure(privateRefusal(offered.refusal)));
+ }
+ return Ok(yield* frontierOf(offered.value));
+ } catch (error) {
+ return Err(translate(error));
+ }
+ },
+
+ *cancel(
+ commandId: string,
+ runId: string,
+ ): Operation>> {
+ try {
+ const offered = yield* connection.ask(
+ commandId,
+ { command: "cancel", runId },
+ (value: unknown) => value,
+ privateRefusal,
+ );
+ if (offered.outcome === "refused") {
+ return Err(storageFailure(privateRefusal(offered.refusal)));
+ }
+ const decided = answered(offered.value, runId, (value) => value);
+ if (decided.kind === "refused") {
+ return Ok(decided);
+ }
+ const frontier = yield* frontierOf(decided.value);
+ return Ok({ kind: "performed", value: frontier.record });
+ } catch (error) {
+ return Err(translate(error));
+ }
+ },
+
+ *stageForkPart(commandId: string, part: RemoteForkPart): Operation