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> { + try { + const offered = yield* connection.ask( + commandId, + { + command: "fork-stage", + section: part.section, + position: part.position, + part: part.part, + }, + (value: unknown) => members(value, ["staged"]).get("staged"), + privateRefusal, + ); + if (offered.outcome === "refused") { + return Err(storageFailure(privateRefusal(offered.refusal))); + } + return Ok(undefined); + } catch (error) { + return Err(translate(error)); + } + }, + + *continueFork( + continuation: RemoteForkContinuation, + ): Operation | "absent">> { + try { + const offered = yield* connection.ask( + continuation.commandId, + { + command: "fork-continue", + runId: continuation.runId, + creation: continuation.creation, + origin: continuation.origin, + runRecord: record(continuation.runRecord), + rootImport: record(continuation.rootImport), + executionId: continuation.executionId, + }, + (value: unknown) => value, + privateRefusal, + ); + if (offered.outcome === "refused") { + const refusal = privateRefusal(offered.refusal); + if (refusal === "command:absent") { + // Nothing there to continue. Not a failure: the caller's next move + // is the source copy it has not needed until now. + return Ok | "absent">("absent"); + } + return Err(storageFailure(refusal)); + } + const decided = yield* forked(offered.value, continuation.runId); + return decided.ok + ? Ok | "absent">(decided.value) + : decided; + } catch (error) { + return Err(translate(error)); + } + }, + *commitFork( + commit: RemoteForkCommit, + ): Operation | "needs-transfer">> { + try { + const offered = yield* connection.ask( + commit.commandId, + { + command: "fork", + runId: commit.runId, + creation: commit.creation, + retrieval: commit.retrieval ?? null, + origin: commit.origin, + counts: commit.counts, + runRecord: record(commit.runRecord), + rootImport: record(commit.rootImport), + executionId: commit.executionId, + }, + (value: unknown) => value, + privateRefusal, + ); + if (offered.outcome === "refused") { + const refusal = privateRefusal(offered.refusal); + if (refusal === "command:needs-transfer") { + // The destination is empty and this connection offered it nothing. + // A closed outcome, not a failure: the caller copies the source. + const outcome: RemoteLifecycleAnswer | "needs-transfer" = "needs-transfer"; + return Ok(outcome); + } + return Err(storageFailure(refusal)); + } + const decided = yield* forked(offered.value, commit.runId); + return decided.ok + ? Ok | "needs-transfer">(decided.value) + : decided; + } catch (error) { + return Err(translate(error)); + } + }, + }; + + /** One fork answer: a conflict, a condition, or the destination it made. */ + function* forked( + value: unknown, + runId: string, + ): Operation>> { + const found = members(value, ["conflict", "refusal", "value"]); + const refusal = found.get("refusal"); + if (refusal !== null) { + const named = refusalOf(refusal); + if (named === undefined) { + return Err( + new WorkflowRecordMalformedError( + "lifecycle answer this run's owner returned", + "it named no condition this build reads", + ), + ); + } + return Ok({ kind: "refused", refusal: named }); + } + const conflict = found.get("conflict"); + if (conflict !== null) { + if (!Array.isArray(conflict) || conflict.length === 0) { + return Err( + new WorkflowRecordMalformedError( + "lifecycle answer this run's owner returned", + "it named no differing field", + ), + ); + } + return Err( + new WorkflowRunConflictError( + runId, + conflict.map((field) => (typeof field === "string" ? field : "definition")), + ), + ); + } + const held = members(found.get("value"), ["frontier", "execution", "replay", "recovered"]); + if (typeof held.get("replay") !== "boolean") { + return Err( + new WorkflowRecordMalformedError( + "lifecycle answer this run's owner returned", + "it did not say whether the destination replayed", + ), + ); + } + const recovered = held.get("recovered"); + return Ok({ + kind: "performed", + value: { + frontier: yield* frontierOf(held.get("frontier")), + execution: execution(held.get("execution")), + replay: held.get("replay") === true, + recovered: recovered === null ? null : execution(recovered), + }, + }); + } +} + +/** One head record, in the canonical spelling a journal retains. */ +function record(event: DurableEvent): string { + return serializeDurableEvent(event); +} diff --git a/packages/workflow/src/cloudflare/marker.ts b/packages/workflow/src/cloudflare/marker.ts new file mode 100644 index 000000000..da15833c9 --- /dev/null +++ b/packages/workflow/src/cloudflare/marker.ts @@ -0,0 +1,99 @@ +/** + * How the Cloudflare owner says which schema its storage holds. + * + * The Deno host writes `PRAGMA application_id` and `PRAGMA user_version` into + * the SQLite header, and recognition reads them back to tell three conditions + * apart: a database belonging to something else, a version this build has not + * learned, and a database that claims version 1 and is not shaped like one. + * + * A Durable Object's SQLite refuses both pragmas — `not authorized: + * SQLITE_AUTH`, on read as well as write — so this adapter carries the same two + * values in a table of its own. The logical schema version is unchanged and + * shared with Deno; only the physical carrier differs, which is why this table + * is adapter-private recognition metadata rather than a WorkflowRun record. It + * is never a journal value, an exported field, an authored value, a public API, + * or a second schema. + * + * The constraints are what make the claim trustworthy. `id` is fixed at 1 by a + * CHECK and is the primary key, so a second identity row cannot exist; both + * values are non-null integers; and a row that disagrees with this build is + * refused rather than migrated. + */ + +import { APPLICATION_ID, isSchemaVersion, SCHEMA_VERSION } from "../sqlite/workflow-schema.ts"; + +/** The adapter-private table carrying this database's identity. */ +export const MARKER_TABLE = "_xmd_workflow_schema"; + +export const MARKER_SQL = `CREATE TABLE ${MARKER_TABLE} ( + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1), + application_id INTEGER NOT NULL, + schema_version INTEGER NOT NULL +) STRICT, WITHOUT ROWID`; + +/** What one marker row says. */ +export interface SchemaMarker { + readonly applicationId: number; + readonly schemaVersion: number; +} + +/** Why a marker could not be accepted. */ +export type MarkerFailure = + | { readonly kind: "absent" } + | { readonly kind: "duplicated"; readonly rows: number } + | { readonly kind: "malformed" } + | { readonly kind: "foreign-application"; readonly applicationId: number } + | { readonly kind: "incomplete-version" } + | { readonly kind: "unknown-version"; readonly schemaVersion: number }; + +/** + * Read a marker out of rows the caller already selected. + * + * Takes rows rather than a connection so the comparison is the same whoever + * consumed the cursor — Cloudflare requires a cursor to be drained + * synchronously, and that is the caller's concern rather than this one's. + */ +export function readMarker(rows: readonly Record[]): SchemaMarker | MarkerFailure { + if (rows.length === 0) { + return { kind: "absent" }; + } + if (rows.length > 1) { + return { kind: "duplicated", rows: rows.length }; + } + const row = rows[0]; + if (row === undefined) { + return { kind: "absent" }; + } + const applicationId = row["application_id"]; + const schemaVersion = row["schema_version"]; + if ( + typeof applicationId !== "number" || + !Number.isInteger(applicationId) || + typeof schemaVersion !== "number" || + !Number.isInteger(schemaVersion) + ) { + return { kind: "malformed" }; + } + if (applicationId !== APPLICATION_ID) { + return { kind: "foreign-application", applicationId }; + } + if (schemaVersion === 0) { + // The identity is this project's and the version says nothing was + // finished. That is a database left partly initialized, not an older one. + return { kind: "incomplete-version" }; + } + if (!isSchemaVersion(schemaVersion)) { + // Outside what the version carrier can hold, so no build wrote it. The row + // is damaged retained data rather than a version to report. + return { kind: "malformed" }; + } + if (schemaVersion !== SCHEMA_VERSION) { + return { kind: "unknown-version", schemaVersion }; + } + return { applicationId, schemaVersion }; +} + +/** Whether a read produced a marker rather than a reason it could not. */ +export function isSchemaMarker(value: SchemaMarker | MarkerFailure): value is SchemaMarker { + return "applicationId" in value && !("kind" in value); +} diff --git a/packages/workflow/src/cloudflare/owner-answers.ts b/packages/workflow/src/cloudflare/owner-answers.ts new file mode 100644 index 000000000..ec9e48bd4 --- /dev/null +++ b/packages/workflow/src/cloudflare/owner-answers.ts @@ -0,0 +1,538 @@ +/** + * What one owner retains for the answers delivered to its durable waits. + * + * A retained answer is a row rather than a journal event, for the same reason + * it is a row on a local host: the value arrives while nothing is running, and + * it becomes history only when an execution reaches the wait it answers and + * publishes it. Until then this is the whole of what the run holds. + * + * The row is written by the delivery plane, which takes no acquisition, and + * spent by a commit, which requires the exact one. Both are decided here rather + * than believed: the wait is read from the run's own account of why it stopped, + * and the consumption is checked against the event the commit is appending. + */ + +import { parseDurableEvent, serializeDurableEvent } from "@executablemd/durable-streams"; +import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import { readRunRecord, type Row } from "../sqlite/rows.ts"; +import { WorkflowRecordMalformedError } from "../storage/errors.ts"; +import { parseJsonValue } from "../storage/members.ts"; +import { canonicalJson } from "../storage/record.ts"; +import { SUSPENSION_ANSWER, SUSPENSION_REQUEST } from "../suspension/effects.ts"; +import { prepareResponseValidator } from "@executablemd/core/elicitation"; +import type { ResponseValidator } from "@executablemd/core/elicitation"; +import { CommandError } from "./commands.ts"; +import { sha256Hex } from "./encoding.ts"; +import { heldExecution } from "./private-schema.ts"; +import { declaredObjects, holdsNoRun, isPristine, recognizeObject } from "./recognition.ts"; +import { retainedText } from "./retained.ts"; +import type { OwnerStorage } from "./storage.ts"; + +/** + * What one wait retains, as this owner reads it. + * + * The request and its response schema travel as the retained description held + * them — unparsed here, because judging a value against a schema is the + * document runtime's work and this is not the document runtime. What this owner + * decides is identity: which run, which wait, which event, and the fingerprint + * a later retention is held to. + */ +export interface OwnerRetainedWait { + readonly runId: string; + readonly suspensionId: string; + readonly requestEventId: string; + readonly request: unknown; + readonly responseSchema: unknown; +} + +/** One answer this owner retains, whatever state it is in. */ +export interface OwnerRetainedAnswer { + readonly suspensionId: string; + readonly requestEventId: string; + readonly requestFingerprint: string; + readonly answer: string; + readonly state: "pending" | "consumed"; +} + +/** + * The wait this run is standing at, or a refusal naming why it is not at one. + * + * Recognition first, so a store that is not this build's run refuses as itself. + * Then the run's own account of why it stopped: a status of `suspended` whose + * stop reason names a retained request event, and that event being the wait + * being asked about. Anything else — a completed run, a cancelled one, a run + * stopped for another reason, another wait's request — is a run this value does + * not answer. + */ +export function readRetainedWait( + storage: OwnerStorage, + runId: string, + suspensionId: string, +): OwnerRetainedWait { + const record = readRunRecord(retainedRun(storage, runId)); + if (record.status !== "suspended") { + throw new CommandError("not-suspended"); + } + const reason = record.stopReason; + if (reason === undefined || reason.kind !== "journal") { + throw new CommandError("not-suspended"); + } + const row = storage.sql + .exec("SELECT event_id, record FROM journal_events WHERE event_id = ?", reason.eventId) + .toArray()[0]; + if (row === undefined) { + throw new CommandError("corrupt-journal"); + } + const event = readEvent(row); + if ( + event.type !== "yield" || + event.description.type !== SUSPENSION_REQUEST || + event.description.name !== suspensionId + ) { + throw new CommandError("wrong-suspension"); + } + return { + runId: record.runId, + suspensionId, + requestEventId: retainedText(row, "event_id"), + request: event.description.request, + responseSchema: event.description.responseSchema, + }; +} + +/** + * The retained answer this acquisition may spend, if there is one. + * + * Reading retained input is part of ending a wait, so it takes the same + * authority ending one does: the acquisition asking has to hold an open + * execution, and the wait asked about has to be the one this run is standing + * at, with the request this claim names. A socket that has begun nothing, or + * whose execution has been settled, recovered or replaced, is told nothing — + * not because the row is missing, but because reading it is not its to do. + */ +export function readClaimableAnswer( + storage: OwnerStorage, + runId: string, + acquisitionId: string, + claim: { readonly suspensionId: string; readonly requestEventId: string }, +): OwnerRetainedAnswer | undefined { + requireOpenExecution(storage, acquisitionId); + // The request this claim names, read from the run's own history. Not the + // stop reason: a run being resumed is running, and the wait it is replaying + // toward is a published event rather than the reason it last stopped. + requirePublishedRequest(storage, runId, claim); + return readRetainedAnswer(storage, claim.suspensionId); +} + +/** + * The exact journal event this run published one wait's request as. + * + * A suspension identifier is derivable and the event it was published as is + * not, so a claim names both and this requires them to describe one retained + * event of this run's. A caller that guessed an identifier is asking about a + * wait rather than claiming one. + */ +function requirePublishedRequest( + storage: OwnerStorage, + runId: string, + claim: { readonly suspensionId: string; readonly requestEventId: string }, +): void { + readRunRecord(retainedRun(storage, runId)); + const row = storage.sql + .exec("SELECT event_id, record FROM journal_events WHERE event_id = ?", claim.requestEventId) + .toArray()[0]; + if (row === undefined) { + throw new CommandError("wrong-suspension"); + } + const event = readEvent(row); + if ( + event.type !== "yield" || + event.description.type !== SUSPENSION_REQUEST || + event.description.name !== claim.suspensionId + ) { + throw new CommandError("wrong-suspension"); + } +} + +/** + * The execution this acquisition holds, or a refusal that it holds none. + * + * A socket is not an execution. What may read retained input and what may + * publish an answer is the acquisition that began an execution the run has not + * moved past — settled, recovered, or taken over by somebody else all end it. + */ +export function requireOpenExecution(storage: OwnerStorage, acquisitionId: string): string { + const held = heldExecution(storage, acquisitionId); + if (held === undefined) { + throw new CommandError("wrong-execution"); + } + const open = storage.sql + .exec( + "SELECT execution_id FROM document_executions WHERE execution_id = ? AND stopped_at IS NULL", + held, + ) + .toArray()[0]; + if (open === undefined) { + throw new CommandError("wrong-execution"); + } + return held; +} + +/** What this run retains for one wait, if it retains anything. */ +export function readRetainedAnswer( + storage: OwnerStorage, + suspensionId: string, +): OwnerRetainedAnswer | undefined { + const row = storage.sql + .exec( + `SELECT suspension_id, request_event_id, request_fingerprint, answer, state + FROM workflow_suspension_answers WHERE suspension_id = ?`, + suspensionId, + ) + .toArray()[0]; + return row === undefined ? undefined : parseRetainedAnswer(row); +} + +/** + * Retain one delivered answer, inside the caller's open transaction. + * + * This is the mutation boundary, so this is where the value is judged. The wait + * is resolved from what the run itself retained, the schema that wait published + * is the schema the value is judged by, and the gate the request selected is + * applied — all of it here, under the write, with nothing taken from the + * caller but the value and the choice. A run that moved on refuses, and the + * transaction it refuses inside wrote nothing. + * + * A compatible repeat is not a second write. The same value against the same + * wait, still answering the same retained request, re-observes the row that is + * already there; anything else disagreeing is a conflict. + */ +export function retainAnswer( + storage: OwnerStorage, + runId: string, + offered: { + readonly suspensionId: string; + readonly answer: string; + /** + * The fingerprint the credential gate read this wait's request under. + * + * The gate runs before this transaction, because the scanner is + * asynchronous and a Durable Object transaction cannot wait. What makes + * that sound is this: the value is the same bytes either way, and the + * framings the gate read are recomputed from a request identity this + * requires to still be the one retained. + */ + readonly gatedFingerprint: string; + }, + now: string, +): { readonly runId: string; readonly suspensionId: string } { + const waiting = readRetainedWait(storage, runId, offered.suspensionId); + const fingerprint = fingerprintOf(waiting); + + judgeOffered(waiting, offered.answer); + if (fingerprint !== offered.gatedFingerprint) { + // The framings the gate read named this wait's request as it was a moment + // ago. A request that changed since is one this value was neither judged + // for nor scanned against. + throw new CommandError("stale-journal"); + } + + const already = readRetainedAnswer(storage, offered.suspensionId); + if (already !== undefined) { + if ( + already.state === "pending" && + already.requestEventId === waiting.requestEventId && + already.requestFingerprint === fingerprint && + already.answer === offered.answer + ) { + // The same delivery again, after its answer was lost. One decision, one + // row, and nothing written a second time. + return { runId: waiting.runId, suspensionId: offered.suspensionId }; + } + throw new CommandError("duplicate-conflict"); + } + + storage.sql.exec( + `INSERT INTO workflow_suspension_answers + (suspension_id, request_event_id, request_fingerprint, answer, state, created_at) + VALUES (?, ?, ?, ?, 'pending', ?)`, + offered.suspensionId, + waiting.requestEventId, + fingerprint, + offered.answer, + now, + ); + return { runId: waiting.runId, suspensionId: offered.suspensionId }; +} + +/** + * Judge the offered value against the schema this wait retained. + * + * The same judgment every other boundary makes. `prepareResponseValidator` is + * what `` prepares, what local delivery judges with and what the remote + * client judges with, and it generates no code — so the verdict here is the + * verdict there, for the same schema and the same value, rather than an + * approximation of it. + * + * A schema that cannot be admitted at all is its own refusal: retaining a value + * against a schema nothing could judge would be retaining an unjudged one. A + * rejected value's refusal names neither the value nor what was wrong with it. + */ +function judgeOffered(waiting: OwnerRetainedWait, answer: string): void { + let value: Json; + let schema: Json; + try { + value = retained(JSON.parse(answer)); + schema = retained(waiting.responseSchema); + } catch { + throw new CommandError("malformed-member"); + } + let validator: ResponseValidator; + try { + validator = prepareResponseValidator("workflow answer", schema); + } catch { + throw new CommandError("unjudgeable-schema"); + } + if (validator.judge(value).length > 0) { + throw new CommandError("answer-rejected"); + } +} + +/** + * The two framings this value will be stored in, for the gate to read. + * + * The retained row and the durable event a later execution would publish — + * exactly the two the local host scans, in the framing each will have. + */ +export function answerFramings( + waiting: OwnerRetainedWait, + fingerprint: string, + answer: string, +): string[] { + let value: Json; + try { + value = retained(JSON.parse(answer)); + } catch { + throw new CommandError("malformed-member"); + } + return [ + canonicalJson({ + suspensionId: waiting.suspensionId, + requestEventId: waiting.requestEventId, + requestFingerprint: fingerprint, + answer: value, + }), + serializeDurableEvent({ + type: "yield", + coroutineId: "", + description: { + type: SUSPENSION_ANSWER, + name: waiting.suspensionId, + suspensionId: waiting.suspensionId, + }, + result: { status: "ok", value }, + }), + ]; +} + +/** + * Hold one proposal's answer events to the consumption that authorizes them. + * + * The two are one act and this is where that is enforced, before anything is + * written. A proposal appending an answer event without a consumption is + * forging durable history: nothing authorized that value, and no retained state + * moves with it. A proposal appending more than one answer event is ending more + * than one wait inside a unit of work that describes ending one. Both refuse + * whole. + * + * Ordinary journal events are not looked at. What is counted is exactly the + * events that claim to end a wait. + */ +export function requireAnswerEventsAuthorized( + events: readonly string[], + consumption: { readonly suspensionId: string } | null, +): void { + const claiming = events.filter((record) => isAnswerEvent(record)); + if (consumption === null) { + if (claiming.length > 0) { + throw new CommandError("answer-unauthorized"); + } + return; + } + if (claiming.length !== 1) { + throw new CommandError("answer-unauthorized"); + } +} + +/** + * Spend one retained answer, inside the commit that publishes it. + * + * The events this commit appends are what decides it. A consumption is admitted + * only when the one answer event it carries is this wait's, carrying exactly + * the value this owner retained — so a runner cannot publish one value and + * spend the row for another, and cannot spend a row without publishing at all. + * A row that is gone, already spent, or delivered against a different request + * refuses, and the whole commit goes with it. + */ +export function consumeRetainedAnswer( + storage: OwnerStorage, + acquisitionId: string, + consumption: { + readonly suspensionId: string; + readonly requestEventId: string; + readonly requestFingerprint: string; + }, + events: readonly string[], + now: string, +): void { + // Publishing an answer is ending a wait, which is the execution's to do. A + // socket that has begun nothing, or whose execution the run has moved past, + // spends nothing however well formed its proposal is. + requireOpenExecution(storage, acquisitionId); + const spending = readRetainedAnswer(storage, consumption.suspensionId); + if (spending === undefined || spending.state !== "pending") { + throw new CommandError("answer-unavailable"); + } + if ( + spending.requestEventId !== consumption.requestEventId || + spending.requestFingerprint !== consumption.requestFingerprint + ) { + throw new CommandError("answer-unavailable"); + } + + const published = events.filter((record) => + isAnswerFor(record, consumption.suspensionId, spending.answer), + ); + if (published.length !== 1) { + throw new CommandError("answer-unavailable"); + } + + storage.sql.exec( + `UPDATE workflow_suspension_answers SET state = 'consumed', consumed_at = ? + WHERE suspension_id = ? AND state = 'pending'`, + now, + consumption.suspensionId, + ); + // Read back rather than counted: what matters is that the row this commit + // spends is spent, and a statement's own report of how many rows it touched + // is not the same statement as the one that says what the row now is. + if (readRetainedAnswer(storage, consumption.suspensionId)?.state !== "consumed") { + throw new CommandError("answer-unavailable"); + } +} + +/** Whether one appended record claims to end a wait at all. */ +function isAnswerEvent(record: string): boolean { + const parsed = parseDurableEvent(record); + if (!parsed.ok) { + return false; + } + const event = parsed.value; + return event.type === "yield" && event.description.type === SUSPENSION_ANSWER; +} + +/** + * Whether one appended record is this wait's answer, carrying this value. + * + * Parsed rather than matched as text: what a serialization spells is not what + * it means, and the value is compared canonically so two encodings of one JSON + * value are one answer. + */ +function isAnswerFor(record: string, suspensionId: string, answer: string): boolean { + const parsed = parseDurableEvent(record); + if (!parsed.ok) { + return false; + } + const event = parsed.value; + if ( + event.type !== "yield" || + event.description.type !== SUSPENSION_ANSWER || + event.description.name !== suspensionId + ) { + return false; + } + if (event.result === undefined || event.result.status !== "ok") { + return false; + } + try { + return canonicalJson(retained(event.result.value)) === answer; + } catch { + return false; + } +} + +/** + * One retained value, held to the JSON rules storage holds every value to. + * + * The retained description and a published result are journal data: what they + * hold is whatever was written, and canonicalizing something that is not JSON + * would name a value nothing could store. + */ +function retained(value: unknown): Json { + return parseJsonValue(value, "$", () => new CommandError("malformed-member")); +} + +/** The run this owner holds, or a refusal that it holds another or none. */ +function retainedRun(storage: OwnerStorage, runId: string): Row { + if (isPristine(declaredObjects(storage)) || holdsNoRun(storage)) { + // Nothing is stored here. Delivery names a run to answer, and answering a + // run that does not exist is a fact about the run rather than damage — and + // recognizing an empty store would report it as somebody else's. + throw new CommandError("absent"); + } + recognizeObject(storage); + const row = storage.sql + .exec( + `SELECT run_id, definition, base, props, status, stop_reason_kind, stop_reason_code, + stop_reason_event_id, created_at, updated_at FROM workflow_run`, + ) + .toArray()[0]; + if (row === undefined) { + throw new CommandError("absent"); + } + if (readRunRecord(row).runId !== runId) { + throw new CommandError("wrong-run"); + } + return row; +} + +function readEvent(row: Row): DurableEvent { + const parsed = parseDurableEvent(retainedText(row, "record")); + if (!parsed.ok) { + throw new CommandError("corrupt-journal"); + } + return parsed.value; +} + +function parseRetainedAnswer(row: Row): OwnerRetainedAnswer { + const state = retainedText(row, "state"); + if (state !== "pending" && state !== "consumed") { + throw new WorkflowRecordMalformedError( + "workflow_suspension_answers.state", + "expected pending or consumed", + ); + } + return { + suspensionId: retainedText(row, "suspension_id"), + requestEventId: retainedText(row, "request_event_id"), + requestFingerprint: retainedText(row, "request_fingerprint"), + answer: retainedText(row, "answer"), + state, + }; +} + +/** + * The fingerprint of the request this wait retained. + * + * Computed from the retained description exactly as the shared contract + * computes it, so an owner and a runner that read the same wait derive the same + * name for it. + */ +export function fingerprintOf(waiting: OwnerRetainedWait): string { + return sha256Hex( + canonicalJson({ + request: retained(waiting.request), + responseSchema: retained(waiting.responseSchema), + }), + ); +} diff --git a/packages/workflow/src/cloudflare/owner-fork.ts b/packages/workflow/src/cloudflare/owner-fork.ts new file mode 100644 index 000000000..7a838df05 --- /dev/null +++ b/packages/workflow/src/cloudflare/owner-fork.ts @@ -0,0 +1,1106 @@ +/** + * Planting one source's committed prefix in a destination this owner holds. + * + * A fork arrives in two stages because a source is larger than one message may + * be. First the runner offers its parts — the roots, the inherited rows, the + * checkouts, and the content those roots name — and each part is scratch that + * belongs to the offering connection and describes nothing. Then one command + * commits them: the destination's schema, its immutable identity, the whole + * copied prefix, its Workspace closure, its lineage and its first execution all + * appear together, or the destination holds nothing at all. + * + * Nothing here trusts the parts for being staged. A staged root is held to its + * own manifest, its reference arrays are derived rather than believed, every + * piece of content it names must have been offered, and every inherited row + * must belong to a root that came with it. Staging is a way to cross, not a way + * to be believed. + */ + +import { + type DurableEvent, + parseDurableEvent, + serializeDurableEvent, +} from "@executablemd/durable-streams"; +import { + compareUtf8, + parseWorkspaceRootManifest, + WORKSPACE_ROOT_DOMAIN, + WORKSPACE_ROOT_FORMAT, +} from "../workspace/root-manifest.ts"; +import { decodeContentManifest } from "../workspace/content-manifest.ts"; +import { sha256Hex } from "../workspace/sha256.ts"; +import { bytesOf } from "./encoding.ts"; +import type { CreateWorkflowRunRequest } from "../storage/api.ts"; +import { + CommandError, + type ForkContinuationOrigin, + type ForkCounts, + type ForkOrigin, + type ForkPart, + type ForkSection, +} from "./commands.ts"; +import type { OwnerStorage } from "./storage.ts"; +import type { OwnerTransaction } from "./owner-transaction.ts"; +import { establishRun } from "./owner-open.ts"; +import { beginRun, type LifecycleRefusal } from "./owner-lifecycle.ts"; +import { conflictingFields } from "../storage/compatibility.ts"; +import { recognizeObject as recognize } from "./recognition.ts"; +import { readFrontier, validateRetainedRoot, type FrontierValue } from "./owner-reads.ts"; +import { FORK_TABLE, holdExecution, STAGING_TABLE } from "./private-schema.ts"; +import { checkoutKey, forkSelectionAnchor } from "./fork-anchor.ts"; +import { forkRunRecordEvent, isRootImportEvent } from "../journal-events.ts"; +import { encodeBase64 } from "./encoding.ts"; +import { readDocumentExecution, readRunRecord, type Row } from "../sqlite/rows.ts"; +import type { DocumentExecutionRecord } from "../storage/record.ts"; + +/** What one committed fork produced. */ +export interface ForkedValue { + readonly frontier: FrontierValue; + readonly execution: DocumentExecutionRecord; + /** Whether the destination kept a terminal state instead of running. */ + readonly replay: boolean; + /** What stale recovery closed on the way in, when it closed anything. */ + readonly recovered: DocumentExecutionRecord | null; +} + +/** A fork answer: the destination, or which immutable fields say it is another. */ +export interface ForkValue { + readonly conflict: readonly string[] | null; + /** Why the lifecycle would not continue, when it would not. */ + readonly refusal: LifecycleRefusal | null; + readonly value: ForkedValue | null; +} + +const RUN_COLUMNS = `run_id, definition, base, props, status, + stop_reason_kind, stop_reason_code, stop_reason_event_id, created_at, updated_at`; + +const MAX_PART_BYTES = 256 * 1024; +const MAX_PARTS = 8192; + +function rows(storage: OwnerStorage, sql: string, ...bindings: unknown[]): Row[] { + return storage.sql.exec(sql, ...bindings).toArray(); +} + +function text(value: unknown): string { + if (typeof value !== "string" || value === "") { + throw new CommandError("malformed-member"); + } + return value; +} + +function digest(value: unknown): string { + const candidate = text(value); + if (!/^[0-9a-f]{64}$/.test(candidate)) { + throw new CommandError("malformed-member"); + } + return candidate; +} + +function count(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new CommandError("malformed-member"); + } + return value; +} + +function list(value: unknown): unknown[] { + if (!Array.isArray(value)) { + throw new CommandError("malformed-member"); + } + return value; +} + +function members(value: unknown, names: readonly string[]): Map { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new CommandError("malformed-member"); + } + const found = new Map(Object.entries(value)); + if (found.size !== names.length || names.some((name) => !found.has(name))) { + throw new CommandError("unknown-member"); + } + return found; +} + +/** + * Keep one offered part, bounded and in its place. + * + * Offering the same position twice is a conflict rather than a replacement: a + * transfer that rewrote its own members would be one nobody could describe. + */ +export function stageForkPart( + storage: OwnerStorage, + acquisitionId: string, + part: ForkPart, +): { staged: number } { + const encoded = JSON.stringify(part.part); + const size = new TextEncoder().encode(encoded).length; + if (size > MAX_PART_BYTES) { + throw new CommandError("too-large"); + } + const held = rows( + storage, + `SELECT count(*) AS parts FROM ${FORK_TABLE} WHERE acquisition_id = ?`, + acquisitionId, + )[0]; + if (count(held?.["parts"]) >= MAX_PARTS) { + throw new CommandError("capacity"); + } + const already = rows( + storage, + `SELECT part FROM ${FORK_TABLE} + WHERE acquisition_id = ? AND section = ? AND position = ?`, + acquisitionId, + part.section, + part.position, + )[0]; + if (already !== undefined) { + if (already["part"] !== encoded) { + throw new CommandError("duplicate-conflict"); + } + return { staged: count(held?.["parts"]) }; + } + storage.sql.exec( + `INSERT INTO ${FORK_TABLE} (acquisition_id, section, position, part, part_bytes) + VALUES (?, ?, ?, ?, ?)`, + acquisitionId, + part.section, + part.position, + encoded, + size, + ); + return { staged: count(held?.["parts"]) + 1 }; +} + +/** Whether this transfer describes anything at all. */ +function expectsTransfer(counts: ForkCounts): boolean { + return counts.inherited + counts.roots + counts.manifests + counts.blobs + counts.checkouts > 0; +} + +/** Whether this acquisition has offered any part of a fork. */ +function offered(storage: OwnerStorage, acquisitionId: string): boolean { + // A store with no scratch table at all has been offered nothing, which is + // the same answer: that table is created by the command that offers a part. + if ( + rows(storage, "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?", FORK_TABLE) + .length === 0 + ) { + return false; + } + return ( + rows( + storage, + `SELECT 1 AS held FROM ${FORK_TABLE} WHERE acquisition_id = ? LIMIT 1`, + acquisitionId, + ).length > 0 + ); +} + +/** Every part of one section, in the order it was offered, with no gaps. */ +function section( + storage: OwnerStorage, + acquisitionId: string, + name: ForkSection, + expected: number, +): Record[] { + const held = rows( + storage, + `SELECT position, part FROM ${FORK_TABLE} + WHERE acquisition_id = ? AND section = ? ORDER BY position`, + acquisitionId, + name, + ); + if (held.length !== expected) { + throw new CommandError("malformed-member"); + } + return held.map((row, at) => { + if (count(row["position"]) !== at) { + // A gap, so the section is not the one the counts describe. + throw new CommandError("malformed-member"); + } + const parsed: unknown = JSON.parse(text(row["part"])); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CommandError("malformed-member"); + } + return Object.fromEntries(Object.entries(parsed)); + }); +} + +/** Content this acquisition offered, by the identity its bytes produce. */ +function staged( + storage: OwnerStorage, + acquisitionId: string, + kind: "manifest" | "blob", + hash: string, +): Uint8Array { + const row = rows( + storage, + `SELECT bytes FROM ${STAGING_TABLE} WHERE acquisition_id = ? AND kind = ? AND digest = ?`, + acquisitionId, + kind, + hash, + )[0]; + if (row === undefined) { + // Named by a root but never offered: the transfer is not the closure it + // claims to be. + throw new CommandError("malformed-member"); + } + const bytes = bytesOf(row["bytes"]); + if (sha256Hex(bytes) !== hash) { + throw new CommandError("malformed-member"); + } + return bytes; +} + +interface StagedRoot { + readonly rootId: string; + readonly formatVersion: number; + readonly manifest: string; + readonly manifestHashes: readonly string[]; + readonly blobHashes: readonly string[]; +} + +function parseRoot(part: Record): StagedRoot { + const found = members(part, [ + "rootId", + "formatVersion", + "manifest", + "manifestHashes", + "blobHashes", + ]); + if (found.get("formatVersion") !== WORKSPACE_ROOT_FORMAT) { + throw new CommandError("malformed-member"); + } + const manifest = text(found.get("manifest")); + const rootId = digest(found.get("rootId")); + if (sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${manifest}`) !== rootId) { + throw new CommandError("malformed-member"); + } + return { + rootId, + formatVersion: WORKSPACE_ROOT_FORMAT, + manifest, + manifestHashes: list(found.get("manifestHashes")).map((value) => digest(value)), + blobHashes: list(found.get("blobHashes")).map((value) => digest(value)), + }; +} + +function sameOrder(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, at) => value === right[at]); +} + +/** + * Retain one root's content and the root itself, proving the closure as it goes. + * + * The reference arrays are derived from the root's own manifest and compared + * element for element, which is the same comparison a destination makes when it + * reads the root back. A root whose arrays are the right set in the wrong order + * is refused here rather than becoming unreadable later. + */ +function retainRoot( + storage: OwnerStorage, + acquisitionId: string, + root: StagedRoot, + watermarks: ContentWatermarks, +): void { + const parsed = parseWorkspaceRootManifest(root.manifest, () => { + throw new CommandError("malformed-member"); + }); + const named = new Set(); + const sizes = new Map(); + for (const entry of parsed.entries) { + if (entry.kind === "file") { + named.add(entry.manifest); + const already = sizes.get(entry.manifest); + if (already !== undefined && already !== entry.size) { + throw new CommandError("malformed-member"); + } + sizes.set(entry.manifest, entry.size); + } + } + const blobs = new Map(); + const manifests = new Map(); + for (const hash of named) { + const bytes = staged(storage, acquisitionId, "manifest", hash); + const content = decodeContentManifest(bytes, () => { + throw new CommandError("malformed-member"); + }); + if (sizes.get(hash) !== content.size) { + throw new CommandError("malformed-member"); + } + manifests.set(hash, bytes); + for (const chunk of content.chunks) { + const seen = blobs.get(chunk.hash); + if (seen !== undefined && seen !== chunk.size) { + throw new CommandError("malformed-member"); + } + blobs.set(chunk.hash, chunk.size); + } + } + if ( + !sameOrder([...named].toSorted(compareUtf8), root.manifestHashes) || + !sameOrder([...blobs.keys()].toSorted(compareUtf8), root.blobHashes) + ) { + throw new CommandError("malformed-member"); + } + + for (const [hash, size] of blobs) { + const bytes = staged(storage, acquisitionId, "blob", hash); + if (bytes.length !== size) { + throw new CommandError("malformed-member"); + } + const key = hexBytes(hash); + storage.sql.exec( + "INSERT INTO vfs_blobs (hash, size, last_seen) VALUES (?, ?, ?) ON CONFLICT(hash) DO NOTHING", + key, + size, + // The watermark the source retained beside these bytes. A digest stands + // for the bytes and for their size; it does not stand for this. + watermarkOf(watermarks.blob, hash), + ); + storage.sql.exec( + "INSERT INTO vfs_blob_bytes (hash, bytes) VALUES (?, ?) ON CONFLICT(hash) DO NOTHING", + key, + bytes, + ); + } + for (const [hash, bytes] of manifests) { + storage.sql.exec( + `INSERT INTO vfs_manifests (hash, size, encoded, last_seen) VALUES (?, ?, ?, ?) + ON CONFLICT(hash) DO NOTHING`, + hexBytes(hash), + decodeContentManifest(bytes, () => { + throw new CommandError("malformed-member"); + }).size, + bytes, + watermarkOf(watermarks.manifest, hash), + ); + } + + const existing = rows( + storage, + "SELECT manifest FROM workspace_roots WHERE root_id = ?", + root.rootId, + )[0]; + if (existing === undefined) { + storage.sql.exec( + "INSERT INTO workspace_roots (root_id, format_version, manifest) VALUES (?, ?, ?)", + root.rootId, + WORKSPACE_ROOT_FORMAT, + root.manifest, + ); + for (const hash of root.manifestHashes) { + storage.sql.exec( + "INSERT INTO workspace_root_manifest_refs (root_id, manifest_hash) VALUES (?, ?)", + root.rootId, + hexBytes(hash), + ); + } + for (const hash of root.blobHashes) { + storage.sql.exec( + "INSERT INTO workspace_root_blob_refs (root_id, blob_hash) VALUES (?, ?)", + root.rootId, + hexBytes(hash), + ); + } + } else if (existing["manifest"] !== root.manifest) { + throw new CommandError("duplicate-conflict"); + } + validateRetainedRoot(storage, root.rootId); +} + +/** What the source retained beside one piece of content. */ +function watermarkOf(watermarks: ReadonlyMap, hash: string): number { + const found = watermarks.get(hash); + if (found === undefined) { + // Every piece a root names was described by the transfer, or the anchor + // would not have matched. Reaching here would mean it did. + throw new CommandError("malformed-member"); + } + return found; +} + +function hexBytes(digestHex: string): Uint8Array { + const bytes = new Uint8Array(32); + for (let at = 0; at < 32; at += 1) { + bytes[at] = Number.parseInt(digestHex.slice(at * 2, at * 2 + 2), 16); + } + return bytes; +} + +/** One inherited row, exactly as the source retained it. */ +function writeInherited( + storage: OwnerStorage, + part: Record, + sourceRunId: string, + carried: ReadonlySet, +): void { + const found = members(part, ["eventId", "record", "workspaceRootId"]); + const eventId = text(found.get("eventId")); + const record = text(found.get("record")); + const rootId = digest(found.get("workspaceRootId")); + if (!carried.has(rootId)) { + throw new CommandError("malformed-member"); + } + const parsed = parseDurableEvent(record); + if (!parsed.ok) { + throw new CommandError("corrupt-journal"); + } + storage.sql.exec( + "INSERT INTO journal_events (event_id, record, workspace_root_id) VALUES (?, ?, ?)", + eventId, + record, + rootId, + ); + storage.sql.exec( + `INSERT INTO journal_event_provenance (event_id, source_run_id, source_event_id) + VALUES (?, ?, ?)`, + eventId, + sourceRunId, + eventId, + ); +} + +/** One inherited checkout, in the directory the checkpoint's Workspace holds. */ +function writeCheckout( + storage: OwnerStorage, + part: Record, + directories: ReadonlySet, + repositories: Set, +): void { + const kind = part["kind"]; + if (kind === "repository") { + const found = members(part, [ + "kind", + "name", + "locator", + "locatorFingerprint", + "requestedBase", + "creationCommit", + "primaryBranch", + "objectFormat", + "checkoutPath", + ]); + const path = text(found.get("checkoutPath")); + if (!directories.has(path)) { + throw new CommandError("malformed-member"); + } + const format = found.get("objectFormat"); + if (format !== "sha1" && format !== "sha256") { + throw new CommandError("malformed-member"); + } + const name = text(found.get("name")); + storage.sql.exec( + `INSERT INTO workspace_repositories (name, locator, locator_fingerprint, requested_base, + creation_commit, primary_branch, object_format, checkout_path) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + name, + text(found.get("locator")), + digest(found.get("locatorFingerprint")), + found.get("requestedBase") === null ? null : text(found.get("requestedBase")), + text(found.get("creationCommit")), + text(found.get("primaryBranch")), + format, + path, + ); + repositories.add(name); + return; + } + const found = members(part, [ + "kind", + "repositoryName", + "name", + "requestedBranch", + "requestedBase", + "creationCommit", + "checkoutPath", + ]); + if (kind !== "worktree") { + throw new CommandError("malformed-member"); + } + const path = text(found.get("checkoutPath")); + if (!directories.has(path)) { + throw new CommandError("malformed-member"); + } + const repository = text(found.get("repositoryName")); + if (!repositories.has(repository)) { + // A Worktree of a Repository that did not come with it belongs to nothing. + throw new CommandError("malformed-member"); + } + storage.sql.exec( + `INSERT INTO workspace_worktrees (repository_name, name, requested_branch, requested_base, + creation_commit, checkout_path) + VALUES (?, ?, ?, ?, ?, ?)`, + repository, + text(found.get("name")), + text(found.get("requestedBranch")), + found.get("requestedBase") === null ? null : text(found.get("requestedBase")), + text(found.get("creationCommit")), + path, + ); +} + +/** + * Rebuild the source selection out of the parts that were offered. + * + * Every value is validated as it is read — roots against their own manifests, + * content against its digests, rows against the roots they name — and then + * described in the shared logical shape and hashed by the shared rule. The + * digest that comes out is comparable with the one the source computed only + * because neither side spells the selection for itself. + */ +function reconstruct( + storage: OwnerStorage, + acquisitionId: string, + input: { readonly origin: ForkOrigin; readonly counts: ForkCounts }, +): { anchor: string; rootIds: Set; watermarks: ContentWatermarks } { + const roots = section(storage, acquisitionId, "roots", input.counts.roots).map((part) => + parseRoot(part), + ); + const rootIds = new Set(roots.map((root) => root.rootId)); + if (rootIds.size !== roots.length) { + throw new CommandError("malformed-member"); + } + for (const rootId of [ + input.origin.checkpointWorkspaceRootId, + input.origin.runRecordWorkspaceRootId, + input.origin.rootImportWorkspaceRootId, + ]) { + if (!rootIds.has(rootId)) { + throw new CommandError("malformed-member"); + } + } + + const manifests = section(storage, acquisitionId, "manifests", input.counts.manifests).map( + (part) => parseContentMetadata(part), + ); + const blobs = section(storage, acquisitionId, "blobs", input.counts.blobs).map((part) => + parseContentMetadata(part), + ); + const inherited = section(storage, acquisitionId, "inherited", input.counts.inherited).map( + (part) => parseInherited(part, rootIds), + ); + const checkouts = section(storage, acquisitionId, "checkouts", input.counts.checkouts).map( + (part) => parseCheckoutPart(part), + ); + + // Two namespaces, never one. A digest identifies bytes, and the same bytes + // can be one manifest's encoding and another manifest's chunk: the two tables + // retain their own watermarks, so one role must not answer for the other. + const watermarks = { + manifest: metadataOf(manifests), + blob: metadataOf(blobs), + }; + + return { + rootIds, + watermarks, + anchor: forkSelectionAnchor({ + checkpointEventId: input.origin.checkpointEventId, + checkpointWorkspaceRootId: input.origin.checkpointWorkspaceRootId, + runRecordWorkspaceRootId: input.origin.runRecordWorkspaceRootId, + rootImportWorkspaceRootId: input.origin.rootImportWorkspaceRootId, + inherited, + roots, + manifests: manifests.map((piece) => ({ + hash: piece.hash, + size: piece.size, + lastSeen: piece.lastSeen, + // The bytes the source hashed, read back out of what was staged rather + // than taken on the word of the part that described them. + encoded: encodeBase64(staged(storage, acquisitionId, "manifest", piece.hash)), + })), + blobs, + checkouts, + }), + }; +} + +/** The watermarks one transfer carried, kept by the role each belongs to. */ +export interface ContentWatermarks { + readonly manifest: ReadonlyMap; + readonly blob: ReadonlyMap; +} + +/** One section's metadata, refusing a digest it names twice. */ +function metadataOf( + pieces: readonly { hash: string; size: number; lastSeen: number }[], +): Map { + const held = new Map(); + for (const piece of pieces) { + if (held.has(piece.hash)) { + // The same identity described twice in one role. Which description is + // the transfer's is not a question this can answer. + throw new CommandError("malformed-member"); + } + held.set(piece.hash, piece.lastSeen); + } + return held; +} + +/** One manifest's or blob's retained metadata, as a part carries it. */ +function parseContentMetadata(part: Record): { + hash: string; + size: number; + lastSeen: number; +} { + const found = members(part, ["hash", "size", "lastSeen"]); + return { + hash: digest(found.get("hash")), + size: count(found.get("size")), + lastSeen: count(found.get("lastSeen")), + }; +} + +/** One inherited row, as a part carries it. */ +function parseInherited( + part: Record, + carried: ReadonlySet, +): { eventId: string; record: string; workspaceRootId: string } { + const found = members(part, ["eventId", "record", "workspaceRootId"]); + const rootId = digest(found.get("workspaceRootId")); + if (!carried.has(rootId)) { + throw new CommandError("malformed-member"); + } + const record = text(found.get("record")); + if (!parseDurableEvent(record).ok) { + throw new CommandError("corrupt-journal"); + } + return { eventId: text(found.get("eventId")), record, workspaceRootId: rootId }; +} + +/** One checkout, as the selection describes it: its key and its record. */ +function parseCheckoutPart(part: Record): { + key: string; + value: Record; +} { + const kind = part["kind"]; + if (kind === "repository") { + return { key: checkoutKey(["repository", text(part["name"])]), value: { ...part } }; + } + if (kind !== "worktree") { + throw new CommandError("malformed-member"); + } + return { + key: checkoutKey(["worktree", text(part["repositoryName"]), text(part["name"])]), + value: { ...part }, + }; +} + +/** + * The two records this fork writes for itself, held to what they must be. + * + * A generic parseable event is not enough where the command promises one + * specific role: the run record is the canonical event this destination's own + * identity, base and pinned commit imply, and the root import is a root import + * written against a root the transfer carried. + */ +function requireHeadRecords( + input: { + readonly runId: string; + readonly creation: CreateWorkflowRunRequest; + readonly origin: ForkOrigin; + readonly runRecord: DurableEvent; + readonly rootImport: DurableEvent; + }, + carried: ReadonlySet, +): void { + const expected = forkRunRecordEvent({ + runId: input.runId, + base: input.creation.base, + pinnedCommit: input.creation.definition.objectId, + }); + if (serializeDurableEvent(input.runRecord) !== serializeDurableEvent(expected)) { + throw new CommandError("malformed-member"); + } + if (!isRootImportEvent(input.rootImport)) { + throw new CommandError("malformed-member"); + } + if (!carried.has(input.origin.rootImportWorkspaceRootId)) { + throw new CommandError("malformed-member"); + } +} + +/** + * Continue a destination that already holds this fork, without its source. + * + * A committed fork is independent: it holds its own prefix, its own content and + * its own Workspace, and nothing about continuing it needs the run it was + * copied from. So this asks only what the destination itself retains — its + * immutable identity, its lineage, and the two head records it wrote for + * itself — and then takes the run up through the same recovery and admission a + * resume uses. + * + * Absent when nothing is here. Making a fork needs a source; this is for when + * one was already made. + */ +export function continueFork( + storage: OwnerStorage, + transaction: OwnerTransaction, + acquisitionId: string, + input: { + readonly runId: string; + readonly creation: CreateWorkflowRunRequest; + readonly origin: ForkContinuationOrigin; + readonly runRecord: DurableEvent; + readonly rootImport: DurableEvent; + readonly executionId: string; + }, + now: () => string, +): ForkValue { + if ( + rows(storage, "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'workflow_run'") + .length === 0 + ) { + // Nothing here to continue. Copying a source is a different command. + throw new CommandError("absent"); + } + recognize(storage); + const stored = rows(storage, `SELECT ${RUN_COLUMNS} FROM workflow_run`)[0]; + if (stored === undefined) { + throw new CommandError("absent"); + } + const record = readRunRecord(stored); + if (record.runId !== input.runId) { + throw new CommandError("wrong-run"); + } + const differing = conflictingFields(record, input.creation); + if (differing.length > 0) { + return { conflict: differing, refusal: null, value: null }; + } + + const lineage = rows( + storage, + `SELECT source_run_id, checkpoint_event_id, checkpoint_workspace_root_id, + selection_anchor, run_record_root_id, root_import_root_id + FROM workflow_fork_lineage WHERE id = 1`, + )[0]; + if ( + lineage === undefined || + lineage["source_run_id"] !== input.origin.sourceRunId || + lineage["checkpoint_event_id"] !== input.origin.checkpointEventId + ) { + // Not a fork at all, or a fork of somewhere else. Either way this request + // is not describing the run that is here. + return { conflict: ["lineage"], refusal: null, value: null }; + } + + // The two records this fork wrote for itself, as it retains them. A request + // carrying a different root import is a different fork, whatever else agrees. + const heads = rows( + storage, + "SELECT record, workspace_root_id FROM journal_events ORDER BY sequence LIMIT 2", + ); + const expected = serializeDurableEvent( + forkRunRecordEvent({ + runId: input.runId, + base: input.creation.base, + pinnedCommit: input.creation.definition.objectId, + }), + ); + if ( + heads.length !== 2 || + serializeDurableEvent(input.runRecord) !== expected || + heads[0]?.["record"] !== expected || + heads[1]?.["record"] !== serializeDurableEvent(input.rootImport) || + // The exact roots these rows were committed against, as this fork's own + // lineage retained them — not merely roots this store happens to hold. A + // head reassociated to another valid root is a different fork's head. + lineage["run_record_root_id"] === null || + lineage["root_import_root_id"] === null || + heads[0]?.["workspace_root_id"] !== lineage["run_record_root_id"] || + heads[1]?.["workspace_root_id"] !== lineage["root_import_root_id"] + ) { + return { conflict: ["lineage"], refusal: null, value: null }; + } + + const resumed = beginRun( + storage, + transaction, + acquisitionId, + input.runId, + "resume", + null, + null, + input.executionId, + now, + ); + if (resumed.conflict !== null) { + return { conflict: resumed.conflict, refusal: null, value: null }; + } + if (resumed.refusal !== null) { + return { conflict: null, refusal: resumed.refusal, value: null }; + } + const begun = resumed.value; + if (begun === null) { + throw new CommandError("malformed-member"); + } + return { + conflict: null, + refusal: null, + value: { + frontier: begun.frontier, + execution: begun.execution, + replay: begun.replay, + recovered: begun.recovered, + }, + }; +} + +/** + * Commit one fork: the destination and everything it inherited, together. + * + * Runs inside the caller's transaction. What it writes is what the schema's own + * references require, in that order: content, then the roots that name it, then + * the Workspace pointer those roots are restored into, then the checkouts, then + * the journal rows that name the roots, then the lineage, and last the fork's + * own first execution. A failure anywhere rolls the whole thing back, so the + * destination is either absent or complete. + */ +export function commitFork( + storage: OwnerStorage, + transaction: OwnerTransaction, + acquisitionId: string, + input: { + 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; + }, + mintEventId: () => string, + now: () => string, +): ForkValue { + // Whether this destination already holds a run decides what committing + // means: making one, or confirming the one that is here is this fork. + const fresh = + rows(storage, "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'workflow_run'") + .length === 0; + + // Before anything semantic: prove that what was offered is the selection the + // source anchored. Everything below is derived from the staged parts, + // described in the shared shape and hashed by the shared rule, then compared. + // A transfer that is not that selection stops here, with the destination + // holding no run. + if (fresh && expectsTransfer(input.counts) && !offered(storage, acquisitionId)) { + // Nothing here, and nothing offered: the commit this command names never + // happened, and the staging it would have used went with the connection + // that made it. Said as its own answer, because it is the one failure a + // caller responds to by copying the source again. + throw new CommandError("needs-transfer"); + } + const staged = fresh ? reconstruct(storage, acquisitionId, input) : undefined; + const watermarks: ContentWatermarks = staged?.watermarks ?? { + manifest: new Map(), + blob: new Map(), + }; + if (staged !== undefined) { + if (staged.anchor !== input.origin.anchor) { + throw new CommandError("stale-journal"); + } + // The two records this fork writes for itself are the fork's own, not any + // parseable event. + requireHeadRecords(input, staged.rootIds); + } + + const conflict = establishRun( + storage, + transaction, + input.runId, + input.creation, + now, + input.retrieval ?? undefined, + ); + if (conflict !== null) { + return { conflict, refusal: null, value: null }; + } + if (!fresh) { + // A destination that already exists is the same fork only when it came + // from the same place. Its identity was compared above; this is its + // lineage. + const lineage = rows( + storage, + `SELECT source_run_id, checkpoint_event_id, checkpoint_workspace_root_id, + selection_anchor, run_record_root_id, root_import_root_id + FROM workflow_fork_lineage WHERE id = 1`, + )[0]; + if ( + lineage === undefined || + lineage["source_run_id"] !== input.origin.sourceRunId || + lineage["checkpoint_event_id"] !== input.origin.checkpointEventId || + lineage["checkpoint_workspace_root_id"] !== input.origin.checkpointWorkspaceRootId + ) { + return { conflict: ["lineage"], refusal: null, value: null }; + } + if (lineage["selection_anchor"] !== input.origin.anchor) { + // The same source and checkpoint, copied out of a different committed + // state. That is a different fork wearing this one's identity. + return { conflict: ["lineage"], refusal: null, value: null }; + } + // The fork is already here, so this is not making one: it is taking a run + // up again. It goes through the same recovery and admission a resume does, + // reconciling what the previous executor left before beginning exactly one + // replacement, rather than inserting a second open execution beside it. + const resumed = beginRun( + storage, + transaction, + acquisitionId, + input.runId, + "resume", + null, + null, + input.executionId, + now, + ); + if (resumed.conflict !== null) { + return { conflict: resumed.conflict, refusal: null, value: null }; + } + if (resumed.refusal !== null) { + return { conflict: null, refusal: resumed.refusal, value: null }; + } + const begun = resumed.value; + if (begun === null) { + throw new CommandError("malformed-member"); + } + return { + conflict: null, + refusal: null, + // Exactly what the shared policy decided, including a terminal run that + // replays and whatever recovery closed on the way in. + value: { + frontier: begun.frontier, + execution: begun.execution, + replay: begun.replay, + recovered: begun.recovered, + }, + }; + } + + const roots = section(storage, acquisitionId, "roots", input.counts.roots).map((part) => + parseRoot(part), + ); + const carried = new Set(roots.map((root) => root.rootId)); + for (const rootId of [ + input.origin.checkpointWorkspaceRootId, + input.origin.runRecordWorkspaceRootId, + input.origin.rootImportWorkspaceRootId, + ]) { + if (!carried.has(rootId)) { + throw new CommandError("malformed-member"); + } + } + for (const root of roots) { + retainRoot(storage, acquisitionId, root, watermarks); + } + + const checkpoint = roots.find((root) => root.rootId === input.origin.checkpointWorkspaceRootId); + if (checkpoint === undefined) { + throw new CommandError("malformed-member"); + } + const directories = new Set(); + for (const entry of parseWorkspaceRootManifest(checkpoint.manifest, () => { + throw new CommandError("malformed-member"); + }).entries) { + if (entry.kind === "directory") { + directories.add(entry.path); + } + } + // The fork's live Workspace is the checkpoint's, named before a single + // journal row names a root. + storage.sql.exec( + "UPDATE workspace_state SET current_root_id = ? WHERE singleton_id = 1", + input.origin.checkpointWorkspaceRootId, + ); + + const repositories = new Set(); + for (const part of section(storage, acquisitionId, "checkouts", input.counts.checkouts)) { + writeCheckout(storage, part, directories, repositories); + } + + // The two records the fork writes for itself stand where the source's stood, + // against the same roots, under identities of this run's own. + storage.sql.exec( + "INSERT INTO journal_events (event_id, record, workspace_root_id) VALUES (?, ?, ?)", + mintEventId(), + serializeDurableEvent(input.runRecord), + input.origin.runRecordWorkspaceRootId, + ); + storage.sql.exec( + "INSERT INTO journal_events (event_id, record, workspace_root_id) VALUES (?, ?, ?)", + mintEventId(), + serializeDurableEvent(input.rootImport), + input.origin.rootImportWorkspaceRootId, + ); + for (const part of section(storage, acquisitionId, "inherited", input.counts.inherited)) { + writeInherited(storage, part, input.origin.sourceRunId, carried); + } + + storage.sql.exec( + `INSERT INTO workflow_fork_lineage + (id, source_run_id, checkpoint_event_id, checkpoint_workspace_root_id, + selection_anchor, run_record_root_id, root_import_root_id, created_at) + VALUES (1, ?, ?, ?, ?, ?, ?, ?)`, + input.origin.sourceRunId, + input.origin.checkpointEventId, + input.origin.checkpointWorkspaceRootId, + // The copy identity and the two head associations, retained with the + // lineage: continuing this fork later is then checkable here, without + // reading the source at all. + input.origin.anchor, + input.origin.runRecordWorkspaceRootId, + input.origin.rootImportWorkspaceRootId, + now(), + ); + + return { conflict: null, refusal: null, value: begunOn(storage, acquisitionId, input, now) }; +} + +/** The fork's first execution, begun by the acquisition that committed it. */ +function begunOn( + storage: OwnerStorage, + acquisitionId: string, + input: { readonly runId: string; readonly executionId: string }, + now: () => string, +): ForkedValue { + storage.sql.exec( + "INSERT INTO document_executions (execution_id, started_at) VALUES (?, ?)", + input.executionId, + now(), + ); + holdExecution(storage, acquisitionId, input.executionId); + storage.sql.exec( + "UPDATE workflow_run SET status = 'running', updated_at = ? WHERE id = 1", + now(), + ); + const row = rows( + storage, + `SELECT execution_id, started_at, stopped_at, stop_status, + stop_reason_kind, stop_reason_code, stop_reason_event_id + FROM document_executions WHERE execution_id = ?`, + input.executionId, + )[0]; + if (row === undefined) { + throw new CommandError("malformed-member"); + } + return { + frontier: readFrontier(storage, input.runId), + execution: readDocumentExecution(row), + // A fork this transaction just created runs; there was nothing here to + // replay and nothing to recover. + replay: false, + recovered: null, + }; +} + +/** What a destination already holds, when a retry finds one. */ +export function retainedFork(storage: OwnerStorage, runId: string): boolean { + const row = rows(storage, "SELECT run_id FROM workflow_run")[0]; + return row !== undefined && readRunRecord(row).runId === runId; +} + +/** Discard this acquisition's offered parts once they have been adopted. */ +export function discardForkParts(storage: OwnerStorage, acquisitionId: string): void { + storage.sql.exec(`DELETE FROM ${FORK_TABLE} WHERE acquisition_id = ?`, acquisitionId); +} diff --git a/packages/workflow/src/cloudflare/owner-gate.ts b/packages/workflow/src/cloudflare/owner-gate.ts new file mode 100644 index 000000000..b22eba82f --- /dev/null +++ b/packages/workflow/src/cloudflare/owner-gate.ts @@ -0,0 +1,48 @@ +/** + * The credential gate a delivered answer crosses, at the owner. + * + * The same gate durable journal persistence is written through — the scanner + * the local host runs, not a summary of it. It runs here because the settled + * contract is that the retained row and the event it may become cross that + * exact gate before either exists, and an authenticated caller reaching this + * owner directly must not be able to decide which checks happened. + * + * It runs outside the transaction because it cannot run inside one: the scanner + * is asynchronous and a Durable Object transaction has to finish before it can + * commit. That is sound rather than convenient — the framings it reads are + * built from the value in the request, which does not change, and from a wait + * identity the transaction requires to still be the one retained before it + * writes anything. + * + * A scanner that could not run at all is a refusal. What must not happen is a + * value entering retained state because the gate failed to say no. + */ + +import type { Operation } from "effection"; +import { createSecretScanner } from "@executablemd/core/secrets"; +import type { SecretFinding } from "@executablemd/core/secrets"; +import { CommandError } from "./commands.ts"; + +/** + * Read every framing, and refuse the value if any of them is a credential. + * + * The scanner is created for this delivery and reclaimed with it, so its + * fingerprints mean nothing outside this call. Nothing about what was matched + * travels: the refusal is one category, and a diagnostic quoting the value or + * the match would publish exactly what the gate exists to keep out. + */ +export function* crossSecretGate(framings: readonly string[]): Operation { + const scanner = createSecretScanner(); + for (const framing of framings) { + let found: SecretFinding[]; + try { + found = yield* scanner.scan(framing); + } catch { + // A gate that could not read this value has not passed it. + throw new CommandError("credential-detected"); + } + if (found.length > 0) { + throw new CommandError("credential-detected"); + } + } +} diff --git a/packages/workflow/src/cloudflare/owner-lifecycle.ts b/packages/workflow/src/cloudflare/owner-lifecycle.ts new file mode 100644 index 000000000..89b000697 --- /dev/null +++ b/packages/workflow/src/cloudflare/owner-lifecycle.ts @@ -0,0 +1,427 @@ +/** + * The run's lifecycle, as its owner keeps it. + * + * Beginning, settling and cancelling are the three moments a run's own state + * changes, and each is one owner transaction: what a dead executor's unfinished + * execution became, whether this caller may continue, and the execution this + * caller began all commit together or not at all. Splitting them would leave a + * recovery published that a refusal then had to take back, or a window where + * this executor's own execution looks like somebody else's leftovers. + * + * The decisions are not made here. `lifecycle/policy.ts` says what an + * unfinished execution becomes, whether an action is admitted and what + * beginning does; this module is where those conclusions meet rows. Both hosts + * reach them through the same functions, so a run means the same thing + * wherever it is stored. + */ + +import { + admissionRefusal, + beginDecision, + closingOutcome, + rootOutcome, + terminal, +} from "../lifecycle/policy.ts"; +import { conflictingFields } from "../storage/compatibility.ts"; +import { canonicalJson } from "../storage/record.ts"; +import { definitionToJson } from "../storage/definition.ts"; +import type { + DocumentExecutionCompletion, + DocumentExecutionRecord, + WorkflowRunRecord, + WorkflowRunStatus, +} from "../storage/record.ts"; +import type { JournalEntry } from "../storage/api.ts"; +import type { CreateWorkflowRunRequest } from "../storage/api.ts"; +import { parseDurableEvent } from "@executablemd/durable-streams"; +import { readDocumentExecution, readRunRecord, type Row } from "../sqlite/rows.ts"; +import { CommandError } from "./commands.ts"; +import type { OwnerStorage } from "./storage.ts"; +import type { OwnerTransaction } from "./owner-transaction.ts"; +import { establishRun } from "./owner-open.ts"; +import { readFrontier, type FrontierValue } from "./owner-reads.ts"; +import { heldExecution, holdExecution, releaseExecution } from "./private-schema.ts"; + +const RUN_COLUMNS = `run_id, definition, base, props, status, + stop_reason_kind, stop_reason_code, stop_reason_event_id, created_at, updated_at`; + +const EXECUTION_COLUMNS = `execution_id, started_at, stopped_at, stop_status, + stop_reason_kind, stop_reason_code, stop_reason_event_id`; + +/** + * Why a lifecycle transition would not proceed. + * + * A closed set of categories rather than refusal spellings, because each is a + * fact about the run that a caller acts on, and each maps to an error the + * provider-neutral vocabulary already has. What the run holds — its props, its + * definition, its history — never travels with one. + */ +export type LifecycleRefusal = + /** Resume reached a run that failed. */ + | "resume-failed" + /** Resume or start reached a run that was cancelled. */ + | "cancelled" + /** Cancellation reached a run whose outcome already won. */ + | "terminal" + /** The run's own root recorded a result this build cannot read. */ + | "damaged-terminal"; + +/** What one begin committed, as the runner is allowed to know it. */ +export interface BegunValue { + readonly frontier: FrontierValue; + readonly execution: DocumentExecutionRecord; + readonly replay: boolean; + /** What recovery closed on the way in, when it closed anything. */ + readonly recovered: DocumentExecutionRecord | null; +} + +/** + * One lifecycle answer: what it did, or why it would not. + * + * Exactly one member is present. A refusal is an answer rather than a protocol + * refusal because it carries which condition applied, and because the recovery + * it may have committed on the way in stands either way. + */ +export interface LifecycleValue { + readonly conflict: readonly string[] | null; + readonly refusal: LifecycleRefusal | null; + readonly value: T | null; +} + +function rows(storage: OwnerStorage, sql: string, ...bindings: unknown[]): Row[] { + return storage.sql.exec(sql, ...bindings).toArray(); +} + +function storedRun(storage: OwnerStorage): WorkflowRunRecord { + const row = rows(storage, `SELECT ${RUN_COLUMNS} FROM workflow_run`)[0]; + if (row === undefined) { + throw new CommandError("absent"); + } + return readRunRecord(row); +} + +function unfinished(storage: OwnerStorage): DocumentExecutionRecord[] { + return rows( + storage, + `SELECT ${EXECUTION_COLUMNS} FROM document_executions + WHERE stopped_at IS NULL ORDER BY sequence`, + ).map((row) => readDocumentExecution(row)); +} + +function journalEntries(storage: OwnerStorage): JournalEntry[] { + return rows( + storage, + "SELECT event_id, record, workspace_root_id FROM journal_events ORDER BY sequence", + ).map((row) => { + const parsed = parseDurableEvent(String(row["record"])); + if (!parsed.ok) { + // Retained history this owner cannot read. Deciding an outcome from a + // journal it cannot parse would be deciding from nothing. + throw new CommandError("corrupt-journal"); + } + return { + eventId: String(row["event_id"]), + event: parsed.value, + workspaceRootId: String(row["workspace_root_id"]), + }; + }); +} + +function readExecution(storage: OwnerStorage, executionId: string): DocumentExecutionRecord { + const row = rows( + storage, + `SELECT ${EXECUTION_COLUMNS} FROM document_executions WHERE execution_id = ?`, + executionId, + )[0]; + if (row === undefined) { + throw new CommandError("malformed-member"); + } + return readDocumentExecution(row); +} + +function finish( + storage: OwnerStorage, + completion: { + executionId: string; + status: WorkflowRunStatus; + reason: DocumentExecutionCompletion["reason"]; + }, + now: () => string, +): void { + const reason = completion.reason; + storage.sql.exec( + `UPDATE document_executions + SET stopped_at = ?, stop_status = ?, stop_reason_kind = ?, + stop_reason_code = ?, stop_reason_event_id = ? + WHERE execution_id = ? AND stopped_at IS NULL`, + now(), + completion.status, + reason?.kind ?? null, + reason?.kind === "host" ? reason.code : null, + reason?.kind === "journal" ? reason.eventId : null, + completion.executionId, + ); +} + +function publish( + storage: OwnerStorage, + status: WorkflowRunStatus, + reason: DocumentExecutionCompletion["reason"], + now: () => string, +): void { + storage.sql.exec( + `UPDATE workflow_run + SET status = ?, stop_reason_kind = ?, stop_reason_code = ?, + stop_reason_event_id = ?, updated_at = ? + WHERE id = 1`, + status, + reason?.kind ?? null, + reason?.kind === "host" ? reason.code : null, + reason?.kind === "journal" ? reason.eventId : null, + now(), + ); +} + +function insertExecution( + storage: OwnerStorage, + executionId: string, + now: () => string, +): DocumentExecutionRecord { + storage.sql.exec( + "INSERT INTO document_executions (execution_id, started_at) VALUES (?, ?)", + executionId, + now(), + ); + return readExecution(storage, executionId); +} + +/** + * Close whatever the previous executor left, on the run's own evidence. + * + * Reached only when this caller holds the acquisition and has begun nothing of + * its own, so an unfinished execution is proven stale by the connection that + * owned it being gone — never by elapsed time. + */ +function reconcile( + storage: OwnerStorage, + stored: WorkflowRunRecord, + now: () => string, +): { + status: WorkflowRunStatus; + recovered: DocumentExecutionRecord | null; + damaged?: boolean; +} { + const closing = closingOutcome(stored.status, rootOutcome(journalEntries(storage))); + if (closing.damaged) { + // Nothing is decided here and nothing is written: whatever the previous + // executor left stays exactly as it left it, because this build cannot say + // what the document it ran did. + return { status: stored.status, recovered: null, damaged: true }; + } + const open = unfinished(storage); + if (open.length === 0) { + return { status: stored.status, recovered: null }; + } + let last: DocumentExecutionRecord | null = null; + for (const execution of open) { + finish( + storage, + { executionId: execution.executionId, status: closing.status, reason: closing.reason }, + now, + ); + last = readExecution(storage, execution.executionId); + } + if (!closing.publishes) { + return { status: stored.status, recovered: last }; + } + publish(storage, closing.status, closing.reason, now); + return { status: closing.status, recovered: last }; +} + +/** + * Begin one document execution, in one transaction. + * + * Recovery decides what the previous executor's execution became, admission + * decides whether this caller may continue, and an admitted caller's execution + * is inserted — all or none. + */ +export function beginRun( + storage: OwnerStorage, + transaction: OwnerTransaction, + acquisitionId: string, + runId: string, + action: "start" | "resume", + creation: CreateWorkflowRunRequest | null, + retrieval: string | null, + executionId: string, + now: () => string, +): LifecycleValue { + if (action === "start" && creation !== null) { + // Creating and beginning are one commit — this runs inside the caller's + // transaction — so a reader observes the whole begun run or no run at all, + // never an initialized candidate with no execution. + const opened = establishRun(storage, transaction, runId, creation, now, retrieval ?? undefined); + if (opened !== null) { + return { conflict: opened, refusal: null, value: null }; + } + } + + return (() => { + if (heldExecution(storage, acquisitionId) !== undefined) { + // One acquisition begins one execution. Its own unfinished execution is + // not somebody else's leftovers, so this is refused rather than + // recovered. Asked once the store is known to exist, because a store + // that holds nothing holds no acquisition either. + throw new CommandError("duplicate-conflict"); + } + const stored = storedRun(storage); + if (stored.runId !== runId) { + throw new CommandError("wrong-run"); + } + if (creation !== null) { + const differing = conflictingFields(stored, creation); + if (differing.length > 0) { + return { conflict: differing, refusal: null, value: null }; + } + } + + const recovered = reconcile(storage, stored, now); + if (recovered.damaged === true) { + return { conflict: null, refusal: "damaged-terminal", value: null }; + } + // The recovery above stays committed whatever this decides: what the + // previous executor's execution became is not undone by this caller being + // told it may not continue. + if (admissionRefusal(action, recovered.status) !== undefined) { + return { + conflict: null, + refusal: recovered.status === "cancelled" ? "cancelled" : "resume-failed", + value: null, + }; + } + + const decision = beginDecision(recovered.status); + const execution = insertExecution(storage, executionId, now); + // Recorded here rather than only in the runner's hold: the owner is what a + // settlement is checked against, and an evicted object keeps its sockets + // but forgets everything that was not written down. + holdExecution(storage, acquisitionId, executionId); + if (decision.kind === "running") { + publish(storage, "running", undefined, now); + } + return { + conflict: null, + refusal: null, + value: { + frontier: readFrontier(storage, runId), + execution, + replay: decision.kind === "replay", + recovered: recovered.recovered, + }, + }; + })(); +} + +/** + * Finish the execution this acquisition began, and publish what it decided. + * + * The expected root is checked in the same transaction: a settlement built + * against a Workspace the run has moved off describes an execution of + * something else. + */ +export function settleRun( + storage: OwnerStorage, + acquisitionId: string, + runId: string, + completion: DocumentExecutionCompletion, + expectedWorkspaceRootId: string, + now: () => string, +): FrontierValue { + return (() => { + const stored = storedRun(storage); + if (stored.runId !== runId) { + throw new CommandError("wrong-run"); + } + const current = rows( + storage, + "SELECT current_root_id FROM workspace_state WHERE singleton_id = 1", + )[0]; + if (current === undefined || String(current["current_root_id"]) !== expectedWorkspaceRootId) { + throw new CommandError("stale-root"); + } + if (heldExecution(storage, acquisitionId) !== completion.executionId) { + // Either this acquisition began nothing, or it began something else. + // Naming an execution is not the same as having begun it. + throw new CommandError("wrong-execution"); + } + const execution = readExecution(storage, completion.executionId); + if (execution.stoppedAt !== undefined) { + // Already settled. Settling it again would replace an outcome that won. + throw new CommandError("duplicate-conflict"); + } + finish( + storage, + { + executionId: completion.executionId, + status: completion.status, + reason: completion.reason, + }, + now, + ); + // A replay closes only its own envelope: the terminal outcome it observed + // is not made mutable again. + if (!terminal(stored.status)) { + publish(storage, completion.status, completion.reason, now); + } + // Finished, so this acquisition holds no execution any more. It does not + // get another: what it may do next is read, and let go. + releaseExecution(storage, acquisitionId); + return readFrontier(storage, runId); + })(); +} + +/** + * Make one run terminal, following what it retains. + * + * Takes no execution of its own. A stale execution is reconciled first, on the + * same evidence a begin would use, so a document that finished before its + * executor disappeared keeps the outcome it recorded. + */ +export function cancelRunOnOwner( + storage: OwnerStorage, + runId: string, + now: () => string, +): LifecycleValue { + return (() => { + const stored = storedRun(storage); + if (stored.runId !== runId) { + throw new CommandError("wrong-run"); + } + if (stored.status === "cancelled") { + // Already what the caller asked for. Saying so twice is the same answer. + return { conflict: null, refusal: null, value: readFrontier(storage, runId) }; + } + if (terminal(stored.status)) { + return { conflict: null, refusal: "terminal", value: null }; + } + const recovered = reconcile(storage, stored, now); + if (recovered.damaged === true) { + // Cancelling would replace a result rather than end a run that had none. + return { conflict: null, refusal: "damaged-terminal", value: null }; + } + if (terminal(recovered.status) || recovered.status === "cancelled") { + // The document finished before its executor disappeared. Restoring what + // it recorded is not cancelling it. + return { conflict: null, refusal: null, value: readFrontier(storage, runId) }; + } + for (const execution of unfinished(storage)) { + finish( + storage, + { executionId: execution.executionId, status: "cancelled", reason: undefined }, + now, + ); + } + publish(storage, "cancelled", undefined, now); + return { conflict: null, refusal: null, value: readFrontier(storage, runId) }; + })(); +} diff --git a/packages/workflow/src/cloudflare/owner-open.ts b/packages/workflow/src/cloudflare/owner-open.ts new file mode 100644 index 000000000..611877b52 --- /dev/null +++ b/packages/workflow/src/cloudflare/owner-open.ts @@ -0,0 +1,197 @@ +/** + * Opening one run on its owner: finding it, or creating it exactly once. + * + * The Durable Object *is* the run's storage, so "where is this run" has already + * been answered by the time a command arrives — routing chose the object. What + * is left is the same question the local provider asks of a file: is there a + * run here, is it this run, and if there is none may this request start one. + * + * Creation is lookup-or-create, and the identity that decides is the run's + * immutable identity alone: its id, its definition, its base and its normalized + * props. Status, timestamps, executions, roots, mappings, answers, retrieval + * and journal history are things a run *has*, not things it *is*, and a + * creation that differed only in those would be the same run asked for twice. + * + * Everything happens inside one owner transaction. A creation that fails leaves + * either no run at all or the whole run that was already committed: there is no + * state in which an empty candidate exists for inspection to mistake for a run. + */ + +import { canonicalJson } from "../storage/record.ts"; +import { definitionToJson } from "../storage/definition.ts"; +import { conflictingFields } from "../storage/compatibility.ts"; +import type { CreateWorkflowRunRequest } from "../storage/api.ts"; +import { readRunRecord } from "../sqlite/rows.ts"; +import { EMPTY_WORKSPACE_MANIFEST, WORKSPACE_ROOT_DOMAIN } from "../workspace/root-manifest.ts"; +import { WORKSPACE_ROOT_FORMAT } from "../workspace/root-manifest.ts"; +import { sha256Hex } from "./encoding.ts"; +import { CommandError } from "./commands.ts"; +import { holdsNoRun, initializeInside, initializeObject, recognizeObject } from "./recognition.ts"; +import type { OwnerStorage } from "./storage.ts"; +import type { OwnerTransaction, OwnerTransactions } from "./owner-transaction.ts"; +import { readFrontier, type FrontierValue } from "./owner-reads.ts"; + +const INSERT_RUN = `INSERT INTO workflow_run + (id, run_id, definition, base, props, status, created_at, updated_at) + VALUES (1, ?, ?, ?, ?, 'running', ?, ?)`; + +/** + * What opening answered: the run, or which immutable fields say it is another. + * + * A conflict is an answer rather than a refusal because it carries something a + * refusal category cannot. Exactly one member is present. + */ +export interface OpenedValue { + readonly conflict: readonly string[] | null; + readonly frontier: FrontierValue | null; +} + +/** + * Create this run, or confirm the one that is here is it. + * + * Runs inside a transaction the caller already opened, so beginning a run can + * create it and record its first execution in one commit. Answers with the + * differing immutable fields when the id wears another identity, and `null` + * when the run is this one. + */ +export function establishRun( + storage: OwnerStorage, + transaction: OwnerTransaction, + runId: string, + creation: CreateWorkflowRunRequest, + now: () => string, + retrieval?: string, +): readonly string[] | null { + if (pristine(storage)) { + const stamp = now(); + initializeInside(storage, transaction, () => { + insertRun(storage, creation, stamp); + if (retrieval !== undefined) { + // Written with the run rather than by a later command: where a + // definition can be fetched from is part of what this caller created, + // and a run that had to be told twice could be told once and crash. + storage.sql.exec( + `INSERT INTO definition_retrieval (id, metadata, revision, updated_at) + VALUES (1, ?, 1, ?)`, + retrieval, + stamp, + ); + } + }); + return null; + } + recognizeObject(storage); + requireRetainedRun(storage, runId); + const differing = conflictingFields(readFrontier(storage, runId).record, creation); + return differing.length > 0 ? differing : null; +} + +/** The root every run starts from, by the identity its bytes produce. */ +export function emptyWorkspaceRootId(): string { + return sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${EMPTY_WORKSPACE_MANIFEST}`); +} + +/** + * Refuse a store that holds a different run, before anything else is read. + * + * The record is parsed by the reader the rest of this build uses, so a row + * that cannot be read is damage and says so; one that reads and names another + * run is this owner answering about somebody else's. + */ +function requireRetainedRun(storage: OwnerStorage, runId: string): void { + const rows = storage.sql + .exec( + `SELECT run_id, definition, base, props, status, + stop_reason_kind, stop_reason_code, stop_reason_event_id, + created_at, updated_at FROM workflow_run`, + ) + .toArray(); + const row = rows[0]; + if (rows.length !== 1 || row === undefined) { + // Not a run at all. The frontier read reports what is wrong with it. + return; + } + if (readRunRecord(row).runId !== runId) { + throw new CommandError("wrong-run"); + } +} + +/** The run row, the Workspace it starts from, and the pointer that selects it. */ +function insertRun(storage: OwnerStorage, creation: CreateWorkflowRunRequest, stamp: string): void { + storage.sql.exec( + INSERT_RUN, + creation.runId, + canonicalJson(definitionToJson(creation.definition)), + creation.base, + canonicalJson(creation.props), + stamp, + stamp, + ); + // Written with the run rather than by a later command: a run whose current + // root named nothing would be a run no execution could begin against. + const rootId = emptyWorkspaceRootId(); + storage.sql.exec( + "INSERT INTO workspace_roots (root_id, format_version, manifest) VALUES (?, ?, ?)", + rootId, + WORKSPACE_ROOT_FORMAT, + EMPTY_WORKSPACE_MANIFEST, + ); + storage.sql.exec( + "INSERT INTO workspace_state (singleton_id, current_root_id) VALUES (1, ?)", + rootId, + ); +} + +/** Whether this object holds nothing at all yet. */ +function pristine(storage: OwnerStorage): boolean { + // Scratch this adapter wrote is not a run. A destination that was offered a + // fork's parts before the fork was committed holds exactly that and nothing + // else, and creating its run has to be able to proceed. + return holdsNoRun(storage); +} + +/** + * The run this owner holds, or why it holds none this request may use. + * + * `creation` absent is a lookup: it creates nothing, and pristine storage is + * an absent run rather than a foreign one — nothing was ever written here, and + * saying "foreign" would send a host looking for someone else's data. + */ +export function openRun( + storage: OwnerStorage, + transactions: OwnerTransactions, + runId: string, + creation: CreateWorkflowRunRequest | null, + now: () => string, +): OpenedValue { + if (pristine(storage)) { + if (creation === null) { + throw new CommandError("absent"); + } + const stamp = now(); + initializeObject(storage, transactions, () => insertRun(storage, creation, stamp)); + return { conflict: null, frontier: readFrontier(storage, creation.runId) }; + } + + // Not pristine, so it is held to the schema this build writes before any of + // it is read. A foreign, damaged or newer store refuses as itself. + recognizeObject(storage); + // Asked before the frontier, and only here. An intact store holding another + // run is not damage — the records parse, the references hold, and it is + // simply not this run. Every read *inside* an open run still treats a + // mismatch as damage, because by then the run has already been addressed and + // a record that changed identity underneath it is a different fact. + requireRetainedRun(storage, runId); + const frontier = readFrontier(storage, runId); + if (creation === null) { + return { conflict: null, frontier }; + } + const differing = conflictingFields(frontier.record, creation); + if (differing.length > 0) { + // The same id wearing a different identity. Nothing is written, the run + // that is here stays exactly as it was, and what travels back is which + // fields differ — never what they differ to, which is the run's content. + return { conflict: differing, frontier: null }; + } + return { conflict: null, frontier }; +} diff --git a/packages/workflow/src/cloudflare/owner-reads.ts b/packages/workflow/src/cloudflare/owner-reads.ts new file mode 100644 index 000000000..d1ec640f5 --- /dev/null +++ b/packages/workflow/src/cloudflare/owner-reads.ts @@ -0,0 +1,684 @@ +/** + * What the owner answers a read with, read out of its own storage. + * + * Every value here is rebuilt from checked columns. The rows are this object's + * own and were written by this build, which is a reason to expect them to be + * right and no reason at all to skip asking: a row that does not parse is + * storage damage, and storage damage answered as though it were a workflow + * value is how damage travels. + * + * Three properties hold the reads together. The frontier is *coherent*: the run + * record, the current root and the journal anchor are read as one, and the + * anchor is the last event that existed at that moment, so later appends cannot + * enter an earlier snapshot. Reads are *bounded*: a journal is returned in + * pages anchored to that event, and content comes back one piece at a time. + * Reads are *referenced*: a root is returned only once its complete content + * graph has been proved present and self-consistent, and a piece is admitted + * only if that root actually names it, so this is a read of one retained root + * rather than of a content-addressed store. Validating the graph up front is + * the point: a root is a starting frontier, and a frontier that turns out not + * to be materializable after the runner has it is a failure arriving too late + * to mean anything. + * + * A refusal says the category and nothing else. Column values, retained JSON + * and request data never appear in one: the caller learns that storage is + * damaged, which is the only thing it can act on. + */ + +import { parseDurableEvent } from "@executablemd/durable-streams"; +import { readDocumentExecution, readRetrieval, readRunRecord, type Row } from "../sqlite/rows.ts"; +import type { DocumentExecutionRecord } from "../storage/record.ts"; +import { + parseRepositoryRecord, + parseWorktreeRecord, + type RepositoryRecord, + type WorktreeRecord, +} from "../composition/records.ts"; +import { type AgentSessionRecord, parseAgentSessionRecord } from "../storage/agent-session.ts"; +import { WorkflowRecordMalformedError } from "../storage/errors.ts"; +import { + parseWorkspaceRootManifest, + SHA256, + WORKSPACE_ROOT_DOMAIN, + type WorkspaceRootManifest, +} from "../workspace/root-manifest.ts"; +import { type ContentManifest, decodeContentManifest } from "../workspace/content-manifest.ts"; +import { + CommandError, + EXECUTION_PAGE_BYTES, + EXECUTION_PAGE_ENTRIES, + executionPageBytes, + MAX_LEDGER_BYTES, + MAX_MAPPINGS, + JOURNAL_PAGE_BYTES, + JOURNAL_PAGE_ENTRIES, + MAX_CONTENT_BYTES, +} from "./commands.ts"; +import { bytesOf, encodeBase64, sha256Hex } from "./encoding.ts"; +import type { OwnerStorage } from "./storage.ts"; + +export interface FrontierValue { + readonly record: ReturnType; + readonly retrieval: ReturnType | null; + readonly workspaceRootId: string; + readonly journalEventId: string | null; +} + +export interface JournalPageValue { + readonly anchorEventId: string | null; + readonly afterEventId: string | null; + readonly entries: readonly { + readonly eventId: string; + readonly previousEventId: string | null; + readonly record: string; + readonly workspaceRootId: string; + }[]; + readonly done: boolean; +} + +export interface RootValue { + readonly workspaceRootId: string; + readonly manifest: string; +} + +export interface ContentValue { + readonly kind: "manifest" | "blob"; + readonly digest: string; + readonly size: number; + readonly bytes: string; +} + +/** + * One retained root, and the whole content graph it names, proved. + * + * `manifests` and `blobs` are not a description of what the root refers to — + * they are what was found and checked. A `StoredRoot` therefore cannot exist + * for a root whose graph is incomplete or disagrees with itself. + */ +interface StoredRoot { + readonly manifest: string; + readonly parsed: WorkspaceRootManifest; + readonly manifests: ReadonlyMap; + readonly blobs: ReadonlySet; +} + +function corrupt(reason: string): never { + throw new WorkflowRecordMalformedError("workflow owner storage", reason); +} + +function exactlyOne(rows: Row[], name: string): Row { + if (rows.length !== 1 || rows[0] === undefined) { + return corrupt(`expected exactly one ${name} row`); + } + return rows[0]; +} + +function safeText(row: Row, column: string): string { + const value = row[column]; + if (typeof value !== "string" || value === "") { + return corrupt(`expected ${column} to be non-empty text`); + } + return value; +} + +function safeInteger(value: unknown, name: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + return corrupt(`expected ${name} to be a nonnegative whole number`); + } + return value; +} + +function rootIdentity(manifest: string): string { + return sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${manifest}`); +} + +function byteRows(storage: OwnerStorage, sql: string, ...bindings: unknown[]): Row[] { + return storage.sql.exec(sql, ...bindings).toArray(); +} + +/** Every content identity one root's reference table holds, in order. */ +function referenceRows( + storage: OwnerStorage, + table: string, + column: string, + rootId: string, +): string[] { + return byteRows( + storage, + `SELECT lower(hex(${column})) AS digest FROM ${table} WHERE root_id = ? ORDER BY digest`, + rootId, + ).map((row) => safeText(row, "digest")); +} + +/** + * Whether a reference table holds exactly the identities the content names. + * + * Both directions matter and for different reasons. A missing row is content + * the root depends on that nothing is keeping alive, so retention may already + * have collected it. An extra row is the root claiming content it does not use, + * which keeps bytes reachable that no manifest accounts for. Neither is a root + * this owner will hand to a runner as a starting frontier. + */ +function requireReferenceSet( + found: readonly string[], + expected: ReadonlySet, + what: string, +): void { + if (found.length !== expected.size || found.some((digest) => !expected.has(digest))) { + corrupt(`a Workspace root's ${what} references disagree with its content`); + } +} + +/** One retained DOFS manifest, proved against its identity, size and entries. */ +function validatedManifest( + storage: OwnerStorage, + parsed: WorkspaceRootManifest, + digest: string, +): { bytes: Uint8Array; manifest: ContentManifest } { + const row = exactlyOne( + byteRows(storage, "SELECT size, encoded FROM vfs_manifests WHERE lower(hex(hash)) = ?", digest), + "DOFS manifest", + ); + const bytes = bytesOf(row["encoded"]); + if (bytes.length > MAX_CONTENT_BYTES || sha256Hex(bytes) !== digest) { + return corrupt("a retained DOFS manifest disagrees with its identity"); + } + const manifest = decodeContentManifest(bytes, corrupt); + if (safeInteger(row["size"], "manifest size") !== manifest.size) { + return corrupt("a retained DOFS manifest disagrees with its recorded size"); + } + for (const entry of parsed.entries) { + if (entry.kind === "file" && entry.manifest === digest && entry.size !== manifest.size) { + return corrupt("a Workspace file size disagrees with its retained manifest"); + } + } + return { bytes, manifest }; +} + +/** One retained blob, proved against its identity and every chunk naming it. */ +function validatedBlob( + storage: OwnerStorage, + rootId: string, + manifests: ReadonlyMap, + digest: string, +): Uint8Array { + const row = exactlyOne( + byteRows( + storage, + `SELECT b.size, x.bytes FROM workspace_root_blob_refs AS r + JOIN vfs_blobs AS b ON b.hash = r.blob_hash + JOIN vfs_blob_bytes AS x ON x.hash = r.blob_hash + WHERE r.root_id = ? AND lower(hex(r.blob_hash)) = ?`, + rootId, + digest, + ), + "DOFS blob", + ); + const bytes = bytesOf(row["bytes"]); + if (bytes.length > MAX_CONTENT_BYTES || sha256Hex(bytes) !== digest) { + return corrupt("a retained DOFS blob disagrees with its identity"); + } + if (safeInteger(row["size"], "blob size") !== bytes.length) { + return corrupt("a retained DOFS blob disagrees with its recorded size"); + } + for (const manifest of manifests.values()) { + for (const chunk of manifest.chunks) { + if (chunk.hash === digest && chunk.size !== bytes.length) { + return corrupt("a DOFS chunk size disagrees with the blob it names"); + } + } + } + return bytes; +} + +/** + * One retained root, with its complete content graph proved before it is a root + * at all. + * + * Accepting a root is accepting a starting frontier: the runner will + * materialize it, work in it, and propose against it. A root whose graph cannot + * be materialized is not a frontier, and discovering that one piece at a time — + * after the frontier has already crossed to the runner — would mean the failure + * arrives once the run has already been told where it stands. + * + * So the whole graph is walked here. The manifests the entries name must be + * exactly the manifests the root retains; each must exist, be bounded, decode + * canonically, hash to its identity, and agree with its recorded size and with + * every file that names it. The blobs those manifests name must be exactly the + * blobs the root retains; each must exist, be bounded, hash to its identity, + * and agree with its recorded size and with every chunk that names it. + * + * The bytes are read and dropped. What is kept is the proof, and a later + * content request re-reads the single piece it is sending — which is what keeps + * the transport piece-oriented rather than turning a validated root into one + * unbounded answer. + */ +/** + * Prove one retained root is complete, for a caller that is about to write. + * + * The same validator the reads use. Keeping the read boundary and the write + * boundary on one proof is what stops a root being publishable by one path and + * refused by the other. + */ +export function validateRetainedRoot(storage: OwnerStorage, rootId: string): void { + referencedRoot(storage, rootId); +} + +function referencedRoot(storage: OwnerStorage, rootId: string): StoredRoot { + if (!SHA256.test(rootId)) { + return corrupt("a Workspace root identity is malformed"); + } + const root = exactlyOne( + byteRows( + storage, + "SELECT root_id, format_version, manifest FROM workspace_roots WHERE root_id = ?", + rootId, + ), + "Workspace root", + ); + const manifest = safeText(root, "manifest"); + if (root["format_version"] !== 1) { + return corrupt("a Workspace root has an unsupported format"); + } + const parsed = parseWorkspaceRootManifest(manifest, corrupt); + if (rootIdentity(manifest) !== rootId || root["root_id"] !== rootId) { + return corrupt("a Workspace root disagrees with its identity"); + } + if (new TextEncoder().encode(manifest).length > MAX_CONTENT_BYTES) { + throw new CommandError("too-large"); + } + + const named = new Set( + parsed.entries.flatMap((entry) => (entry.kind === "file" ? [entry.manifest] : [])), + ); + requireReferenceSet( + referenceRows(storage, "workspace_root_manifest_refs", "manifest_hash", rootId), + named, + "manifest", + ); + + const manifests = new Map(); + for (const digest of named) { + manifests.set(digest, validatedManifest(storage, parsed, digest).manifest); + } + + const reachable = new Set(); + for (const decoded of manifests.values()) { + for (const chunk of decoded.chunks) { + reachable.add(chunk.hash); + } + } + requireReferenceSet( + referenceRows(storage, "workspace_root_blob_refs", "blob_hash", rootId), + reachable, + "blob", + ); + for (const digest of reachable) { + validatedBlob(storage, rootId, manifests, digest); + } + + return { manifest, parsed, manifests, blobs: reachable }; +} + +export function readFrontier(storage: OwnerStorage, runId: string): FrontierValue { + const record = readRunRecord( + exactlyOne( + byteRows( + storage, + `SELECT run_id, definition, base, props, status, + stop_reason_kind, stop_reason_code, stop_reason_event_id, + created_at, updated_at FROM workflow_run`, + ), + "workflow run", + ), + ); + if (record.runId !== runId) { + return corrupt("the retained run identity does not address this owner"); + } + const state = exactlyOne( + byteRows(storage, "SELECT current_root_id FROM workspace_state WHERE singleton_id = 1"), + "Workspace state", + ); + const workspaceRootId = safeText(state, "current_root_id"); + referencedRoot(storage, workspaceRootId); + const retrievalRows = byteRows( + storage, + "SELECT metadata, revision, updated_at FROM definition_retrieval WHERE id = 1", + ); + if (retrievalRows.length > 1) { + return corrupt("the definition retrieval is not a singleton"); + } + const last = byteRows( + storage, + "SELECT event_id FROM journal_events ORDER BY sequence DESC LIMIT 1", + )[0]; + return { + record, + retrieval: retrievalRows[0] === undefined ? null : readRetrieval(retrievalRows[0]), + workspaceRootId, + journalEventId: last === undefined ? null : safeText(last, "event_id"), + }; +} + +/** + * One coherent admission fact: the root, the journal anchor and every mapping. + * + * Read together, in one owner-side read, because they are one state. Taking the + * mappings from one request and the root from another would let an invocation + * begin against a Workspace whose retained Repository rows describe a different + * moment — and nothing later could notice, because each answer was true when it + * was given. + * + * Complete rather than paged. The mapping tables are insert-only, so a cursor + * over sorted names cannot be made safe by root and journal equality alone: a + * name inserted later can sort before the cursor and never be seen. A count and + * byte ceiling refuses instead, and refuses whole. + */ +export function readInvocationSnapshot( + storage: OwnerStorage, + runId: string, +): InvocationSnapshotValue { + const frontier = readFrontier(storage, runId); + const repositories = byteRows( + storage, + `SELECT name, locator, locator_fingerprint, requested_base, creation_commit, + primary_branch, object_format, checkout_path + FROM workspace_repositories ORDER BY name`, + ).map((row) => ({ + record: readRepositoryRecord(row), + locator: safeText(row, "locator"), + })); + const worktrees = byteRows( + storage, + `SELECT repository_name, name, requested_branch, requested_base, + creation_commit, checkout_path + FROM workspace_worktrees ORDER BY repository_name, name`, + ).map((row) => readWorktreeRecord(row)); + const agentSessions = byteRows( + storage, + `SELECT session_key, provider, agent_command, session_identity, policy, + assertion_kind, assertion_value, created_at + FROM agent_sessions ORDER BY session_key`, + ).map((row) => readAgentSessionRow(row)); + + const entries = repositories.length + worktrees.length + agentSessions.length; + if (entries > MAX_MAPPINGS) { + throw new CommandError("too-large"); + } + const snapshot = { + workspaceRootId: frontier.workspaceRootId, + journalEventId: frontier.journalEventId, + repositories, + worktrees, + agentSessions, + }; + // Measured over the complete semantic answer, before any of it is returned. A + // ceiling checked per record would let an aggregate no message can carry + // through one record at a time. + if (new TextEncoder().encode(JSON.stringify(snapshot)).length > MAX_LEDGER_BYTES) { + throw new CommandError("too-large"); + } + return snapshot; +} + +function readRepositoryRecord(row: Row): RepositoryRecord { + const parsed = parseRepositoryRecord({ + name: row["name"], + locatorFingerprint: row["locator_fingerprint"], + requestedBase: row["requested_base"] ?? null, + creationCommit: row["creation_commit"], + primaryBranch: row["primary_branch"], + objectFormat: row["object_format"], + checkoutPath: row["checkout_path"], + }); + if (parsed === undefined) { + return corrupt("a retained Repository row does not describe a Repository"); + } + return parsed; +} + +function readWorktreeRecord(row: Row): WorktreeRecord { + const parsed = parseWorktreeRecord({ + repositoryName: row["repository_name"], + name: row["name"], + requestedBranch: row["requested_branch"], + requestedBase: row["requested_base"] ?? null, + creationCommit: row["creation_commit"], + checkoutPath: row["checkout_path"], + }); + if (parsed === undefined) { + return corrupt("a retained Worktree row does not describe a Worktree"); + } + return parsed; +} + +function readAgentSessionRow(row: Row): AgentSessionRecord { + const parsed = parseAgentSessionRecord({ + sessionKey: row["session_key"], + provider: row["provider"], + agentCommand: row["agent_command"], + sessionIdentity: row["session_identity"], + policy: row["policy"], + assertion: { kind: row["assertion_kind"], value: row["assertion_value"] }, + createdAt: row["created_at"], + }); + if (parsed === undefined) { + return corrupt("a retained Agent session row does not describe a session"); + } + return parsed; +} + +export function readJournalPage( + storage: OwnerStorage, + anchorEventId: string | null, + afterEventId: string | null, +): JournalPageValue { + if (anchorEventId === null) { + if (afterEventId !== null) { + return corrupt("an empty journal snapshot names an earlier event"); + } + return { anchorEventId, afterEventId, entries: [], done: true }; + } + const anchor = exactlyOne( + byteRows(storage, "SELECT sequence FROM journal_events WHERE event_id = ?", anchorEventId), + "journal anchor", + ); + const anchorSequence = safeInteger(anchor["sequence"], "journal anchor sequence"); + let afterSequence = 0; + if (afterEventId !== null) { + const after = exactlyOne( + byteRows(storage, "SELECT sequence FROM journal_events WHERE event_id = ?", afterEventId), + "journal cursor", + ); + afterSequence = safeInteger(after["sequence"], "journal cursor sequence"); + if (afterSequence >= anchorSequence) { + return corrupt("a journal cursor is outside its anchored snapshot"); + } + } + const rows = byteRows( + storage, + `SELECT event_id, record, workspace_root_id, + (SELECT event_id FROM journal_events AS predecessor + WHERE predecessor.sequence < event.sequence + ORDER BY predecessor.sequence DESC LIMIT 1) AS previous_event_id + FROM journal_events AS event + WHERE sequence > ? AND sequence <= ? ORDER BY sequence ASC LIMIT ?`, + afterSequence, + anchorSequence, + JOURNAL_PAGE_ENTRIES + 1, + ); + const entries: JournalPageValue["entries"][number][] = []; + let encodedBytes = 0; + for (const row of rows.slice(0, JOURNAL_PAGE_ENTRIES)) { + const eventId = safeText(row, "event_id"); + const previous = row["previous_event_id"]; + if (previous !== null && typeof previous !== "string") { + return corrupt("a journal predecessor identity is malformed"); + } + const record = safeText(row, "record"); + const workspaceRootId = safeText(row, "workspace_root_id"); + if (!SHA256.test(workspaceRootId) || !parseDurableEvent(record).ok) { + return corrupt("a journal row is malformed"); + } + const entry = { eventId, previousEventId: previous, record, workspaceRootId }; + const nextBytes = new TextEncoder().encode(JSON.stringify(entry)).length; + if (entries.length > 0 && encodedBytes + nextBytes > JOURNAL_PAGE_BYTES) { + break; + } + if (nextBytes > MAX_CONTENT_BYTES) { + throw new CommandError("too-large"); + } + entries.push(entry); + encodedBytes += nextBytes; + } + const done = rows.length <= entries.length; + if (done && entries.at(-1)?.eventId !== anchorEventId) { + return corrupt("an anchored journal snapshot is incomplete"); + } + return { anchorEventId, afterEventId, entries, done }; +} + +export function readRoot(storage: OwnerStorage, workspaceRootId: string): RootValue { + const root = referencedRoot(storage, workspaceRootId); + return { workspaceRootId, manifest: root.manifest }; +} + +export function readContent( + storage: OwnerStorage, + workspaceRootId: string, + kind: "manifest" | "blob", + digest: string, + sourceManifest: string | null, +): ContentValue { + const root = referencedRoot(storage, workspaceRootId); + const bytes = piece(storage, workspaceRootId, root, kind, digest, sourceManifest); + if (bytes.length === 0) { + return corrupt("a retained content piece is empty"); + } + return { kind, digest, size: bytes.length, bytes: encodeBase64(bytes) }; +} + +/** + * The one piece a content request names, re-read from the proved graph. + * + * Membership is decided against what the root actually names rather than + * against the reference tables alone, and a blob is reached only through a + * manifest the request names. That is what keeps this a read of one retained + * root instead of a read of the content store: staged, orphaned or + * otherwise-unreferenced bytes are addressable by nobody through here. + */ +function piece( + storage: OwnerStorage, + rootId: string, + root: StoredRoot, + kind: "manifest" | "blob", + digest: string, + sourceManifest: string | null, +): Uint8Array { + if (kind === "manifest") { + if (!root.manifests.has(digest)) { + return corrupt("a DOFS manifest is not referenced by this Workspace root"); + } + return validatedManifest(storage, root.parsed, digest).bytes; + } + const source = sourceManifest === null ? undefined : root.manifests.get(sourceManifest); + if (source === undefined || !source.chunks.some((chunk) => chunk.hash === digest)) { + return corrupt("a blob is not referenced by the named DOFS manifest"); + } + return validatedBlob(storage, rootId, root.manifests, digest); +} + +/** One page of document executions, anchored to the snapshot that began it. */ +/** The one admitted state a remote Workspace invocation begins from. */ +export interface InvocationSnapshotValue { + readonly workspaceRootId: string; + readonly journalEventId: string | null; + readonly repositories: readonly { readonly record: RepositoryRecord; readonly locator: string }[]; + readonly worktrees: readonly WorktreeRecord[]; + readonly agentSessions: readonly AgentSessionRecord[]; +} + +export interface ExecutionsValue { + readonly runId: string; + readonly anchor: number | null; + readonly after: number | null; + readonly rows: readonly { readonly sequence: number; readonly record: DocumentExecutionRecord }[]; + readonly done: boolean; +} + +/** + * Read one bounded page of the executions this run has begun. + * + * Anchored the way the journal is, and for the same reason: a caller assembling + * a list across several requests must see one snapshot rather than whatever the + * table held at each moment. The first page fixes the terminal sequence; every + * later page is constrained to it, so an execution begun while the read is in + * flight cannot appear halfway through the answer. + * + * The run identity travels with the page so the runner can refuse an answer + * from another run, and the sequence travels so it can prove adjacency. Neither + * becomes part of the semantic record. + */ +export function readExecutions( + storage: OwnerStorage, + runId: string, + anchor: number | null, + after: number | null, +): ExecutionsValue { + // The first request carries no anchor because the runner has nothing to + // anchor to yet. The owner chooses it — the terminal row at this moment — and + // answers with it, so every later page is held to the snapshot this one + // began. An empty run answers with an explicit empty anchor. + const selected = anchor ?? (after === null ? executionAnchor(storage) : null); + if (selected === null) { + if (after !== null) { + return corrupt("an empty execution snapshot names an earlier row"); + } + return { runId, anchor: null, after, rows: [], done: true }; + } + + const found = byteRows( + storage, + `SELECT sequence, execution_id, started_at, stopped_at, stop_status, + stop_reason_kind, stop_reason_code, stop_reason_event_id + FROM document_executions + WHERE sequence > ? AND sequence <= ? ORDER BY sequence ASC LIMIT ?`, + after ?? 0, + selected, + EXECUTION_PAGE_ENTRIES + 1, + ); + + const page: { sequence: number; record: DocumentExecutionRecord }[] = []; + for (const row of found.slice(0, EXECUTION_PAGE_ENTRIES)) { + const at = safeInteger(row["sequence"], "execution sequence"); + // The semantic record is what crosses, not the physical row. A row this + // owner cannot read is storage damage; sending its columns would make the + // runner responsible for a shape it has no business knowing. + const entry = { sequence: at, record: readDocumentExecution(row) }; + const grown = [...page, entry]; + if (executionPageBytes(grown) > EXECUTION_PAGE_BYTES) { + if (page.length === 0) { + // One record larger than a whole page: this snapshot cannot be paged, + // and answering with it would send what the runner must refuse. + throw new CommandError("too-large"); + } + break; + } + page.push(entry); + } + + const done = found.length <= page.length; + if (done && page.at(-1)?.sequence !== selected) { + return corrupt("an anchored execution snapshot is incomplete"); + } + return { runId, anchor: selected, after, rows: page, done }; +} + +/** The terminal execution sequence right now, or `null` when there is none. */ +export function executionAnchor(storage: OwnerStorage): number | null { + const last = byteRows( + storage, + "SELECT sequence FROM document_executions ORDER BY sequence DESC LIMIT 1", + )[0]; + return last === undefined ? null : safeInteger(last["sequence"], "execution sequence"); +} diff --git a/packages/workflow/src/cloudflare/owner-transaction.ts b/packages/workflow/src/cloudflare/owner-transaction.ts new file mode 100644 index 000000000..b91a3ec7c --- /dev/null +++ b/packages/workflow/src/cloudflare/owner-transaction.ts @@ -0,0 +1,144 @@ +/** + * The one real transaction an owner commit runs inside. + * + * A Durable Object's SQLite accepts exactly one shape of transaction: the + * runtime's own `transactionSync()`, entered once. It refuses `BEGIN`, `COMMIT` + * and `SAVEPOINT` through `sql.exec()`, and it refuses a reentrant + * `transactionSync()`. The vendored DOFS `Database` does not know that — asked + * to transact while it believes a transaction is already open, it falls back to + * `SAVEPOINT`, and every DOFS filesystem primitive opens a transaction of its + * own on the way in. + * + * So the owner enters the real transaction itself and hands DOFS a wrapper + * whose `transactionSync` runs its callback directly. Inside the real + * callback that is not a weaker promise: the outer transaction is already + * open, so a body that returns has had its work applied to the same + * transaction, and a body that throws unwinds through the real callback and + * Cloudflare rolls the whole thing back. + * + * That substitution is only safe because the owner does not use a DOFS + * savepoint as a recovery boundary. The runner has already performed the live + * Workspace work against disposable materialization; what reaches the owner is + * a complete proposal. The owner commits all of it or, treating any validation + * or application failure as infrastructure failure, none of it. + * + * The wrapper is created for one callback and refuses use outside it, so + * nothing can retain it and reach the storage later. Its DOFS caches are built + * fresh for the same reason: a resolution or blob cache populated from + * uncommitted rows must not survive a rollback or be read by a later + * operation. + */ + +import { Database as DofsDatabase } from "../../vendor/cloudflare-computer-dofs/generated/storage.js"; +import { clearBlobCache } from "../../vendor/cloudflare-computer-dofs/generated/fs/blobCache.js"; +import { clearResolveCache } from "../../vendor/cloudflare-computer-dofs/generated/fs/resolveCache.js"; +import { dofsStorage, type OwnerStorage } from "./storage.ts"; + +/** Using an enlistment after its transaction returned. */ +export class OwnerTransactionClosedError extends Error { + override name = "OwnerTransactionClosedError"; + + constructor() { + super( + "this owner transaction has finished; a DOFS enlistment is valid only inside the callback that created it.", + ); + } +} + +/** Opening an owner transaction inside one. */ +export class OwnerTransactionNestedError extends Error { + override name = "OwnerTransactionNestedError"; + + constructor() { + super( + "an owner transaction is already open; Durable Object storage admits exactly one, and a second would reach SAVEPOINT.", + ); + } +} + +/** What the body of an owner transaction is given. */ +export interface OwnerTransaction { + /** The DOFS database, enlisted in this transaction and valid only inside it. */ + readonly dofs: DofsDatabase; +} + +/** + * One Durable Object's claim on its own storage. + * + * Owned by the object rather than by this module. A module-scoped flag would be + * shared by every object in an isolate, so one object's transaction would + * refuse another's; a module-scoped registry keyed by storage would fix that + * and still be a process-lifetime table this package's rules do not allow. An + * instance the object creates and holds says the same thing without either + * problem: the gate's lifetime is the object's, and no other object can see it. + */ +export class OwnerTransactions { + #open = false; + + /** + * Run `body` inside one real `ctx.storage.transactionSync()`. + * + * `body` must complete synchronously. Nothing may await, suspend, hold a + * cursor, wait on a WebSocket or reach the runner from inside it: the runtime + * requires the callback to finish before it can commit, and a value that + * arrived later would be applied to a transaction nobody is holding. + */ + run(storage: OwnerStorage, body: (transaction: OwnerTransaction) => T): T { + if (this.#open) { + throw new OwnerTransactionNestedError(); + } + this.#open = true; + try { + return enter(storage, body); + } finally { + this.#open = false; + } + } +} + +/** + * Run `body` inside one real `ctx.storage.transactionSync()`. + * + * `body` must complete synchronously. Nothing may await, suspend, hold a + * cursor, wait on a WebSocket or reach the runner from inside it: the runtime + * requires the callback to finish before it can commit, and a value that + * arrived later would be applied to a transaction nobody is holding. + */ +/** + * Enter the one real transaction and enlist DOFS inside it. + * + * Separate from the gate above so the claim and the runtime call are two + * things: the gate says whether this object may transact, and this says what a + * transaction is. + */ +function enter(storage: OwnerStorage, body: (transaction: OwnerTransaction) => T): T { + return storage.transactionSync(() => { + let live = true; + const dofs = new DofsDatabase(dofsStorage(storage)); + // Fresh caches for this transaction alone. They are keyed by database, so + // an entry populated from rows this transaction may roll back would + // otherwise outlive it and be read by a later operation. + clearResolveCache(dofs); + clearBlobCache(dofs); + // The substitution: DOFS believes it is opening a transaction, and runs in + // the one already open. Reentrancy inside DOFS becomes ordinary nesting of + // plain function calls, which is what the runtime allows. + Object.defineProperty(dofs, "transactionSync", { + value: (closure: () => R): R => { + if (!live) { + throw new OwnerTransactionClosedError(); + } + return closure(); + }, + configurable: false, + writable: false, + }); + try { + return body({ dofs }); + } finally { + live = false; + clearResolveCache(dofs); + clearBlobCache(dofs); + } + }); +} diff --git a/packages/workflow/src/cloudflare/owner.ts b/packages/workflow/src/cloudflare/owner.ts new file mode 100644 index 000000000..5ec256251 --- /dev/null +++ b/packages/workflow/src/cloudflare/owner.ts @@ -0,0 +1,482 @@ +/** + * The Durable Object that owns one workflow run. + * + * One run, one object, selected arithmetically from the public run ID. It holds + * the WorkflowRun record and its filtered journal, the immutable Workspace roots + * and their content, and executor ownership — and it holds them in one embedded + * SQLite database, because a second store would be a second thing to keep in + * agreement with the first. + * + * What it does *not* do is as much of the contract as what it does. It runs no + * native client: no Git, no evidence process, no Agent. Those live on the + * ephemeral runner against disposable materialization, and what crosses the + * connection is a proposal this object validates and publishes. The runner + * performs; the owner decides. + * + * Three planes reach it and only one of them can advance a run. The executor + * plane is one authenticated WebSocket whose lifetime is the acquisition. + * Delivery and inspection arrive over ordinary requests, take no acquisition, + * and cannot move the lifecycle — which is why they are separate methods here + * rather than commands on the socket. + * + * `fetch` is where those three meet an actual request. It reads which plane was + * addressed and nothing else: the upgrade, the admission order and every + * decision stay in the three methods below, so a gateway in front of this + * routes bytes and cannot pre-approve any of it. + */ + +import { DurableObject } from "cloudflare:workers"; +import { run, type Operation } from "effection"; +import { OwnerTransactions } from "./owner-transaction.ts"; +import { + acquireExecutor, + type AcquisitionAttachment, + AcquisitionError, + releaseExecutor, + requireAcquisition, + requireExecutorSocket, +} from "./acquisition.ts"; +import { admitToken, type AdmissionPolicy, AdmissionError } from "./admission.ts"; +import { TokenError, type TokenVerification } from "./token.ts"; +import { CommandError, type CommandResult, parseCommand, type RunnerCommand } from "./commands.ts"; +import { + answerRead, + parseReadOperation, + type ReadAdmission, + type ReadAnswer, +} from "./read-plane.ts"; +import { + answerRetainedWait, + type DeliveryAnswer, + deliverySubject, + parseDeliveryOperation, + retainDeliveredAnswer, +} from "./delivery-plane.ts"; +import { crossSecretGate } from "./owner-gate.ts"; +import { dispatchCommand } from "./dispatcher.ts"; +import { WorkflowRecordMalformedError } from "../storage/errors.ts"; +import { discardPriorAcquisitions, PRIVATE_OBJECT_NAMES } from "./private-schema.ts"; +import { + RELEASE_HEADER, + routeOf, + selectedProtocol, + upgradeAdmission, + type RouteAdmission, +} from "./routes.ts"; +import { + declaredObjects, + holdsNoRun, + initializeObject, + isPristine, + recognizeObject, + WorkflowObjectStorageError, +} from "./recognition.ts"; +import { ReleaseIdentityError, requireSameRelease } from "./release.ts"; +import { admitRunId, RunIdError } from "./routing.ts"; +import type { OwnerStorage } from "./storage.ts"; + +/** + * What one admission presents. + * + * Bytes and identifiers, all of them untrusted. There is deliberately no member + * for a verified result, a claim set, an acquisition identity or verification + * material: a request that could name any of those would be a request choosing + * what it is allowed to be. + */ +export interface AdmissionRequest { + readonly runId: unknown; + readonly release: unknown; + /** The raw short-lived OIDC token, exactly as presented. */ + readonly token: unknown; +} + +/** Everything a deployment must state before this object admits anybody. */ +export interface OwnerConfiguration { + readonly policy: AdmissionPolicy; + /** The issuer's keys and clock. Trusted closure state, never request data. */ + readonly verification: TokenVerification; +} + +/** Name a refusal without repeating what caused it. */ +export function refusalOf(error: unknown): string { + if (error instanceof AcquisitionError) { + return `acquisition:${error.refusal}`; + } + if (error instanceof AdmissionError) { + return `admission:${error.refusal}`; + } + if (error instanceof TokenError) { + return `token:${error.refusal}`; + } + if (error instanceof ReleaseIdentityError) { + return `release:${error.refusal}`; + } + if (error instanceof RunIdError) { + return `run-id:${error.refusal}`; + } + if (error instanceof CommandError) { + return `command:${error.refusal}`; + } + if (error instanceof WorkflowObjectStorageError) { + if (error.failure.kind === "unsupported-version") { + // The version travels in the category rather than beside it, because the + // answer envelope carries a refusal and nothing else. It is the one fact + // a host needs to decide whether this build may open the store, and a + // public error that guessed it would state something untrue. + return `storage:unsupported-version-v${error.failure.schemaVersion}`; + } + return `storage:${error.failure.kind}`; + } + if (error instanceof WorkflowRecordMalformedError) { + return "storage:corrupt"; + } + if ( + error instanceof Error && + (error.message.startsWith("private protocol storage") || + error.message.startsWith("private staging") || + error.message.startsWith("stored bytes")) + ) { + return "storage:corrupt"; + } + return "internal"; +} + +/** + * The owner, minus the deployment's own configuration. + * + * Subclassed rather than configured through a binding because the policy is + * trusted host state: a value a request could supply would be a runner naming + * the identities it must satisfy. + */ +export abstract class WorkflowOwnerObject extends DurableObject { + /** + * This object's claim on its own storage. + * + * One per Durable Object, so a transaction here cannot refuse one in another + * object and no table outlives the object that owns it. + */ + protected readonly transactions: OwnerTransactions = new OwnerTransactions(); + + protected abstract configuration(): OwnerConfiguration; + + /** This object's storage, as the shared modules expect to see it. */ + protected get owned(): OwnerStorage { + return this.ctx.storage; + } + + /** + * Admit one executor connection. + * + * The order is the contract: the build is compared before any token work, the + * token is verified before the run is touched, and the acquisition is taken + * last. A refusal at any step leaves no acquisition and no object state. + * + * The correlation value is minted here, after both checks pass, and never + * taken from the request. A caller-selected one would let a later connection + * reuse an abandoned identifier and collide with the private staging that + * identifier partitions. + */ + *admit(request: AdmissionRequest, socket: WebSocket): Operation { + const { policy, verification } = this.configuration(); + requireSameRelease(policy.release, request.release); + yield* admitToken(policy, verification, request.token); + const runId = admitRunId(request.runId); + const acquisitionId = mintAcquisitionId(); + return acquireExecutor(this.ctx, socket, runId, acquisitionId, () => { + const names = new Set(declaredObjects(this.owned).map((object) => object.name)); + if (PRIVATE_OBJECT_NAMES.every((name) => names.has(name))) { + // A store that holds nothing but this adapter's scratch holds no run + // to recognize — a fork was offered parts here and never committed. + // The scratch still goes, because it belonged to a connection that is + // gone. + if (!holdsNoRun(this.owned)) { + recognizeObject(this.owned); + } + this.transactions.run(this.owned, () => { + discardPriorAcquisitions(this.owned, acquisitionId); + }); + } + }); + } + + /** + * Answer one ordinary read, taking nothing. + * + * The same order admission uses — the build is compared before any token + * work, and the token is verified before the run is touched — and then it + * stops. No socket is accepted, no acquisition is minted or compared, and + * nothing is written, so this can be answered while an executor is live and + * a refusal at any step leaves the acquisition set and the run untouched. + */ + *read(admission: ReadAdmission, body: string): Operation { + const { policy, verification } = this.configuration(); + try { + // The order is the contract, and the body takes no part in it. A build + // this owner will not talk to is refused before its request is decoded, + // and an unauthenticated one before the run is named. + requireSameRelease(policy.release, admission.release); + yield* admitToken(policy, verification, admission.token); + const runId = admitRunId(admission.runId); + return { + outcome: "performed", + value: answerRead(this.owned, runId, parseReadOperation(body)), + }; + } catch (error) { + return { outcome: "refused", refusal: refusalOf(error) }; + } + } + + /** + * Answer one typed delivery, taking nothing. + * + * The same order the other two planes use — the build is compared before any + * token work, and the token is verified before the run is named — and then it + * writes exactly one row inside one transaction. No socket is accepted and no + * acquisition is minted or compared, so a run with a live executor can still + * be answered and a refusal at any step leaves the run untouched. + */ + *deliver(admission: ReadAdmission, body: string): Operation { + const { policy, verification } = this.configuration(); + try { + requireSameRelease(policy.release, admission.release); + yield* admitToken(policy, verification, admission.token); + const runId = admitRunId(admission.runId); + const request = parseDeliveryOperation(body); + const now = new Date().toISOString(); + if (request.operation === "wait") { + return { + outcome: "performed", + value: answerRetainedWait(this.owned, runId, request.suspensionId), + }; + } + + // The gate durable journal persistence is written through, over the two + // framings this value would be stored in. It runs before the transaction + // because it is asynchronous and a Durable Object transaction cannot + // wait; what it read is pinned by the request identity carried into the + // write, which the transaction requires to still be the retained one. + const subject = deliverySubject(this.owned, runId, request); + if (request.secretDetection) { + yield* crossSecretGate(subject.framings); + } + + // One transaction, entered here rather than inside the answer, so every + // fact the retention depends on is read under the write it is about to + // make and a refusal rolls back having written nothing. + return { + outcome: "performed", + value: this.transactions.run(this.owned, () => + retainDeliveredAnswer(this.owned, runId, request, subject.requestFingerprint, now), + ), + }; + } catch (error) { + return { outcome: "refused", refusal: refusalOf(error) }; + } + } + + /** + * Handle one message from an admitted connection. + * + * Acquisition is proved before the message is parsed, so a superseded or + * foreign socket never reaches the command reader — and proved again by + * whatever writes, inside the transaction that writes. + */ + onRunnerMessage(socket: WebSocket, runId: string, raw: string): CommandResult { + let command: RunnerCommand | undefined; + try { + requireAcquisition(this.ctx, socket, runId); + command = parseCommand(raw); + return dispatchCommand(this.ctx, this.transactions, socket, runId, command); + } catch (error) { + return { id: command?.id ?? "", outcome: "refused", refusal: refusalOf(error) }; + } + } + + webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): void { + let answer: CommandResult; + if (typeof message !== "string") { + answer = { id: "", outcome: "refused", refusal: "command:malformed-member" }; + } else { + try { + const held = requireExecutorSocket(this.ctx, socket); + answer = this.onRunnerMessage(socket, held.runId, message); + } catch (error) { + answer = { id: "", outcome: "refused", refusal: refusalOf(error) }; + } + } + try { + socket.send(JSON.stringify(answer)); + } catch { + releaseExecutor(socket); + socket.close(1011, "send failed"); + return; + } + if (fatal(answer)) { + releaseExecutor(socket); + socket.close(1002, "protocol refused"); + } + } + + /** A connection that ended owns nothing, and rolled nothing back. */ + webSocketClose(socket: WebSocket): void { + releaseExecutor(socket); + } + + webSocketError(socket: WebSocket): void { + releaseExecutor(socket); + } + + /** + * Create this run's storage, or recognize what is already there. + * + * Pristine is asked first rather than inferred from a refusal: storage that + * holds nothing is the only storage this build may write into, and every + * other state — foreign, damaged, a version this build does not implement — + * is recognition's to refuse rather than initialization's to overwrite. + */ + /** + * Answer one request on one of this owner's three planes. + * + * The runtime callback boundary this host adapts at, so the Effection scope + * is opened here and closed before a response leaves: every plane below is an + * operation, and none of them may outlive the request that asked. + * + * Which plane is decided by the path, and the run id it names is admitted by + * the plane rather than here — a path that says nothing this build writes is + * refused before an admission exists at all. + */ + override async fetch(request: Request): Promise { + const url = new URL(request.url); + const route = routeOf(url.pathname); + if (route === undefined) { + return new Response("route", { status: 404 }); + } + if (route.plane === "executor") { + return await this.#upgrade(request, route.runId); + } + const admission: RouteAdmission = { + release: request.headers.get(RELEASE_HEADER), + token: bearer(request.headers.get("authorization")), + runId: route.runId, + }; + const body = await request.text(); + const answered = await run(() => + route.plane === "read" ? this.read(admission, body) : this.deliver(admission, body), + ); + // Both planes answer rather than raise, so the status says only that this + // owner answered; what it answered is the envelope, and a refusal category + // is the same word on either plane. + return new Response(JSON.stringify(answered), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + /** + * Take one executor connection, or refuse the upgrade. + * + * The socket the owner accepts is the runtime's own server half, created + * here; the client half is handed back with the 101 and is the only end the + * caller ever holds. A refusal closes nothing because nothing was accepted: + * `admit()` takes the acquisition last, so a release, token or run-id + * refusal leaves this object exactly as it was. + */ + async #upgrade(request: Request, runId: string): Promise { + if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return new Response("upgrade", { status: 400 }); + } + const admission = upgradeAdmission(request.headers.get("sec-websocket-protocol"), runId); + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + if (client === undefined || server === undefined) { + return new Response("internal", { status: 500 }); + } + try { + await run(() => + this.admit( + { runId: admission.runId, release: admission.release, token: admission.token }, + server, + ), + ); + } catch (error) { + // The refusal category, and nothing else. A caller learns which of the + // ordered checks said no; it learns nothing about this object's state. + return new Response(refusalOf(error), { status: 403 }); + } + return new Response(null, { + status: 101, + webSocket: client, + // Selected explicitly: a handshake that offered a subprotocol and got + // none back is one a standard client fails. + headers: { "sec-websocket-protocol": selectedProtocol() }, + }); + } + + open(runId: string, initializeRun: () => void): void { + admitRunId(runId); + if (isPristine(declaredObjects(this.owned))) { + initializeObject(this.owned, this.transactions, initializeRun); + return; + } + recognizeObject(this.owned); + } +} + +/** The token one `Authorization` header carries, if it carries one. */ +function bearer(header: string | null): string | null { + if (header === null) { + return null; + } + const [scheme, value] = header.split(" "); + return scheme?.toLowerCase() === "bearer" && value !== undefined && value !== "" ? value : null; +} + +/** + * A fresh correlation value for one acquisition. + * + * Bounded and unpredictable, and used only to partition acquisition-private + * staging and duplicate handling. It is not a bearer credential, a lease, a + * generation record or a durable identity: what proves a message may act is the + * exact live socket, and this value proves nothing on its own. + */ +function mintAcquisitionId(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** + * Whether a refusal means the connection itself is finished. + * + * Two kinds of refusal reach here and they deserve opposite treatment. One says + * the channel or the store is not what it claims — a message that would not + * parse, an acquisition this socket does not hold, storage that is damaged — + * and carrying on would mean guessing what the other side meant. + * + * The other is an answer about the request. A duplicate id, a frontier that has + * moved, a mapping that disagrees with what is already retained, and a command + * this release does not implement are all decisions the runner can act on: read + * the frontier again, propose against it, or stop. Closing the connection on + * those would turn every ordinary disagreement into a lost acquisition and make + * the runner reconnect to be told the same thing. + */ +const ANSWERED: readonly string[] = [ + "command:duplicate-conflict", + "command:unavailable", + "command:stale-root", + "command:stale-journal", + "command:mapping-conflict", + // Both are answers about this run rather than faults in the protocol: there + // is nothing stored here, or something is and it is not this run. A caller + // acts on either and goes on using the connection. + "command:absent", + "command:wrong-run", +]; + +function fatal(answer: CommandResult): boolean { + if (answer.outcome === "performed") { + return false; + } + return !ANSWERED.includes(answer.refusal); +} diff --git a/packages/workflow/src/cloudflare/private-schema.ts b/packages/workflow/src/cloudflare/private-schema.ts new file mode 100644 index 000000000..112d69fa9 --- /dev/null +++ b/packages/workflow/src/cloudflare/private-schema.ts @@ -0,0 +1,292 @@ +/** + * The scratch state one acquisition keeps, and nothing else keeps. + * + * Hibernation is why this is in SQLite rather than in a field or an attachment. + * An idle Durable Object is evicted while its sockets stay open, so anything + * held in memory is gone by the time the next message arrives; and the + * attachment is bounded at 16 KiB and is the compact acquisition identity, not + * somewhere to put a growing ledger or a content payload. + * + * Two tables, both keyed by the owner-minted acquisition ID. One remembers what + * each command ID already decided, so a retry returns the decision rather than + * acting twice. The other holds content a runner has offered but nothing has + * adopted. + * + * Neither is run state. Staged bytes are not published content: they are in no + * root, referenced by nothing, invisible to every retained read, and adopting + * them is a later checkpoint's transaction to perform. Both are declared here + * rather than in the shared logical schema for exactly that reason — they are + * this adapter's physical scratch, and a host that had no hibernation would + * need neither. + * + * Recognition checks their exact shapes like any other declared object. Storage + * carrying a table this build did not write is refused rather than tolerated + * because its name looked familiar. + */ + +import { normalize, type SchemaObject } from "../sqlite/workflow-schema.ts"; +import type { OwnerStorage } from "./storage.ts"; + +export const COMMAND_TABLE = "_xmd_executor_commands"; +/** + * Decisions about mutations, which outlive the connection that asked for them. + * + * The acquisition-scoped ledger answers a retry on the same socket. It cannot + * answer the case that matters most: the owner committed, the answer was lost, + * and the connection died. A replacement acquisition discards its predecessor's + * scratch — correctly, because staged bytes and read decisions belong to the + * connection that produced them — but the fact that a mutation was applied is + * not scratch. It is the only thing that lets the next connection tell "this + * already happened" from "this never happened", and without it the same request + * meets a moved frontier and is refused as stale while the runner has no way to + * know whether to promote or discard. + * + * So a mutation decision is keyed by the run rather than the acquisition, and + * cleanup never touches it. It is not a lease and does not expire because a + * socket did. + */ +export const MUTATION_TABLE = "_xmd_run_mutations"; +export const STAGING_TABLE = "_xmd_executor_staging"; + +/** + * Which execution each acquisition began, as the owner knows it. + * + * The runner remembers this too, in the hold it issued, but a runner's memory + * is not authority: a settlement arrives naming an execution, and what decides + * whether this caller may finish it is what the owner retained when that + * execution began. Kept here rather than in a field because an evicted Durable + * Object forgets fields and keeps its sockets, so the association has to be + * where the next message can still find it. + * + * One row per acquisition: one acquisition begins one execution. The row is + * this connection's, so a replacement acquisition discards it and cannot adopt + * the execution it named. + */ +export const HOLD_TABLE = "_xmd_executor_holds"; + +/** + * The parts of a fork one acquisition has offered, before any of it is a run. + * + * A fork source is larger than one message may be, so it crosses in bounded + * parts that name where they belong in the selection, and the final command + * commits them together. Like staged content these are scratch: nothing reads + * them, nothing inherits them, and a replacement acquisition throws them away. + */ +export const FORK_TABLE = "_xmd_executor_fork_parts"; + +const COMMAND_SQL = `CREATE TABLE ${COMMAND_TABLE} ( + acquisition_id TEXT NOT NULL, + command_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL CHECK ( + length(request_fingerprint) = 64 AND request_fingerprint NOT GLOB '*[^0-9a-f]*' + ), + response TEXT NOT NULL CHECK (json_valid(response)), + response_bytes INTEGER NOT NULL CHECK (response_bytes >= 0), + PRIMARY KEY (acquisition_id, command_id) +) STRICT, WITHOUT ROWID`; + +const STAGING_SQL = `CREATE TABLE ${STAGING_TABLE} ( + acquisition_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('manifest', 'blob')), + digest TEXT NOT NULL CHECK ( + length(digest) = 64 AND digest NOT GLOB '*[^0-9a-f]*' + ), + size INTEGER NOT NULL CHECK (size > 0), + bytes BLOB NOT NULL, + PRIMARY KEY (acquisition_id, kind, digest) +) STRICT, WITHOUT ROWID`; + +const HOLD_SQL = `CREATE TABLE ${HOLD_TABLE} ( + acquisition_id TEXT PRIMARY KEY, + execution_id TEXT NOT NULL +) STRICT, WITHOUT ROWID`; + +const FORK_SQL = `CREATE TABLE ${FORK_TABLE} ( + acquisition_id TEXT NOT NULL, + section TEXT NOT NULL CHECK ( + section IN ('inherited', 'roots', 'manifests', 'blobs', 'checkouts') + ), + position INTEGER NOT NULL CHECK (position >= 0), + part TEXT NOT NULL CHECK (json_valid(part)), + part_bytes INTEGER NOT NULL CHECK (part_bytes > 0), + PRIMARY KEY (acquisition_id, section, position) +) STRICT, WITHOUT ROWID`; + +const MUTATION_SQL = `CREATE TABLE ${MUTATION_TABLE} ( + command_id TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL CHECK ( + length(request_fingerprint) = 64 AND request_fingerprint NOT GLOB '*[^0-9a-f]*' + ), + response TEXT NOT NULL CHECK (json_valid(response)), + response_bytes INTEGER NOT NULL CHECK (response_bytes >= 0), + execution_id TEXT +) STRICT, WITHOUT ROWID`; + +const PRIVATE_OBJECTS = new Map([ + [COMMAND_TABLE, { type: "table", sql: COMMAND_SQL }], + [STAGING_TABLE, { type: "table", sql: STAGING_SQL }], + [MUTATION_TABLE, { type: "table", sql: MUTATION_SQL }], + [HOLD_TABLE, { type: "table", sql: HOLD_SQL }], + [FORK_TABLE, { type: "table", sql: FORK_SQL }], +]); + +export const PRIVATE_OBJECT_NAMES: readonly string[] = Object.freeze([...PRIVATE_OBJECTS.keys()]); + +export function initializePrivateSchema(storage: OwnerStorage): void { + if (privateSchemaPresent(storage)) { + // Already here, because a transfer was offered before the run it belongs + // to existed. The scratch is this adapter's own and is not rewritten. + return; + } + storage.sql.exec( + `${COMMAND_SQL};\n\n${STAGING_SQL};\n\n${MUTATION_SQL};\n\n${HOLD_SQL};\n\n${FORK_SQL};`, + ); +} + +/** Whether this adapter's own scratch tables are already declared here. */ +export function privateSchemaPresent(storage: OwnerStorage): boolean { + const names = new Set( + storage.sql + .exec("SELECT name FROM sqlite_schema WHERE type = 'table'") + .toArray() + .map((row) => String(row["name"])), + ); + return PRIVATE_OBJECT_NAMES.every((name) => names.has(name)); +} + +export function privateStructureFailure( + objects: readonly SchemaObject[], +): { kind: "missing" | "misshapen"; name: string } | undefined { + const byName = new Map(objects.map((object) => [object.name, object])); + for (const [name, expected] of PRIVATE_OBJECTS) { + const found = byName.get(name); + if (found === undefined) { + return { kind: "missing", name }; + } + if (found.type !== expected.type || normalize(found.sql) !== normalize(expected.sql)) { + return { kind: "misshapen", name }; + } + } + return undefined; +} + +/** + * Discard what belonged to a connection that is gone. + * + * Staged bytes and read decisions are that connection's scratch and go with it. + * Mutation decisions deliberately do not: they are how the next connection + * learns that a commit already happened, and deleting one would turn a retry + * into a second mutation or a refusal the runner cannot interpret. + */ +export function discardPriorAcquisitions(storage: OwnerStorage, acquisitionId: string): void { + storage.sql.exec(`DELETE FROM ${COMMAND_TABLE} WHERE acquisition_id <> ?`, acquisitionId); + storage.sql.exec(`DELETE FROM ${STAGING_TABLE} WHERE acquisition_id <> ?`, acquisitionId); + // A previous connection's fork parts describe a transfer nobody is going to + // finish, and its execution association belonged to a connection that can no + // longer settle anything. Neither is inherited: what an earlier executor left + // unfinished is decided by recovery, from what the run itself retains. + storage.sql.exec(`DELETE FROM ${FORK_TABLE} WHERE acquisition_id <> ?`, acquisitionId); + storage.sql.exec(`DELETE FROM ${HOLD_TABLE} WHERE acquisition_id <> ?`, acquisitionId); +} + +/** + * Adopt the execution a retained decision began, when nobody else holds it. + * + * The case this exists for: a mutation committed, its answer was lost, the + * connection that asked died, and a replacement acquisition asked the same + * question again. Re-observing the decision is not enough — the execution it + * began has to become this acquisition's, or the caller would be handed a run + * it cannot settle. The old acquisition is already gone by the time this runs, + * because its scratch and its hold went with it. + */ +export function adoptExecution( + storage: OwnerStorage, + acquisitionId: string, + commandId: string, +): "adopted" | "nothing-to-adopt" | "stale" { + const decided = storage.sql + .exec(`SELECT execution_id FROM ${MUTATION_TABLE} WHERE command_id = ?`, commandId) + .toArray()[0]; + const executionId = decided?.["execution_id"]; + if (typeof executionId !== "string") { + // The decision began nothing, so there is nothing to hold. Answering it + // again is answering a question, not granting authority. + return "nothing-to-adopt"; + } + const open = storage.sql + .exec( + "SELECT execution_id FROM document_executions WHERE execution_id = ? AND stopped_at IS NULL", + executionId, + ) + .toArray()[0]; + if (open === undefined) { + // Finished since — recovered by a later executor, or settled. The run has + // moved past this decision, and handing it back as current authority would + // hand back a database nobody may settle. + return "stale"; + } + const holder = storage.sql + .exec(`SELECT acquisition_id FROM ${HOLD_TABLE} WHERE execution_id = ?`, executionId) + .toArray()[0]; + if (holder !== undefined) { + if (holder["acquisition_id"] !== acquisitionId) { + // Somebody live holds it. Two acquisitions cannot hold one execution. + return "stale"; + } + return "adopted"; + } + storage.sql.exec( + `INSERT INTO ${HOLD_TABLE} (acquisition_id, execution_id) VALUES (?, ?)`, + acquisitionId, + executionId, + ); + return "adopted"; +} + +/** + * Which execution one retained decision began, as the ledger recorded it. + * + * Read without adopting anything. The mutation row and the answer it retains + * have to agree about whether a decision granted execution authority, and + * establishing that is not the same act as taking the authority. + */ +export function recordedExecution(storage: OwnerStorage, commandId: string): string | undefined { + const row = storage.sql + .exec(`SELECT execution_id FROM ${MUTATION_TABLE} WHERE command_id = ?`, commandId) + .toArray()[0]; + const recorded = row?.["execution_id"]; + return typeof recorded === "string" ? recorded : undefined; +} + +/** Which execution this acquisition began, when it has begun one. */ +export function heldExecution(storage: OwnerStorage, acquisitionId: string): string | undefined { + const row = storage.sql + .exec(`SELECT execution_id FROM ${HOLD_TABLE} WHERE acquisition_id = ?`, acquisitionId) + .toArray()[0]; + const held = row?.["execution_id"]; + return typeof held === "string" ? held : undefined; +} + +/** + * Record that this acquisition began this execution. + * + * Refuses a second one. An acquisition begins one execution, and the owner is + * where that is decided: a runner that lost track of its own hold cannot talk + * this store into holding two. + */ +export function holdExecution( + storage: OwnerStorage, + acquisitionId: string, + executionId: string, +): void { + storage.sql.exec( + `INSERT INTO ${HOLD_TABLE} (acquisition_id, execution_id) VALUES (?, ?)`, + acquisitionId, + executionId, + ); +} + +/** Let go of the execution this acquisition began, once it is finished. */ +export function releaseExecution(storage: OwnerStorage, acquisitionId: string): void { + storage.sql.exec(`DELETE FROM ${HOLD_TABLE} WHERE acquisition_id = ?`, acquisitionId); +} diff --git a/packages/workflow/src/cloudflare/publish.ts b/packages/workflow/src/cloudflare/publish.ts new file mode 100644 index 000000000..a74ca3167 --- /dev/null +++ b/packages/workflow/src/cloudflare/publish.ts @@ -0,0 +1,773 @@ +/** + * Deciding one proposal, and applying all of it or none of it. + * + * This is where a remote run actually moves. Everything before it is reading + * and staging; everything after it is history. The runner has done the work, + * captured a root, and offered a description of what it wants published — and + * none of that is authority. The owner recomputes every identity, resolves + * every piece against content it already holds or bytes this exact acquisition + * staged, and only then writes. + * + * The order is deliberate and each step exists because skipping it is a way to + * publish something nobody proposed: + * + * 1. The frontier is re-read *here*, inside the transaction, and compared with + * what the runner said it started from — root and terminal event both, + * `null` included exactly. A frontier read before the transaction is a + * frontier that can move before the write. + * 2. The proposed identity is recomputed from the manifest rather than + * believed. An identity is a digest, and a digest a caller supplies is a + * claim about bytes rather than a property of them. + * 3. The inventory must be exactly the closure of that manifest — every + * manifest its file entries name, every blob those manifests name, once + * each, and nothing else. A missing piece is a root that cannot be + * materialized; an extra one is content the root does not account for. + * 4. Each piece resolves from authoritative content or from this acquisition's + * staging. Staging supplies bytes and grants nothing: a digest another + * acquisition staged is not reachable, and a digest already authoritative + * under different bytes is a disagreement rather than an overwrite. + * 5. Content, root, references, mappings, the current pointer and the journal + * rows are written together. The pointer moves by compare-and-set from the + * expected root, so two commits racing the same frontier cannot both win. + * + * Journal rows are associated with the root this commit selected: the proposed + * root when there is a publication, the unchanged expected root when there is + * not. That is the same rule the local host follows, and it is what makes + * history readable against the Workspace it happened in. + * + * Nothing here awaits, yields, sends a frame or contacts the runner. It runs + * inside one synchronous transaction and returns a value the caller serializes + * afterwards. + */ + +import { WorkflowRecordMalformedError } from "../storage/errors.ts"; +import { + compareUtf8, + parseWorkspaceRootManifest, + WORKSPACE_ROOT_DOMAIN, +} from "../workspace/root-manifest.ts"; +import { decodeContentManifest } from "../workspace/content-manifest.ts"; +import { MAX_CONTENT_BYTES } from "./commands.ts"; +import { sha256Hex } from "../workspace/sha256.ts"; +import { CommandError, type CommitCommand, type ProposedMapping } from "./commands.ts"; +import { validateRetainedRoot } from "./owner-reads.ts"; +import { consumeRetainedAnswer, requireAnswerEventsAuthorized } from "./owner-answers.ts"; +import { readRetrieval } from "../sqlite/rows.ts"; +import { bytesOf } from "./encoding.ts"; +import { STAGING_TABLE } from "./private-schema.ts"; +import type { OwnerStorage } from "./storage.ts"; + +/** What the owner answers a performed commit with. */ +export interface CommitValue { + readonly workspaceRootId: string; + readonly journalEventIds: readonly string[]; +} + +function corrupt(reason: string): never { + throw new WorkflowRecordMalformedError("workflow owner storage", reason); +} + +function rows( + storage: OwnerStorage, + sql: string, + ...bindings: unknown[] +): Record[] { + return storage.sql.exec(sql, ...bindings).toArray(); +} + +/** Content identities in the canonical order references are written in. */ +function sortedDigests(digests: Iterable): string[] { + const found = [...digests]; + found.sort(compareUtf8); + return found; +} + +function hexBytes(digest: string): Uint8Array { + const bytes = new Uint8Array(digest.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(digest.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +/** + * The frontier as it is right now, read where the write will happen. + * + * The current root is proved complete by the same validator the read boundary + * uses, not merely read out of the pointer. A commit accepts its starting root + * as the run's frontier, and accepting one whose content graph cannot be + * materialized would append history against a Workspace nothing can restore — + * a proposal is not a licence to repair, so damage is refused here rather than + * worked around. + */ +function frontier(storage: OwnerStorage): { rootId: string; journalEventId: string | null } { + const state = rows(storage, "SELECT current_root_id FROM workspace_state WHERE singleton_id = 1"); + const current = state[0]?.["current_root_id"]; + if (state.length !== 1 || typeof current !== "string") { + return corrupt("the Workspace has no single current root"); + } + validateRetainedRoot(storage, current); + const last = rows( + storage, + "SELECT event_id FROM journal_events ORDER BY sequence DESC LIMIT 1", + )[0]; + const eventId = last?.["event_id"]; + if (last !== undefined && typeof eventId !== "string") { + return corrupt("a journal row has no identity"); + } + return { rootId: current, journalEventId: last === undefined ? null : String(eventId) }; +} + +/** Bytes for one proposed identity, from what is authoritative or what was staged. */ +function resolve( + storage: OwnerStorage, + acquisitionId: string, + kind: "manifest" | "blob", + digest: string, +): { bytes: Uint8Array; authoritative: boolean } { + const table = kind === "manifest" ? "vfs_manifests" : "vfs_blob_bytes"; + const column = kind === "manifest" ? "encoded" : "bytes"; + const authoritative = rows( + storage, + `SELECT ${column} AS content FROM ${table} WHERE lower(hex(hash)) = ?`, + digest, + ); + if (authoritative.length > 1) { + return corrupt("retained content is stored more than once under one identity"); + } + const held = authoritative[0]; + if (held !== undefined) { + const bytes = bytesOf(held["content"]); + if (bytes.length > MAX_CONTENT_BYTES || sha256Hex(bytes) !== digest) { + return corrupt("retained content disagrees with the identity it is stored under"); + } + // The companion row is part of the same fact. A size that disagrees with + // the bytes is damage, and adopting a proposal over it would publish a root + // whose content the read path refuses. + confirmCompanion(storage, kind, digest, bytes); + return { bytes, authoritative: true }; + } + if (kind === "blob") { + // A metadata row with no bytes is a half-written identity. Falling through + // to staging here would complete it as a side effect of a proposal, and + // which durable state won would depend on the write path rather than on + // what the store actually holds. + const partial = rows(storage, "SELECT size FROM vfs_blobs WHERE lower(hex(hash)) = ?", digest); + if (partial.length > 0) { + return corrupt("a retained blob has no bytes"); + } + } + const staged = rows( + storage, + `SELECT bytes FROM ${STAGING_TABLE} WHERE acquisition_id = ? AND kind = ? AND digest = ?`, + acquisitionId, + kind, + digest, + )[0]; + if (staged === undefined) { + // Either never offered, or offered by an acquisition that is not this one. + // Both are the same refusal: this proposal names content this connection + // has not supplied. + throw new CommandError("malformed-member"); + } + const bytes = bytesOf(staged["bytes"]); + if (sha256Hex(bytes) !== digest) { + return corrupt("staged content disagrees with the identity it was stored under"); + } + return { bytes, authoritative: false }; +} + +/** + * The metadata stored beside one content identity, confirmed rather than fixed. + * + * A manifest's recorded size must equal what its chunks add up to; a blob's + * recorded size must equal its bytes; and a blob's byte row and its `vfs_blobs` + * row must both exist. Any disagreement is existing damage, refused here rather + * than silently repaired by an `ON CONFLICT DO NOTHING` that leaves the wrong + * row in place. + */ +function confirmCompanion( + storage: OwnerStorage, + kind: "manifest" | "blob", + digest: string, + bytes: Uint8Array, +): void { + if (kind === "manifest") { + const row = rows( + storage, + "SELECT size FROM vfs_manifests WHERE lower(hex(hash)) = ?", + digest, + )[0]; + const decoded = decodeContentManifest(bytes, corrupt); + if (row === undefined || Number(row["size"]) !== decoded.size) { + return corrupt("a retained manifest disagrees with its recorded size"); + } + return; + } + const row = rows(storage, "SELECT size FROM vfs_blobs WHERE lower(hex(hash)) = ?", digest)[0]; + if (row === undefined || Number(row["size"]) !== bytes.length) { + return corrupt("a retained blob disagrees with its recorded size"); + } +} + +/** + * Apply one proposal, entirely, inside the caller's open transaction. + * + * The caller has already proved the acquisition twice and recognized the store. + * What is left is deciding whether this proposal is true and writing it. + */ +export function applyCommit( + storage: OwnerStorage, + acquisitionId: string, + command: CommitCommand, + mintEventId: () => string, + at: string, +): CommitValue { + const now = frontier(storage); + if (now.rootId !== command.expectedWorkspaceRootId) { + throw new CommandError("stale-root"); + } + if (now.journalEventId !== command.expectedJournalEventId) { + throw new CommandError("stale-journal"); + } + + const selected = + command.publication === null + ? command.expectedWorkspaceRootId + : publish(storage, acquisitionId, command); + + const selectedEntries = directoriesOf( + command.publication === null ? undefined : command.publication.proposedManifest, + ); + // The whole collection is decided before any of it is written. A Worktree may + // name a Repository that arrives in the same proposal, and which of the two + // happens to come first in an array is not a difference between proposals — + // an owner that applied them in order would accept one spelling of a + // transaction and refuse an identical one. + validateMappings(storage, command.mappings, selectedEntries); + // Applied in dependency order rather than the order they arrived in. A + // Worktree row references its Repository, so the parent has to exist when the + // child is written — but which one a proposal happens to list first is not a + // difference between proposals, and the owner decides that rather than making + // the runner arrange an array to suit the schema. + for (const mapping of dependencyOrder(command.mappings)) { + applyMapping(storage, mapping); + } + + // Before the events are written, and inside the same transaction that writes + // them. An answer event is authorized by a consumption or by nothing, so the + // two are checked together: the events this proposal appends must carry + // exactly the answer its consumption spends, and none if it spends none. + requireAnswerEventsAuthorized(command.events, command.answer); + if (command.answer !== null) { + consumeRetainedAnswer(storage, acquisitionId, command.answer, command.events, at); + } + + const journalEventIds: string[] = []; + for (const record of command.events) { + const eventId = mintEventId(); + storage.sql.exec( + "INSERT INTO journal_events (event_id, record, workspace_root_id) VALUES (?, ?, ?)", + eventId, + record, + selected, + ); + journalEventIds.push(eventId); + } + + return { workspaceRootId: selected, journalEventIds }; +} + +/** Adopt the content and the root, and move the pointer to it. */ +function publish(storage: OwnerStorage, acquisitionId: string, command: CommitCommand): string { + const proposal = command.publication; + if (proposal === null) { + return command.expectedWorkspaceRootId; + } + if ( + sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${proposal.proposedManifest}`) !== + proposal.proposedWorkspaceRootId + ) { + throw new CommandError("malformed-member"); + } + const parsed = parseWorkspaceRootManifest(proposal.proposedManifest, () => { + throw new CommandError("malformed-member"); + }); + + // The closure the manifest actually names, derived here rather than taken + // from the inventory the request supplied. + const named = new Set( + parsed.entries.flatMap((entry) => (entry.kind === "file" ? [entry.manifest] : [])), + ); + const offered = new Map( + proposal.content.map((piece) => [`${piece.kind}:${piece.digest}`, piece]), + ); + + const manifests = new Map(); + for (const digest of named) { + const piece = offered.get(`manifest:${digest}`); + if (piece === undefined) { + throw new CommandError("malformed-member"); + } + const { bytes } = resolve(storage, acquisitionId, "manifest", digest); + if (bytes.length !== piece.size) { + throw new CommandError("malformed-member"); + } + manifests.set(digest, bytes); + } + + const blobs = new Map(); + for (const [digest, bytes] of manifests) { + const decoded = decodeContentManifest(bytes, () => { + throw new CommandError("malformed-member"); + }); + for (const entry of parsed.entries) { + if (entry.kind === "file" && entry.manifest === digest && entry.size !== decoded.size) { + throw new CommandError("malformed-member"); + } + } + for (const chunk of decoded.chunks) { + const seen = blobs.get(chunk.hash); + if (seen !== undefined && seen !== chunk.size) { + throw new CommandError("malformed-member"); + } + blobs.set(chunk.hash, chunk.size); + } + } + + // Exactly the closure: nothing missing, nothing extra. + if (offered.size !== named.size + blobs.size) { + throw new CommandError("malformed-member"); + } + + const blobBytes = new Map(); + for (const [digest, size] of blobs) { + const piece = offered.get(`blob:${digest}`); + if (piece === undefined || piece.size !== size) { + throw new CommandError("malformed-member"); + } + const { bytes } = resolve(storage, acquisitionId, "blob", digest); + if (bytes.length !== size) { + throw new CommandError("malformed-member"); + } + blobBytes.set(digest, bytes); + } + + for (const [digest, bytes] of blobBytes) { + const hash = hexBytes(digest); + storage.sql.exec( + "INSERT INTO vfs_blobs (hash, size, last_seen) VALUES (?, ?, 0) ON CONFLICT(hash) DO NOTHING", + hash, + bytes.length, + ); + storage.sql.exec( + "INSERT INTO vfs_blob_bytes (hash, bytes) VALUES (?, ?) ON CONFLICT(hash) DO NOTHING", + hash, + bytes, + ); + } + for (const [digest, bytes] of manifests) { + storage.sql.exec( + `INSERT INTO vfs_manifests (hash, size, encoded, last_seen) VALUES (?, ?, ?, 0) + ON CONFLICT(hash) DO NOTHING`, + hexBytes(digest), + decodeContentManifest(bytes, () => { + throw new CommandError("malformed-member"); + }).size, + bytes, + ); + } + + // Immutable: a root already retained is confirmed rather than rewritten. + const existing = rows( + storage, + "SELECT manifest FROM workspace_roots WHERE root_id = ?", + proposal.proposedWorkspaceRootId, + )[0]; + if (existing === undefined) { + storage.sql.exec( + "INSERT INTO workspace_roots (root_id, format_version, manifest) VALUES (?, 1, ?)", + proposal.proposedWorkspaceRootId, + proposal.proposedManifest, + ); + for (const digest of sortedDigests(named)) { + storage.sql.exec( + "INSERT INTO workspace_root_manifest_refs (root_id, manifest_hash) VALUES (?, ?)", + proposal.proposedWorkspaceRootId, + hexBytes(digest), + ); + } + for (const digest of sortedDigests(blobs.keys())) { + storage.sql.exec( + "INSERT INTO workspace_root_blob_refs (root_id, blob_hash) VALUES (?, ?)", + proposal.proposedWorkspaceRootId, + hexBytes(digest), + ); + } + } else if (existing["manifest"] !== proposal.proposedManifest) { + return corrupt("a retained Workspace root disagrees with the identity it is stored under"); + } + + // Whether it was just written or was already there, the root the pointer is + // about to name is proved to be a complete materializable root — the same + // proof the read boundary applies, so a root cannot be publishable by one + // path and refused by the other. + validateRetainedRoot(storage, proposal.proposedWorkspaceRootId); + + // Compare-and-set. Two commits racing one frontier cannot both move it. + storage.sql.exec( + "UPDATE workspace_state SET current_root_id = ? WHERE singleton_id = 1 AND current_root_id = ?", + proposal.proposedWorkspaceRootId, + command.expectedWorkspaceRootId, + ); + const moved = rows( + storage, + "SELECT current_root_id FROM workspace_state WHERE singleton_id = 1", + )[0]; + if (moved?.["current_root_id"] !== proposal.proposedWorkspaceRootId) { + throw new CommandError("stale-root"); + } + return proposal.proposedWorkspaceRootId; +} + +/** How one mapping is named within a proposal, whatever order it arrives in. */ +function mappingIdentity(mapping: ProposedMapping): string { + if (mapping.kind === "worktree") { + return `worktree:${mapping.record.repositoryName}/${mapping.record.name}`; + } + if (mapping.kind === "repository") { + return `repository:${mapping.record.name}`; + } + return `agent-session:${mapping.record.sessionKey}`; +} + +/** + * Decide the whole mapping collection, before any of it is written. + * + * Identity, duplication, parent relationships and checkout placement are all + * properties of the proposal rather than of one mapping, so they are settled + * here — against every mapping the proposal carries and the Workspace it + * selects. Deciding them one at a time during application would make acceptance + * depend on transport order. + */ +function validateMappings( + storage: OwnerStorage, + mappings: readonly ProposedMapping[], + selectedEntries: ReadonlySet | undefined, +): void { + const named = new Set(); + for (const mapping of mappings) { + const identity = mappingIdentity(mapping); + if (named.has(identity)) { + // One proposal naming one mapping twice cannot be applied once and is not + // two mappings either. + throw new CommandError("mapping-conflict"); + } + named.add(identity); + } + + for (const mapping of mappings) { + if (mapping.kind === "agent-session") { + continue; + } + if (retainedMapping(storage, mapping) !== undefined) { + // Already retained. Whether the proposal agrees with it is confirmed + // where the row is read; a mapping that exists needs no new checkout. + continue; + } + // A new checkout mapping is only true if this proposal publishes the + // Workspace that contains it. + if (selectedEntries === undefined || !selectedEntries.has(mapping.record.checkoutPath)) { + throw new CommandError("mapping-conflict"); + } + if ( + mapping.kind === "worktree" && + rows( + storage, + "SELECT name FROM workspace_repositories WHERE name = ?", + mapping.record.repositoryName, + )[0] === undefined && + !proposesRepository(mappings, mapping.record.repositoryName) + ) { + // A Worktree exists inside a Repository. One that named none — neither + // retained nor arriving in this same proposal — would be a checkout + // belonging to nothing. + throw new CommandError("mapping-conflict"); + } + } +} + +/** The row already retained for one mapping, if there is one. */ +function retainedMapping( + storage: OwnerStorage, + mapping: ProposedMapping, +): Record | undefined { + if (mapping.kind === "repository") { + return rows( + storage, + `SELECT locator, locator_fingerprint, requested_base, creation_commit, primary_branch, + object_format, checkout_path FROM workspace_repositories WHERE name = ?`, + mapping.record.name, + )[0]; + } + if (mapping.kind === "worktree") { + return rows( + storage, + `SELECT requested_branch, requested_base, creation_commit, checkout_path + FROM workspace_worktrees WHERE repository_name = ? AND name = ?`, + mapping.record.repositoryName, + mapping.record.name, + )[0]; + } + return rows( + storage, + `SELECT provider, agent_command, session_identity, policy, + assertion_kind, assertion_value, created_at + FROM agent_sessions WHERE session_key = ?`, + mapping.record.sessionKey, + )[0]; +} + +/** Parents before children, so a proposal's array order carries no meaning. */ +const APPLICATION_ORDER: readonly ProposedMapping["kind"][] = [ + "repository", + "worktree", + "agent-session", +]; + +function dependencyOrder(mappings: readonly ProposedMapping[]): ProposedMapping[] { + const ordered: ProposedMapping[] = []; + for (const kind of APPLICATION_ORDER) { + for (const mapping of mappings) { + if (mapping.kind === kind) { + ordered.push(mapping); + } + } + } + return ordered; +} + +/** Whether this proposal itself supplies the Repository a Worktree names. */ +function proposesRepository(mappings: readonly ProposedMapping[], name: string): boolean { + return mappings.some((mapping) => mapping.kind === "repository" && mapping.record.name === name); +} + +/** Every directory the selected root contains, for placement checks. */ +function directoriesOf(manifest: string | undefined): ReadonlySet | undefined { + if (manifest === undefined) { + return undefined; + } + const parsed = parseWorkspaceRootManifest(manifest, () => { + throw new CommandError("malformed-member"); + }); + return new Set( + parsed.entries.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])), + ); +} + +function sameText(row: Record, column: string, expected: string | null): boolean { + const value = row[column]; + return expected === null ? value === null : value === expected; +} + +/** + * One retained mapping, inserted or confirmed in full. + * + * Creation identity is immutable, so an existing row is compared on every field + * that establishes it — not on a convenient subset. A partial comparison would + * report performed for a proposal that disagrees with what an earlier execution + * established, and the disagreement would only surface later, as a checkout + * that is not what its record says. + */ +function applyMapping(storage: OwnerStorage, mapping: ProposedMapping): void { + if (mapping.kind === "repository") { + const record = mapping.record; + const held = rows( + storage, + `SELECT locator, locator_fingerprint, requested_base, creation_commit, primary_branch, + object_format, checkout_path FROM workspace_repositories WHERE name = ?`, + record.name, + )[0]; + if (held === undefined) { + storage.sql.exec( + `INSERT INTO workspace_repositories + (name, locator, locator_fingerprint, requested_base, creation_commit, + primary_branch, object_format, checkout_path) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + record.name, + mapping.locator, + record.locatorFingerprint, + record.requestedBase, + record.creationCommit, + record.primaryBranch, + record.objectFormat, + record.checkoutPath, + ); + return; + } + if ( + held["locator"] !== mapping.locator || + held["locator_fingerprint"] !== record.locatorFingerprint || + !sameText(held, "requested_base", record.requestedBase) || + held["creation_commit"] !== record.creationCommit || + held["primary_branch"] !== record.primaryBranch || + held["object_format"] !== record.objectFormat || + held["checkout_path"] !== record.checkoutPath + ) { + throw new CommandError("mapping-conflict"); + } + return; + } + + if (mapping.kind === "worktree") { + const record = mapping.record; + const held = rows( + storage, + `SELECT requested_branch, requested_base, creation_commit, checkout_path + FROM workspace_worktrees WHERE repository_name = ? AND name = ?`, + record.repositoryName, + record.name, + )[0]; + if (held === undefined) { + storage.sql.exec( + `INSERT INTO workspace_worktrees + (repository_name, name, requested_branch, requested_base, creation_commit, checkout_path) + VALUES (?, ?, ?, ?, ?, ?)`, + record.repositoryName, + record.name, + record.requestedBranch, + record.requestedBase, + record.creationCommit, + record.checkoutPath, + ); + return; + } + if ( + held["requested_branch"] !== record.requestedBranch || + !sameText(held, "requested_base", record.requestedBase) || + held["creation_commit"] !== record.creationCommit || + held["checkout_path"] !== record.checkoutPath + ) { + throw new CommandError("mapping-conflict"); + } + return; + } + + const record = mapping.record; + const held = rows( + storage, + `SELECT provider, agent_command, session_identity, policy, + assertion_kind, assertion_value, created_at + FROM agent_sessions WHERE session_key = ?`, + record.sessionKey, + )[0]; + if (held === undefined) { + storage.sql.exec( + `INSERT INTO agent_sessions + (session_key, provider, agent_command, session_identity, policy, + assertion_kind, assertion_value, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + record.sessionKey, + record.provider, + record.agentCommand, + record.sessionIdentity, + record.policy, + record.assertion.kind, + record.assertion.value, + record.createdAt, + ); + return; + } + if ( + held["provider"] !== record.provider || + held["agent_command"] !== record.agentCommand || + held["session_identity"] !== record.sessionIdentity || + held["policy"] !== record.policy || + held["assertion_kind"] !== record.assertion.kind || + held["assertion_value"] !== record.assertion.value || + held["created_at"] !== record.createdAt + ) { + throw new CommandError("mapping-conflict"); + } +} + +/** What the owner answers a performed retrieval replacement with. */ +export interface RetrievalValue { + readonly retrieval: { + readonly metadata: unknown; + readonly revision: number; + readonly updatedAt: string; + } | null; +} + +/** + * Replace or clear where this run's definition can be fetched from. + * + * Its own mutation rather than a degenerate commit. Nothing is appended to the + * journal, no root moves, and the revision is the owner's arithmetic over what + * is stored rather than a number the runner proposed — two handles that both + * read revision one before either wrote would otherwise both write two, and the + * second would silently lose the first. + * + * The expected root is revalidated here, inside the transaction that writes, so + * a replacement proposed against a frontier that has moved is refused on the + * same terms a commit is. + */ +export function applyRetrieval( + storage: OwnerStorage, + command: { expectedWorkspaceRootId: string; metadata: string | null }, + now: () => string, +): RetrievalValue { + const state = rows(storage, "SELECT current_root_id FROM workspace_state WHERE singleton_id = 1"); + const current = state[0]?.["current_root_id"]; + if (state.length !== 1 || typeof current !== "string") { + return corrupt("the Workspace has no single current root"); + } + if (current !== command.expectedWorkspaceRootId) { + throw new CommandError("stale-root"); + } + validateRetainedRoot(storage, current); + + if (command.metadata === null) { + // Clearing removes the row. The next replacement starts counting again, + // because a revision counts replacements since the metadata last existed. + storage.sql.exec("DELETE FROM definition_retrieval WHERE id = 1"); + return { retrieval: null }; + } + + const held = rows(storage, "SELECT revision FROM definition_retrieval WHERE id = 1")[0]; + const revision = held === undefined ? 1 : safeRevision(held["revision"]) + 1; + const updatedAt = now(); + storage.sql.exec( + `INSERT INTO definition_retrieval (id, metadata, revision, updated_at) VALUES (1, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET metadata = excluded.metadata, + revision = excluded.revision, updated_at = excluded.updated_at`, + command.metadata, + revision, + updatedAt, + ); + + const written = rows( + storage, + "SELECT metadata, revision, updated_at FROM definition_retrieval WHERE id = 1", + )[0]; + if (written === undefined) { + return corrupt("a retrieval replacement wrote no row"); + } + // Read back and parsed, so the answer describes what is actually stored. + const parsed = readRetrieval(written); + return { + retrieval: { + metadata: parsed.metadata, + revision: parsed.revision, + updatedAt: parsed.updatedAt, + }, + }; +} + +function safeRevision(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) { + return corrupt("a retained retrieval revision is not a positive whole number"); + } + return value; +} diff --git a/packages/workflow/src/cloudflare/read-client.ts b/packages/workflow/src/cloudflare/read-client.ts new file mode 100644 index 000000000..07c49f198 --- /dev/null +++ b/packages/workflow/src/cloudflare/read-client.ts @@ -0,0 +1,1042 @@ +/** + * Reading a run's owner from the runner, over the no-acquisition plane. + * + * Every answer is parsed before it is believed, and a paged answer is held to + * the anchor its first page chose: a missing, repeated, reordered, wrong-run or + * changed-anchor page fails the whole read rather than producing a shorter + * history nobody asked for. Nothing partial is ever published. + * + * The transport is narrow on purpose. It sends one request and returns one + * response, and knows nothing about runs, anchors or authority — a host wires + * it to an ordinary HTTP request, and a test wires it to the object directly. + */ + +import { Err, Ok, type Operation, type Result } from "effection"; +import { parseMembers, requireMemberNames } from "../storage/members.ts"; +import type { WorkflowStorageError } from "../storage/errors.ts"; +import { WorkflowRecordMalformedError, WorkflowRunNotFoundError } from "../storage/errors.ts"; +import { type DurableEvent, parseDurableEvent } from "@executablemd/durable-streams"; +import type { + DefinitionRetrieval, + DocumentExecutionRecord, + WorkflowRunRecord, +} from "../storage/record.ts"; +import { + parseRemoteExecution, + parseRemoteRetrieval, + parseRemoteRunRecord, + RemoteRecordError, +} from "../remote/records.ts"; +import { READ_PAGE_BYTES, READ_PAGE_ENTRIES, READ_REQUEST_ENVELOPE } from "./read-plane.ts"; +import { decodeBase64, sha256Hex } from "./encoding.ts"; +import { + compareUtf8, + parseWorkspaceRootManifest, + SHA256, + WORKSPACE_ROOT_DOMAIN, + WORKSPACE_ROOT_FORMAT, +} from "../workspace/root-manifest.ts"; +import { decodeContentManifest } from "../workspace/content-manifest.ts"; +import type { + RemoteBlob, + RemoteCheckout, + RemoteForkSource, + RemoteManifest, + RemoteReadPlane, + RemoteStoredRoot, + RetainedHistory, + RetainedInspection, + RetainedProvenance, + RetainedRow, +} from "../remote/read.ts"; +import { type PrivateRefusal, privateRefusal, storageFailure } from "./client.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 ReadTransport { + send(admission: ReadAdmission, body: string): Operation; +} + +/** What a request carries outside its body. */ +export interface ReadAdmission { + readonly release: string; + readonly token: string; + readonly runId: string; +} + +/** The most pages one answer may take before it is refused as unbounded. */ +const MAX_PAGES = 4096; + +/** + * The most serialized bytes one fork-source answer may carry. + * + * Measured over the finished UTF-8 encoding, and derived from what a page may + * hold rather than picked: one page of rows, the checkpoint the answer names — + * an event id, bounded by the journal row that carries it being a member — and + * the fixed envelope of anchors, roots, positions and counts around them. The + * rows are nested values rather than strings inside a string, so what a page + * measured is what an answer carries. A smaller number would make a retained + * selection this build accepts impossible to read back. + */ +export const FORK_SOURCE_ANSWER_BYTES = 2 * READ_PAGE_BYTES + READ_REQUEST_ENVELOPE; + +/** + * The most serialized bytes one public answer may carry. + * + * Public history and inspection are paged by count rather than by bytes, and + * one retained record may be as large as the transaction that wrote it, so + * what bounds these is not what bounds a fork source. This is the capacity + * these operations already had, kept as it was: a fork source's own arithmetic + * is a fact about fork-source pages, and adopting it here would make retained + * history that this plane could read unreadable. + */ +export const PUBLIC_ANSWER_BYTES = 1638400; + +/** + * Which ceiling one answer is held to, decided by what was asked. + * + * The operation is this build's own, chosen before the request is sent, so an + * answer never selects the bound it is measured against. + */ +function answerBytes(operation: unknown): number { + return operation === "fork-source" ? FORK_SOURCE_ANSWER_BYTES : PUBLIC_ANSWER_BYTES; +} + +function fail(reason: string): never { + throw new RemoteRecordError(`the owner returned a malformed read answer: ${reason}`); +} + +function failure(reason: string, path: string): Error { + return new RemoteRecordError(`the owner returned a malformed read answer at ${path}: ${reason}`); +} + +function members(value: unknown, names: readonly string[]): Map { + const found = parseMembers(value, "$", failure); + requireMemberNames(found, names, "$", failure); + return found; +} + +function text(value: unknown, what: string): string { + if (typeof value !== "string" || value === "") { + return fail(`it did not name ${what}`); + } + return value; +} + +function list(value: unknown, what: string): unknown[] { + if (!Array.isArray(value)) { + return fail(`it did not carry ${what}`); + } + return value; +} + +/** + * Open one read plane over this transport. + * + * `expectedRunId` is what every answer is held to. An owner that answered about + * another run is not this run's owner, whatever the answer says. + */ +export function cloudflareReadPlane( + transport: ReadTransport, + release: string, + token: () => Operation, + expectedRunId: string, +): RemoteReadPlane { + function* ask(read: Record): Operation { + const admission = { release, token: yield* token(), runId: expectedRunId }; + const raw = yield* transport.send(admission, JSON.stringify(read)); + if (new TextEncoder().encode(raw).length > answerBytes(read["operation"])) { + // An answer larger than one may be is not one this build reads, and + // reading it far enough to find out would be reading it. + return fail("it exceeded the bytes one answer may carry"); + } + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + // Invalid JSON becomes the same bounded malformed-owner failure that + // every unreadable answer does; no parser diagnostic escapes. + return fail("it was not one JSON object"); + } + const answer = members(decoded, ["outcome", "value", "refusal"]); + if (answer.get("outcome") === "refused") { + // Parsed into this build's closed vocabulary before it becomes an error, + // so no refusal spelling reaches a caller. + const refusal = privateRefusal(text(answer.get("refusal"), "a refusal")); + // Nothing stored here is a different fact from storage this build cannot + // use, and a caller listing an owner acts on the difference. + throw refusal === "command:absent" + ? new WorkflowRunNotFoundError(expectedRunId) + : storageFailure(refusal); + } + if (answer.get("outcome") !== "performed") { + return fail("it named no outcome this build reads"); + } + return answer.get("value"); + } + + return { + runId: expectedRunId, + + *inspect(): Operation> { + try { + return Ok(parseInspection(yield* ask({ operation: "inspect" }), expectedRunId)); + } catch (error) { + return Err(translateRead(error)); + } + }, + + *history(): Operation> { + try { + return Ok(yield* pages(ask, (anchor, after) => ({ operation: "history", anchor, after }))); + } catch (error) { + return Err(translateRead(error)); + } + }, + + *forkSource(checkpointEventId: string): Operation> { + try { + return Ok(yield* collectForkSource(ask, expectedRunId, checkpointEventId)); + } catch (error) { + return Err(translateRead(error)); + } + }, + }; +} + +function parseEvent(record: string): DurableEvent { + const parsed = parseDurableEvent(record); + if (!parsed.ok) { + return fail("it carried a retained event this build cannot read"); + } + return parsed.value; +} + +/** + * Walk one anchored page sequence to its end, or refuse the whole answer. + * + * The first page chooses the anchor and every later page is held to it and to + * the cursor it was asked to continue from. A page that skips, repeats, + * reorders or changes the anchor is a page of some other snapshot, and there is + * no partial answer to give. + */ +function* pages( + ask: (read: Record) => Operation, + request: (anchor: string | null, after: string | null) => Record, +): Operation { + const entries: RetainedRow[] = []; + const seen = new Set(); + let anchor: string | null | undefined; + let after: string | null = null; + let retainedRoots: ReadonlySet = new Set(); + let inherited: ReadonlyMap = new Map(); + + for (let page = 0; ; page += 1) { + if (page > MAX_PAGES) { + return fail("it did not terminate its anchored answer"); + } + const found = members(yield* ask(request(anchor ?? null, after)), [ + "anchor", + "after", + "rows", + "done", + "retainedRoots", + "provenance", + ]); + const offered = found.get("anchor"); + const expected = anchor === undefined ? offered : anchor; + if (offered !== expected || found.get("after") !== after) { + return fail("a page did not continue its anchored snapshot"); + } + anchor = offered === null ? null : text(offered, "an anchor"); + const rows = list(found.get("rows"), "rows"); + if (rows.length > READ_PAGE_ENTRIES) { + return fail("a page carried more rows than one may"); + } + if (anchor === null) { + if (rows.length > 0 || found.get("done") !== true || after !== null) { + return fail("an empty snapshot carried rows or did not terminate"); + } + return { entries, retainedRoots, inherited }; + } + if (rows.length === 0) { + return fail("a page of an anchored snapshot carried no rows"); + } + for (const row of rows) { + const entry = members(row, ["eventId", "record", "workspaceRootId"]); + const eventId = text(entry.get("eventId"), "an event"); + if (seen.has(eventId)) { + return fail("a page repeated an event"); + } + seen.add(eventId); + entries.push({ + eventId, + event: parseEvent(text(entry.get("record"), "a retained record")), + workspaceRootId: text(entry.get("workspaceRootId"), "a Workspace root"), + }); + after = eventId; + } + if (found.get("done") !== true) { + continue; + } + if (after !== anchor) { + return fail("a page terminated short of its anchor"); + } + retainedRoots = new Set( + list(found.get("retainedRoots"), "retained roots").map((root) => text(root, "a root")), + ); + inherited = new Map( + list(found.get("provenance"), "provenance").map((row) => { + const entry = members(row, ["eventId", "sourceRunId", "sourceEventId"]); + return [ + text(entry.get("eventId"), "an event"), + Object.freeze({ + sourceRunId: text(entry.get("sourceRunId"), "a source run"), + sourceEventId: text(entry.get("sourceEventId"), "a source event"), + }), + ]; + }), + ); + return { entries, retainedRoots, inherited }; + } +} + +function parseInspection(value: unknown, expectedRunId: string): RetainedInspection { + const found = members(value, [ + "record", + "executions", + "retrieval", + "journalFrontier", + "currentWorkspaceRootId", + "lineage", + ]); + const record = parseRemoteRunRecord(found.get("record")); + if (record.runId !== expectedRunId) { + return fail("it described another run"); + } + const executions = list(found.get("executions"), "executions").map((entry) => + parseRemoteExecution(entry), + ); + const frontier = found.get("journalFrontier"); + const lineage = found.get("lineage"); + const retrieval = parseRemoteRetrieval(found.get("retrieval")); + + return Object.freeze({ + record, + executions: Object.freeze(executions), + ...(retrieval === undefined ? {} : { retrieval }), + ...(frontier === null + ? {} + : { + journalFrontier: Object.freeze({ + eventId: text( + members(frontier, ["eventId", "workspaceRootId"]).get("eventId"), + "an event", + ), + workspaceRootId: text( + members(frontier, ["eventId", "workspaceRootId"]).get("workspaceRootId"), + "a Workspace root", + ), + }), + }), + currentWorkspaceRootId: text(found.get("currentWorkspaceRootId"), "a Workspace root"), + ...(lineage === null + ? {} + : { + lineage: Object.freeze(parseLineage(lineage)), + }), + }); +} + +function parseLineage(value: unknown) { + const found = members(value, ["sourceRunId", "checkpointEventId", "checkpointWorkspaceRootId"]); + return { + sourceRunId: text(found.get("sourceRunId"), "a source run"), + checkpointEventId: text(found.get("checkpointEventId"), "a checkpoint"), + checkpointWorkspaceRootId: text(found.get("checkpointWorkspaceRootId"), "a Workspace root"), + }; +} + +/** Any failure from the read plane, as a provider-neutral one. */ +function translateRead(error: unknown): WorkflowStorageError { + if (error instanceof WorkflowRecordMalformedError || error instanceof WorkflowRunNotFoundError) { + return error; + } + if (error instanceof RemoteRecordError) { + return new WorkflowRecordMalformedError( + "record this run's owner returned", + "it is not a record this build can read", + ); + } + return storageFailure(readRefusal(error)); +} + +function readRefusal(error: unknown): PrivateRefusal { + return error instanceof Error && "refusal" in error + ? privateRefusal(String(Reflect.get(error, "refusal"))) + : "command:unavailable"; +} + +/** + * Read every section of one fork source, and hold them all to one selection. + * + * Each section is its own anchored sequence; the anchor covers the whole + * selection rather than the checkpoint alone, so a section that arrived from a + * different selection is refused rather than mixed in. The head members are + * checked to agree across every page of every section for the same reason. + */ +function* collectForkSource( + ask: (read: Record) => Operation, + sourceRunId: string, + checkpointEventId: string, +): Operation { + let head: Map | undefined; + let anchor: string | undefined; + + /** + * Read one section to its end, holding every page to the same selection. + * + * The cursor is not taken on trust: it must be the identity of the last row + * the page actually carried, so a page cannot advance past rows it did not + * send, and each page must begin where the sequence has reached. Identities + * are unique within a section, and the sorted sections are required to + * arrive in the order they are sorted in, so a sequence cannot repeat, skip + * or reorder the members a destination will retain. The declared total is + * pinned by the first page and must describe what finally arrived. + */ + function* section(name: string, order: "sorted" | "journal"): Operation { + const carried: unknown[] = []; + const seen = new Set(); + let after: number | null = null; + let declared: number | undefined; + let previous: readonly string[] | undefined; + + for (let page = 0; ; page += 1) { + if (page > MAX_PAGES) { + return fail("it did not terminate a fork-source section"); + } + const found = members( + yield* ask({ + operation: "fork-source", + checkpointEventId, + section: name, + anchor: anchor ?? null, + after, + }), + [ + "anchor", + "after", + "section", + "checkpointEventId", + "checkpointWorkspaceRootId", + "runRecordWorkspaceRootId", + "rootImportWorkspaceRootId", + "rows", + "from", + "cursor", + "done", + "total", + ], + ); + const offered = text(found.get("anchor"), "a selection anchor"); + if (anchor === undefined) { + anchor = offered; + head = found; + } + if ( + offered !== anchor || + found.get("section") !== name || + found.get("after") !== after || + found.get("checkpointEventId") !== checkpointEventId || + found.get("checkpointWorkspaceRootId") !== head?.get("checkpointWorkspaceRootId") || + found.get("runRecordWorkspaceRootId") !== head?.get("runRecordWorkspaceRootId") || + found.get("rootImportWorkspaceRootId") !== head?.get("rootImportWorkspaceRootId") + ) { + return fail("a page did not continue its selection"); + } + const total = count(found.get("total"), "a section total"); + if (declared === undefined) { + declared = total; + } + if (total !== declared) { + // The section changed size under the sequence, so its pages describe + // two different answers. + return fail("a page redeclared the size of its section"); + } + + const rows = list(found.get("rows"), "rows"); + if (rows.length > READ_PAGE_ENTRIES) { + return fail("a page carried more rows than one may"); + } + // Where the owner says this page begins has to be where the sequence + // has got to. A page beginning anywhere else skipped members or sent + // some of them twice, whatever its cursor says. + if (count(found.get("from"), "a section position") !== carried.length) { + return fail("a page did not begin where the sequence had reached"); + } + for (const row of rows) { + const identity = identityOf(name, row); + if (seen.has(identity)) { + return fail("a section repeated a member"); + } + if (order === "journal") { + // The one order nothing here can derive, so the owner states it per + // member and it must advance by one from where the section stood. + // Two rows swapped inside a page carry each other's positions. + if (positionOf(row) !== carried.length) { + return fail("a member did not stand where the section had reached"); + } + } + if (order === "sorted") { + const sorts = orderOf(name, row); + if (previous !== undefined && !precedes(previous, sorts)) { + return fail("a section carried its members out of order"); + } + previous = sorts; + } + seen.add(identity); + carried.push(row); + } + + // Derived from what arrived, never believed: the page ended on the last + // row it actually carried, so that is the only position it may name. + const ended: number | null = rows.length === 0 ? after : carried.length - 1; + const cursor = found.get("cursor"); + if (found.get("done") === true) { + if (carried.length !== declared) { + return fail("a section terminated short of what it declared"); + } + // A terminal page still describes where it ended. + if (cursor !== ended) { + return fail("a terminal page did not name the member it ended on"); + } + return carried; + } + if (rows.length === 0) { + return fail("a page of an unfinished section carried no rows"); + } + if (cursor !== ended) { + return fail("a page did not advance to the member it ended on"); + } + after = ended; + } + } + + const inherited = (yield* section("inherited", "journal")).map((row) => { + const found = members(row, ["eventId", "record", "workspaceRootId", "position"]); + const record = text(found.get("record"), "a retained record"); + // Parsed to prove it is a record this build can read, and then kept + // exactly: a destination inserts these bytes, and a spelling reconstructed + // from the parse would be a different history. + parseEvent(record); + return { + eventId: text(found.get("eventId"), "an event"), + record, + workspaceRootId: digest(found.get("workspaceRootId"), "a Workspace root"), + }; + }); + // These four arrive sorted by identities this side can derive, so a + // reordering is a sequence this build did not produce. The inherited rows + // arrive in the source's own journal order, which nothing here can derive: + // what holds them is that each page begins where the sequence reached, that + // no identity arrives twice, and that the whole selection is anchored. + const roots = (yield* section("roots", "sorted")).map((row) => parseStoredRoot(row)); + const manifests = (yield* section("manifests", "sorted")).map((row) => parseManifest(row)); + const blobs = (yield* section("blobs", "sorted")).map((row) => parseBlob(row)); + const checkouts = (yield* section("checkouts", "sorted")).map((row) => parseCheckout(row)); + + validateClosure(inherited, roots, manifests, blobs); + + const checkpointWorkspaceRootId = digest( + head?.get("checkpointWorkspaceRootId"), + "a checkpoint Workspace root", + ); + const runRecordWorkspaceRootId = digest( + head?.get("runRecordWorkspaceRootId"), + "a Workspace root", + ); + const rootImportWorkspaceRootId = digest( + head?.get("rootImportWorkspaceRootId"), + "a Workspace root", + ); + // All three heads, not just the checkpoint. A destination writes its own run + // record and root import against these, and it cannot write against a root + // it was not given. + const carried = new Set(roots.map((root) => root.rootId)); + for (const rootId of [ + checkpointWorkspaceRootId, + runRecordWorkspaceRootId, + rootImportWorkspaceRootId, + ]) { + if (!carried.has(rootId)) { + return fail("a head names a Workspace root the selection did not carry"); + } + } + validateCheckouts(checkouts, roots, checkpointWorkspaceRootId); + return Object.freeze({ + sourceRunId, + anchor: anchor ?? fail("it answered no page of the selection"), + checkpointEventId, + checkpointWorkspaceRootId, + runRecordWorkspaceRootId, + rootImportWorkspaceRootId, + inherited: Object.freeze(inherited), + roots: Object.freeze(roots), + manifests: Object.freeze(manifests), + blobs: Object.freeze(blobs), + checkouts: Object.freeze(checkouts), + }); +} + +/** + * Prove the transported bytes really are the Workspace this selection names. + * + * A digest proves its own bytes, so a manifest or blob that arrived under the + * wrong name is caught where it is parsed. What is left is the shape of the + * closure: every root the prefix names is here, every root's own manifest + * derives exactly the references it was sent with, every content manifest a + * root needs is here and decodes to the size it claims, every chunk those + * manifests name is a blob that is here, and nothing arrived that the selection + * does not need. Anything less would let a destination retain a history whose + * Workspace cannot be restored. + */ +function validateClosure( + inherited: readonly { readonly workspaceRootId: string }[], + roots: readonly RemoteStoredRoot[], + manifests: readonly RemoteManifest[], + blobs: readonly RemoteBlob[], +): void { + const held = new Set(roots.map((root) => root.rootId)); + for (const row of inherited) { + if (!held.has(row.workspaceRootId)) { + return fail("a selected row names a Workspace root the selection did not carry"); + } + } + + const manifestsByHash = new Map(manifests.map((manifest) => [manifest.hash, manifest])); + const blobsByHash = new Map(blobs.map((blob) => [blob.hash, blob])); + if (manifestsByHash.size !== manifests.length || blobsByHash.size !== blobs.length) { + return fail("the selection carried one piece twice"); + } + + // Derived from the roots themselves rather than believed: a reference set the + // owner sent is only correct if the root's own manifest produces it. + for (const blob of blobs) { + // The bytes themselves, against the size the destination will persist + // beside them. The digest proves which bytes these are and the chunk + // comparison proves the manifest agrees, but two coordinated wrong sizes + // satisfy both — only the actual length settles it. + if (blob.content.byteLength !== blob.size) { + return fail("a selected blob disagreed with its recorded size"); + } + } + + const neededManifests = new Set(); + const neededBlobs = new Set(); + for (const root of roots) { + const parsed = parseWorkspaceRootManifest(root.manifest, (reason) => + fail(`a selected root is not a canonical Workspace root: ${reason}`), + ); + const declared = new Set(); + const sizes = new Map(); + for (const entry of parsed.entries) { + if (entry.kind === "file") { + declared.add(entry.manifest); + const already = sizes.get(entry.manifest); + if (already !== undefined && already !== entry.size) { + // Two entries sharing one content identity must describe the same + // bytes, so they cannot claim different sizes. + return fail("a selected root gave one content two sizes"); + } + sizes.set(entry.manifest, entry.size); + } + } + if (!sameOrder(canonical(declared), root.manifestHashes)) { + return fail("a selected root disagreed with the content references it carried"); + } + const rootBlobs = new Set(); + for (const hash of declared) { + neededManifests.add(hash); + const manifest = manifestsByHash.get(hash); + if (manifest === undefined) { + return fail("a selected root names a manifest the selection did not carry"); + } + const content = decodeContentManifest(manifest.encoded, (reason) => + fail(`a selected manifest is not canonically encoded: ${reason}`), + ); + if (content.size !== manifest.size) { + return fail("a selected manifest disagreed with the size it describes"); + } + // And with the file the root says it holds. A destination restores that + // entry from these bytes, so a root claiming another length describes a + // Workspace this content cannot produce. + if (sizes.get(hash) !== content.size) { + return fail("a selected root disagreed with the content it names"); + } + for (const chunk of content.chunks) { + rootBlobs.add(chunk.hash); + neededBlobs.add(chunk.hash); + const blob = blobsByHash.get(chunk.hash); + if (blob === undefined) { + return fail("a selected manifest names a blob the selection did not carry"); + } + if (blob.size !== chunk.size) { + return fail("a selected chunk disagreed with the blob it names"); + } + } + } + if (!sameOrder(canonical(rootBlobs), root.blobHashes)) { + return fail("a selected root disagreed with the blob references it carried"); + } + } + + // Nothing beyond the closure: an extra piece is data the selection does not + // account for, and a destination retaining it would hold content no root of + // its own refers to. + if (!sameSet(neededManifests, new Set(manifestsByHash.keys()))) { + return fail("the selection carried a manifest no selected root requires"); + } + if (!sameSet(neededBlobs, new Set(blobsByHash.keys()))) { + return fail("the selection carried a blob no selected manifest requires"); + } +} + +/** + * Prove the checkouts are a graph a destination can retain. + * + * Each one names a directory the checkpoint's Workspace actually holds, so a + * fork does not inherit a checkout with nowhere to live; each Worktree names a + * Repository that came with it; and nothing is named twice, because the + * retained schema keys these by name and by path. + */ +function validateCheckouts( + checkouts: readonly RemoteCheckout[], + roots: readonly RemoteStoredRoot[], + checkpointWorkspaceRootId: string, +): void { + const checkpoint = roots.find((root) => root.rootId === checkpointWorkspaceRootId); + if (checkpoint === undefined) { + return fail("the checkpoint names a Workspace root the selection did not carry"); + } + const directories = new Set(); + for (const entry of parseWorkspaceRootManifest(checkpoint.manifest, (reason) => + fail(`a selected root is not a canonical Workspace root: ${reason}`), + ).entries) { + if (entry.kind === "directory") { + directories.add(entry.path); + } + } + + const repositories = new Set(); + const worktrees = new Set(); + const paths = new Set(); + for (const checkout of checkouts) { + if (!directories.has(checkout.checkoutPath)) { + return fail("a selected checkout names a directory the checkpoint Workspace does not hold"); + } + if (paths.has(checkout.checkoutPath)) { + return fail("two selected checkouts name one directory"); + } + paths.add(checkout.checkoutPath); + if (checkout.kind === "repository") { + if (repositories.has(checkout.name)) { + return fail("one Repository name was selected twice"); + } + repositories.add(checkout.name); + } + } + for (const checkout of checkouts) { + if (checkout.kind !== "worktree") { + continue; + } + const identity = checkoutKey(["worktree", checkout.repositoryName, checkout.name]); + if (worktrees.has(identity)) { + return fail("one Worktree identity was selected twice"); + } + worktrees.add(identity); + if (!repositories.has(checkout.repositoryName)) { + // A checkout belonging to nothing. The destination would retain a + // Worktree of a Repository it does not have. + return fail("a selected Worktree names a Repository the selection did not carry"); + } + } +} + +/** + * A root's references, in the one order a root is retained with. + * + * The same derivation the Workspace root implementation makes: the identities + * a root's own manifest produces, deduplicated, ordered by their UTF-8 bytes. + */ +function canonical(references: ReadonlySet): string[] { + return [...references].sort(compareUtf8); +} + +/** + * Element for element, not member for member. + * + * A destination compares a root's reference arrays exactly when it retains + * them, so an array that is the right set in the wrong order — or with one + * identity twice — is a source it would refuse later. It is refused here + * instead, where the whole selection can still be rejected. + */ +function sameOrder(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, at) => value === right[at]); +} + +function sameSet(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false; + } + for (const value of left) { + if (!right.has(value)) { + return false; + } + } + return true; +} + +/** + * What names one member of a section. + * + * The same key the owner pages by, derived here from the row itself so a + * cursor is checked against what arrived rather than taken from beside it. + */ +function identityOf(section: string, row: unknown): string { + const found = parseMembers(row, "$", failure); + if (section === "inherited") { + return text(found.get("eventId"), "an event"); + } + if (section === "roots") { + return text(found.get("rootId"), "a Workspace root"); + } + if (section === "manifests" || section === "blobs") { + return text(found.get("hash"), "a content identity"); + } + if (found.get("kind") === "repository") { + return checkoutKey(["repository", text(found.get("name"), "a name")]); + } + return checkoutKey([ + "worktree", + text(found.get("repositoryName"), "a Repository"), + text(found.get("name"), "a name"), + ]); +} + +/** + * One checkout's identity, as a key nothing else can spell. + * + * The owner pages by this exact string. Repository and Worktree names are + * retained text and may hold any character, so `("a:b", "c")` and + * `("a", "b:c")` are two retained Worktrees that a separator would join into + * one key: the client would refuse a valid source as a duplicate, or continue + * from the wrong member. A JSON array separates the parts it escapes. + */ +function checkoutKey(parts: readonly string[]): string { + return JSON.stringify(parts); +} + +/** Where one member of a journal-ordered section stands in its selection. */ +function positionOf(row: unknown): number { + return count(parseMembers(row, "$", failure).get("position"), "a position"); +} + +/** + * How the owner sorted this section, as the parts it sorted by. + * + * Compared part by part rather than as one string: the checkouts are sorted by + * a Repository name and then a Worktree name, and joining those into one string + * would order two names differently than sorting them separately does. + */ +function orderOf(section: string, row: unknown): readonly string[] { + const found = parseMembers(row, "$", failure); + if (section === "roots") { + return [text(found.get("rootId"), "a Workspace root")]; + } + if (section === "manifests" || section === "blobs") { + return [text(found.get("hash"), "a content identity")]; + } + if (found.get("kind") === "repository") { + return ["0", text(found.get("name"), "a name")]; + } + return [ + "1", + text(found.get("repositoryName"), "a Repository"), + text(found.get("name"), "a name"), + ]; +} + +/** Whether one member sorts strictly before another, by UTF-8 bytes. */ +function precedes(previous: readonly string[], next: readonly string[]): boolean { + for (let at = 0; at < Math.max(previous.length, next.length); at += 1) { + const left = previous[at]; + const right = next[at]; + if (left === undefined || right === undefined) { + return right !== undefined; + } + const order = compareUtf8(left, right); + if (order !== 0) { + return order < 0; + } + } + return false; +} + +function parseStoredRoot(value: unknown): RemoteStoredRoot { + const found = members(value, [ + "rootId", + "formatVersion", + "manifest", + "manifestHashes", + "blobHashes", + ]); + const manifest = text(found.get("manifest"), "a root manifest"); + const rootId = digest(found.get("rootId"), "a Workspace root"); + if (found.get("formatVersion") !== WORKSPACE_ROOT_FORMAT) { + return fail("a selected root is not the Workspace root format this build writes"); + } + // The identity is the hash of the bytes, so a root that did not produce it + // is not the root it says it is. + if (sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${manifest}`) !== rootId) { + return fail("a selected root disagreed with its own identity"); + } + return Object.freeze({ + rootId, + formatVersion: count(found.get("formatVersion"), "a format version"), + manifest, + manifestHashes: Object.freeze( + list(found.get("manifestHashes"), "manifest hashes").map((hash) => + digest(hash, "a manifest"), + ), + ), + blobHashes: Object.freeze( + list(found.get("blobHashes"), "blob hashes").map((hash) => digest(hash, "a blob")), + ), + }); +} + +function parseManifest(value: unknown): RemoteManifest { + const found = members(value, ["hash", "size", "lastSeen", "encoded"]); + const encoded = decodeBase64(text(found.get("encoded"), "encoded bytes")); + const hash = digest(found.get("hash"), "a manifest"); + // Content-addressed, so the bytes decide the name here exactly as they do + // for a blob. A manifest under the wrong name is not the manifest a root + // referred to. + if (sha256Hex(encoded) !== hash) { + return fail("a selected manifest disagreed with its own identity"); + } + return Object.freeze({ + hash, + size: count(found.get("size"), "a size"), + lastSeen: count(found.get("lastSeen"), "a timestamp"), + encoded, + }); +} + +function parseBlob(value: unknown): RemoteBlob { + const found = members(value, ["hash", "size", "lastSeen", "content"]); + const content = decodeBase64(text(found.get("content"), "content bytes")); + const hash = digest(found.get("hash"), "a blob"); + // Content-addressed, so the bytes decide the name. A piece whose bytes do + // not hash to it is not the piece the root referred to. + if (sha256Hex(content) !== hash) { + return fail("a selected blob disagreed with its own identity"); + } + return Object.freeze({ + hash, + size: count(found.get("size"), "a size"), + lastSeen: count(found.get("lastSeen"), "a timestamp"), + content, + }); +} + +function parseCheckout(value: unknown): RemoteCheckout { + const probe = parseMembers(value, "$", failure); + if (probe.get("kind") === "repository") { + const found = members(value, [ + "kind", + "name", + "locator", + "locatorFingerprint", + "requestedBase", + "creationCommit", + "primaryBranch", + "objectFormat", + "checkoutPath", + ]); + return Object.freeze({ + kind: "repository", + name: text(found.get("name"), "a name"), + locator: text(found.get("locator"), "a locator"), + locatorFingerprint: digest(found.get("locatorFingerprint"), "a fingerprint"), + requestedBase: optional(found.get("requestedBase")), + creationCommit: text(found.get("creationCommit"), "a commit"), + primaryBranch: text(found.get("primaryBranch"), "a branch"), + objectFormat: objectFormat(found.get("objectFormat")), + checkoutPath: workspacePath(found.get("checkoutPath")), + }); + } + const found = members(value, [ + "kind", + "repositoryName", + "name", + "requestedBranch", + "requestedBase", + "creationCommit", + "checkoutPath", + ]); + if (found.get("kind") !== "worktree") { + return fail("a selected checkout named no kind this build reads"); + } + return Object.freeze({ + kind: "worktree", + repositoryName: text(found.get("repositoryName"), "a Repository"), + name: text(found.get("name"), "a name"), + requestedBranch: text(found.get("requestedBranch"), "a branch"), + requestedBase: optional(found.get("requestedBase")), + creationCommit: text(found.get("creationCommit"), "a commit"), + checkoutPath: workspacePath(found.get("checkoutPath")), + }); +} + +/** One of the object formats this build writes. */ +function objectFormat(value: unknown): "sha1" | "sha256" { + const candidate = text(value, "an object format"); + if (candidate !== "sha1" && candidate !== "sha256") { + return fail("it did not name an object format"); + } + return candidate; +} + +/** A Workspace-relative path, which is absolute within the Workspace. */ +function workspacePath(value: unknown): string { + const candidate = text(value, "a checkout path"); + if (!candidate.startsWith("/")) { + return fail("it did not name a Workspace path"); + } + return candidate; +} + +/** A content identity, held to the shape every digest in this build has. */ +function digest(value: unknown, what: string): string { + const candidate = text(value, `${what} identity`); + if (!SHA256.test(candidate)) { + return fail(`it did not name ${what}`); + } + return candidate; +} + +function count(value: unknown, what: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + return fail(`it did not name ${what}`); + } + return value; +} + +function optional(value: unknown): string | null { + return value === null ? null : text(value, "text"); +} diff --git a/packages/workflow/src/cloudflare/read-plane.ts b/packages/workflow/src/cloudflare/read-plane.ts new file mode 100644 index 000000000..bca51f760 --- /dev/null +++ b/packages/workflow/src/cloudflare/read-plane.ts @@ -0,0 +1,929 @@ +/** + * Reading a run's owner without taking it. + * + * The executor plane is one admitted WebSocket and one acquisition: whoever + * holds it is *the* executor, and nobody else may advance the run. That is the + * right shape for mutation and the wrong shape for reading. Inspecting a run, + * listing what an owner holds, replaying its history, and selecting a fork's + * source are all questions about committed state, and asking one must not make + * the run unrunnable for as long as the answer takes. + * + * So this is an ordinary request plane. It accepts no socket, mints no + * acquisition, writes nothing, and can be answered while an executor is live. + * Admission shares the executor plane's order — release before token, token + * before the run is touched — but stops before the acquisition the executor + * plane takes last. + * + * What crosses is closed and private to this release. Public history is + * projected on the runner from the retained rows this returns, so there is one + * interpretation of a journal rather than two. + */ + +import { parseMembers, requireMemberNames } from "../storage/members.ts"; +import { WorkflowRecordMalformedError } from "../storage/errors.ts"; +import { readDocumentExecution, readRetrieval, readRunRecord, type Row } from "../sqlite/rows.ts"; +import { CommandError } from "./commands.ts"; +import type { OwnerStorage } from "./storage.ts"; +import { recognizeObject } from "./recognition.ts"; +import { isRootImportEvent, isRunRecordEvent } from "../journal-events.ts"; +import { parseDurableEvent } from "@executablemd/durable-streams"; +import { compareUtf8, parseWorkspaceRootManifest } from "../workspace/root-manifest.ts"; +import { + type AnchorBlob, + type AnchorManifest, + type AnchorRoot, + checkoutKey, + forkSelectionAnchor, +} from "./fork-anchor.ts"; +import { encodeBase64, sha256Hex } from "./encoding.ts"; +import { + retainedBytes, + retainedCount, + retainedDigest, + retainedNullableText, + retainedObjectFormat, + retainedPath, + retainedText, +} from "./retained.ts"; + +/** What a read answered, or why it would not. */ +export type ReadAnswer = + | { readonly outcome: "performed"; readonly value: unknown } + | { readonly outcome: "refused"; readonly refusal: string }; + +/** The most rows one page of a read answer may carry. */ +export const READ_PAGE_ENTRIES = 128; + +/** The most serialized bytes one page of a read answer may carry. */ +export const READ_PAGE_BYTES = 512 * 1024; + +/** The most characters a public run id may carry. */ +const MAX_RUN_ID = 128; + +/** + * What a request carries besides the checkpoint it names. + * + * An operation, a section, an anchor, an ordinal and the punctuation around + * them: all fixed shapes, so one generous constant covers every request this + * build makes. + */ +export const READ_REQUEST_ENVELOPE = 4096; + +/** + * The most serialized bytes one request may carry. + * + * Measured over the finished UTF-8 encoding, because that is what crosses. A + * request names a checkpoint, and a checkpoint event id is retained text: what + * bounds it is that its own journal row is a member, and no page carries a + * member larger than a page. Everything else in a request is fixed. + */ +export const READ_REQUEST_BYTES = READ_PAGE_BYTES + READ_REQUEST_ENVELOPE; + +/** + * What a reader may ask for. + * + * Closed, and none of it names a table, a row, a root or a hash: a caller + * chooses a run, an operation and — where an answer is paged — the anchor it is + * continuing. Everything else is the owner's to decide. + */ +export type ReadOperation = + | { readonly operation: "inspect" } + | { readonly operation: "history"; readonly anchor: string | null; readonly after: string | null } + | { + readonly operation: "fork-source"; + readonly checkpointEventId: string; + /** Which part of the selection this page continues. */ + readonly section: ForkSourceSection; + readonly anchor: string | null; + /** + * Where in the anchored selection to continue, as a position rather than + * a name. + * + * A name would have to be spelled inside this request, and a retained + * name is text that JSON escapes — twice, once for the name's own + * encoding and once for the request carrying it — so a member the owner + * will put in a page could name a page nobody can ask for. A position is + * the same size whatever it points at, and it means nothing outside the + * anchor that pins the selection. + */ + readonly after: number | null; + }; + +/** + * The parts a fork's source is read in. + * + * Separate sections rather than one stream because they are different kinds of + * thing and different sizes: rows, root manifests, encoded manifests and blob + * content each need their own bound, and content needs chunking that rows do + * not. + */ +export type ForkSourceSection = "inherited" | "roots" | "manifests" | "blobs" | "checkouts"; + +/** + * What a read request carries outside its body. + * + * Separate from the operation on purpose. The release fingerprint decides + * whether this build will talk to the caller at all, and deciding that after + * decoding the body would mean a mismatched release had already been allowed to + * drive a parser. A host reads these from the request's own metadata, exactly + * as the executor plane reads them from its upgrade headers. + */ +export interface ReadAdmission { + readonly release: string | null; + readonly token: string | null; + readonly runId: string | null; +} + +function failure(reason: string, path: string): Error { + return new WorkflowRecordMalformedError(`read request at ${path}`, reason); +} + +function text(value: unknown, path: string, maximum = MAX_RUN_ID): string { + if (typeof value !== "string" || value === "" || value.length > maximum) { + throw failure("expected bounded non-empty text", path); + } + return value; +} + +function nullableText(value: unknown, path: string): string | null { + return value === null ? null : text(value, path); +} + +/** A position in a selection: whole, not negative, and one this build can hold. */ +function ordinal(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw failure("expected a position in the selection", path); + } + return value; +} + +/** + * One read operation, parsed only after admission has passed. + * + * Strict about membership on purpose: a body carrying more than this release + * declares is not a request this build understands, and reading it leniently + * would accept a shape a later version wrote. + */ +export function parseReadOperation(raw: string): ReadOperation { + if (new TextEncoder().encode(raw).length > READ_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 === "inspect") { + requireMemberNames(read, ["operation"], "$", failure); + return { operation }; + } + if (operation === "history" || operation === "fork-source") { + const names = + operation === "history" + ? ["operation", "anchor", "after"] + : ["operation", "checkpointEventId", "section", "anchor", "after"]; + requireMemberNames(read, names, "$", failure); + const anchor = nullableText(read.get("anchor"), "$.anchor"); + const after = + read.get("after") === null + ? null + : operation === "history" + ? text(read.get("after"), "$.after") + : ordinal(read.get("after"), "$.after"); + if (anchor === null && after !== null) { + // Nothing to continue from: a cursor without a snapshot names no page. + throw failure("expected a cursor only inside an anchored snapshot", "$"); + } + if (operation === "history") { + if (typeof after === "number") { + throw failure("expected a cursor this operation pages by", "$.after"); + } + return { operation, anchor, after }; + } + if (typeof after === "string") { + throw failure("expected a cursor this operation pages by", "$.after"); + } + const section = read.get("section"); + if ( + section !== "inherited" && + section !== "roots" && + section !== "manifests" && + section !== "blobs" && + section !== "checkouts" + ) { + throw failure("expected a section this build answers", "$.section"); + } + return { + operation, + checkpointEventId: text(read.get("checkpointEventId"), "$.checkpointEventId"), + section, + anchor, + after, + }; + } + throw failure("expected an operation this build answers", "$.operation"); +} + +function rows(storage: OwnerStorage, sql: string, ...bindings: unknown[]): Row[] { + return storage.sql.exec(sql, ...bindings).toArray(); +} + +function only(storage: OwnerStorage, sql: string): Row | undefined { + return rows(storage, sql)[0]; +} + +/** The run this owner holds, or a refusal that it holds another or none. */ +function retained(storage: OwnerStorage, runId: string): Row { + recognizeObject(storage); + const row = only( + storage, + `SELECT run_id, definition, base, props, status, stop_reason_kind, stop_reason_code, + stop_reason_event_id, created_at, updated_at FROM workflow_run`, + ); + if (row === undefined) { + throw new CommandError("absent"); + } + if (readRunRecord(row).runId !== runId) { + throw new CommandError("wrong-run"); + } + return row; +} + +/** + * One coherent inspection, from one committed reading. + * + * Every member describes the same moment: the record, its executions, the + * frontier, the current root and the lineage are read together so a caller + * cannot be handed a run whose parts came from different commits. + */ +export function readInspection(storage: OwnerStorage, runId: string): Record { + const row = retained(storage, runId); + const executions = rows( + storage, + `SELECT execution_id, started_at, stopped_at, stop_status, stop_reason_kind, + stop_reason_code, stop_reason_event_id FROM document_executions ORDER BY sequence`, + ).map((entry) => readDocumentExecution(entry)); + const retrieval = only( + storage, + "SELECT metadata, revision, updated_at FROM definition_retrieval WHERE id = 1", + ); + const frontier = only( + storage, + "SELECT event_id, workspace_root_id FROM journal_events ORDER BY sequence DESC LIMIT 1", + ); + const state = only(storage, "SELECT current_root_id FROM workspace_state WHERE singleton_id = 1"); + if (state === undefined) { + throw new WorkflowRecordMalformedError("workflow owner storage", "it selects no Workspace"); + } + const lineage = only( + storage, + `SELECT source_run_id, checkpoint_event_id, checkpoint_workspace_root_id + FROM workflow_fork_lineage WHERE id = 1`, + ); + + return { + record: readRunRecord(row), + executions, + retrieval: retrieval === undefined ? null : readRetrieval(retrieval), + journalFrontier: + frontier === undefined + ? null + : { + eventId: retainedText(frontier, "event_id"), + workspaceRootId: retainedDigest(frontier, "workspace_root_id"), + }, + currentWorkspaceRootId: retainedDigest(state, "current_root_id"), + lineage: + lineage === undefined + ? null + : { + sourceRunId: retainedText(lineage, "source_run_id"), + checkpointEventId: retainedText(lineage, "checkpoint_event_id"), + checkpointWorkspaceRootId: retainedDigest(lineage, "checkpoint_workspace_root_id"), + }, + }; +} + +/** + * One anchored page of retained journal rows. + * + * The anchor is the terminal event the first page chose, and every later page + * is held to it: a run that appended after the snapshot began does not grow the + * answer, because the anchor decides what the answer is about. + */ +export function readHistoryPage( + storage: OwnerStorage, + runId: string, + anchor: string | null, + after: string | null, +): Record { + retained(storage, runId); + const terminal = only( + storage, + "SELECT event_id FROM journal_events ORDER BY sequence DESC LIMIT 1", + ); + const selected = anchor ?? (terminal === undefined ? null : retainedText(terminal, "event_id")); + if (selected === null) { + // An empty history is terminal and carries nothing. + return { anchor: null, after: null, rows: [], done: true, retainedRoots: [], provenance: [] }; + } + const sequenceOf = (eventId: string): number => { + const row = rows(storage, "SELECT sequence FROM journal_events WHERE event_id = ?", eventId)[0]; + if (row === undefined) { + // An anchor or cursor this run does not hold. Answering from the rest + // would be answering about a different snapshot. + throw new CommandError("stale-journal"); + } + return retainedCount(row, "sequence"); + }; + const anchorAt = sequenceOf(selected); + const afterAt = after === null ? 0 : sequenceOf(after); + const page = rows( + storage, + `SELECT event_id, record, workspace_root_id FROM journal_events + WHERE sequence > ? AND sequence <= ? ORDER BY sequence LIMIT ?`, + afterAt, + anchorAt, + READ_PAGE_ENTRIES, + ).map((row) => ({ + eventId: retainedText(row, "event_id"), + record: retainedText(row, "record"), + workspaceRootId: retainedDigest(row, "workspace_root_id"), + })); + const last = page.at(-1); + const done = last === undefined || last.eventId === selected; + return { + anchor: selected, + after, + rows: page, + done, + // Sent with the terminal page alone, because they describe the whole + // snapshot rather than one page of it. + retainedRoots: done + ? rows(storage, "SELECT root_id FROM workspace_roots ORDER BY root_id").map((row) => + retainedDigest(row, "root_id"), + ) + : [], + provenance: done + ? rows( + storage, + `SELECT event_id, source_run_id, source_event_id + FROM journal_event_provenance ORDER BY event_id`, + ).map((row) => ({ + eventId: retainedText(row, "event_id"), + sourceRunId: retainedText(row, "source_run_id"), + sourceEventId: retainedText(row, "source_event_id"), + })) + : [], + }; +} + +/** + * The selection one checkpoint names, as values rather than pages. + * + * Computed whole every time a page is asked for, from one committed reading. + * That is what makes paging safe without a retained session: there is nothing + * to keep alive between pages, and a page is answered from the same selection + * the first one was — or the anchor no longer matches and the whole read fails. + */ +interface ForkSelection { + readonly anchor: string; + readonly checkpointWorkspaceRootId: string; + readonly runRecordWorkspaceRootId: string; + readonly rootImportWorkspaceRootId: string; + readonly inherited: readonly { eventId: string; record: string; workspaceRootId: string }[]; + readonly rootIds: readonly string[]; + readonly manifestHashes: readonly string[]; + readonly blobHashes: readonly string[]; + readonly checkoutPaths: ReadonlySet; +} + +/** + * Select the prefix this checkpoint names, and everything it depends on. + * + * The rules are the shared ones: the source's own run record and root import + * are what a fork writes for itself and are excluded; every root the prefix + * touches is needed, and so is the content those roots close over; and only a + * checkout whose directory exists in the checkpoint's own Workspace is + * inherited, so a Repository the source created afterwards does not arrive in a + * fork with nowhere to put it. + */ +function selectForkSource(storage: OwnerStorage, checkpointEventId: string): ForkSelection { + const all = rows( + storage, + "SELECT event_id, record, workspace_root_id FROM journal_events ORDER BY sequence", + ).map((row) => ({ + eventId: retainedText(row, "event_id"), + record: retainedText(row, "record"), + workspaceRootId: retainedDigest(row, "workspace_root_id"), + })); + const at = all.findIndex((row) => row.eventId === checkpointEventId); + if (at === -1) { + throw new CommandError("stale-journal"); + } + const prefix = all.slice(0, at + 1); + const checkpoint = prefix[at]; + if (checkpoint === undefined) { + throw new CommandError("stale-journal"); + } + + const classify = (row: { record: string }) => { + const parsed = parseDurableEvent(row.record); + if (!parsed.ok) { + throw new CommandError("corrupt-journal"); + } + return parsed.value; + }; + const record = prefix.find((row) => isRunRecordEvent(classify(row))); + if (record === undefined) { + // Nothing to inherit: the source recorded no run of its own before here. + throw new CommandError("not-forkable"); + } + const rootImport = prefix.find((row) => isRootImportEvent(classify(row))); + const inherited = prefix.filter((row) => row !== record && row !== rootImport); + + const rootIds = new Set(prefix.map((row) => row.workspaceRootId)); + rootIds.add(checkpoint.workspaceRootId); + const ordered = [...rootIds].sort(); + + const manifestHashes = new Set(); + const blobHashes = new Set(); + for (const rootId of ordered) { + for (const hash of referenced( + storage, + "workspace_root_manifest_refs", + "manifest_hash", + rootId, + )) { + manifestHashes.add(hash); + } + for (const hash of referenced(storage, "workspace_root_blob_refs", "blob_hash", rootId)) { + blobHashes.add(hash); + } + } + + const checkoutPaths = checkpointDirectories(storage, checkpoint.workspaceRootId); + const selection = { + checkpointEventId, + checkpointWorkspaceRootId: checkpoint.workspaceRootId, + runRecordWorkspaceRootId: record.workspaceRootId, + rootImportWorkspaceRootId: rootImport?.workspaceRootId ?? record.workspaceRootId, + inherited, + rootIds: ordered, + manifestHashes: [...manifestHashes].sort(), + blobHashes: [...blobHashes].sort(), + checkoutPaths, + }; + return { ...selection, anchor: selectionAnchor(storage, selection) }; +} + +function referenced( + storage: OwnerStorage, + table: string, + column: string, + rootId: string, +): string[] { + const sql = + table === "workspace_root_manifest_refs" + ? "SELECT lower(hex(manifest_hash)) AS hash FROM workspace_root_manifest_refs WHERE root_id = ?" + : "SELECT lower(hex(blob_hash)) AS hash FROM workspace_root_blob_refs WHERE root_id = ?"; + return rows(storage, sql, rootId).map((row) => retainedDigest(row, "hash")); +} + +/** + * An identity for the whole selection, not for the checkpoint alone. + * + * The checkpoint says which prefix; it says nothing about which roots, content + * or checkouts came with it, and those travel in separate requests. Everything + * a destination will copy that is not already named by a digest goes in here: + * the inherited rows with their bytes and root associations, the three head + * roots, each root's canonical record and its exact ordered reference sets, and + * every selected checkout in full. + * + * Retained mappings are appendable, which is the case this exists for. A + * qualifying Repository added between the earlier sections and the checkouts + * section changes this value, so the sequence refuses instead of joining two + * committed states. + * + * A content-addressed identity stands for its bytes, because the parse on the + * other side proves the bytes hash to it. Nothing else is stood in for. + */ +function selectionAnchor( + storage: OwnerStorage, + selection: { + checkpointEventId: string; + checkpointWorkspaceRootId: string; + runRecordWorkspaceRootId: string; + rootImportWorkspaceRootId: string; + inherited: readonly { eventId: string; record: string; workspaceRootId: string }[]; + rootIds: readonly string[]; + manifestHashes: readonly string[]; + blobHashes: readonly string[]; + checkoutPaths: ReadonlySet; + }, +): string { + // Built here from what this owner retains, and hashed by the shared rule a + // destination will hash its own copy with. The two sides read from different + // places on purpose; what they must not do is describe the selection + // differently. + return forkSelectionAnchor({ + checkpointEventId: selection.checkpointEventId, + checkpointWorkspaceRootId: selection.checkpointWorkspaceRootId, + runRecordWorkspaceRootId: selection.runRecordWorkspaceRootId, + rootImportWorkspaceRootId: selection.rootImportWorkspaceRootId, + inherited: selection.inherited, + roots: selection.rootIds.map((rootId) => anchorRoot(storage, rootId)), + manifests: selection.manifestHashes.map((hash) => anchorManifest(storage, hash)), + blobs: selection.blobHashes.map((hash) => anchorBlob(storage, hash)), + checkouts: [ + ...readCheckoutRepositories(storage, selection.checkoutPaths), + ...readCheckoutWorktrees(storage, selection.checkoutPaths), + ].map((entry) => ({ key: entry.cursor, value: entry.value })), + }); +} + +/** One root, as the shared selection describes it. */ +function anchorRoot(storage: OwnerStorage, rootId: string): AnchorRoot { + const read = readStoredRoot(storage, rootId); + return { + rootId: retainedTextOf(read, "rootId"), + formatVersion: retainedNumberOf(read, "formatVersion"), + manifest: retainedTextOf(read, "manifest"), + manifestHashes: retainedListOf(read, "manifestHashes"), + blobHashes: retainedListOf(read, "blobHashes"), + }; +} + +/** One content manifest, as the shared selection describes it. */ +function anchorManifest(storage: OwnerStorage, hash: string): AnchorManifest { + const read = readStoredManifest(storage, hash); + return { + hash, + size: retainedNumberOf(read, "size"), + lastSeen: retainedNumberOf(read, "lastSeen"), + encoded: retainedTextOf(read, "encoded"), + }; +} + +/** One blob's metadata, as the shared selection describes it. */ +function anchorBlob(storage: OwnerStorage, hash: string): AnchorBlob { + const read = blobMetadata(storage, hash); + return { + hash, + size: retainedNumberOf(read, "size"), + lastSeen: retainedNumberOf(read, "lastSeen"), + }; +} + +function retainedTextOf(value: Record, name: string): string { + const found = value[name]; + if (typeof found !== "string") { + throw new CommandError("corrupt-journal"); + } + return found; +} + +function retainedNumberOf(value: Record, name: string): number { + const found = value[name]; + if (typeof found !== "number") { + throw new CommandError("corrupt-journal"); + } + return found; +} + +function retainedListOf(value: Record, name: string): string[] { + const found = value[name]; + if (!Array.isArray(found)) { + throw new CommandError("corrupt-journal"); + } + return found.map((entry) => { + if (typeof entry !== "string") { + throw new CommandError("corrupt-journal"); + } + return entry; + }); +} + +/** The directories the checkpoint's own Workspace held. */ +function checkpointDirectories(storage: OwnerStorage, rootId: string): ReadonlySet { + const row = rows(storage, "SELECT manifest FROM workspace_roots WHERE root_id = ?", rootId)[0]; + if (row === undefined) { + throw new CommandError("stale-root"); + } + const parsed = parseWorkspaceRootManifest(retainedText(row, "manifest"), (reason) => { + throw new WorkflowRecordMalformedError("a retained Workspace root", reason); + }); + const directories = new Set(); + for (const entry of parsed.entries) { + if (entry.kind === "directory") { + directories.add(entry.path); + } + } + return directories; +} + +/** + * One page of one section of a fork's source. + * + * Sectioned because the parts are different kinds and sizes: rows, root + * manifests, encoded manifests and blob content each carry their own bound, and + * a page ends on whichever of count or bytes it reaches first. + */ +export function readForkSourcePage( + storage: OwnerStorage, + runId: string, + checkpointEventId: string, + section: ForkSourceSection, + anchor: string | null, + after: number | null, +): Record { + retained(storage, runId); + const selection = selectForkSource(storage, checkpointEventId); + if (anchor !== null && anchor !== selection.anchor) { + // The selection moved, so this page belongs to a snapshot that no longer + // exists. There is no partial answer to give. + throw new CommandError("stale-journal"); + } + + const head = { + anchor: selection.anchor, + after, + section, + checkpointEventId, + checkpointWorkspaceRootId: selection.checkpointWorkspaceRootId, + runRecordWorkspaceRootId: selection.runRecordWorkspaceRootId, + rootImportWorkspaceRootId: selection.rootImportWorkspaceRootId, + }; + + if (section === "inherited") { + return { + ...head, + ...page( + selection.inherited, + after, + // The journal's own order is not derivable from the rows, so each one + // says where it stands in the selected prefix. + (row, at) => ({ ...row, position: at }), + ), + }; + } + if (section === "roots") { + return { + ...head, + ...page(selection.rootIds, after, (rootId) => readStoredRoot(storage, rootId)), + }; + } + if (section === "manifests") { + return { + ...head, + ...page(selection.manifestHashes, after, (hash) => readStoredManifest(storage, hash)), + }; + } + if (section === "blobs") { + return { + ...head, + ...page(selection.blobHashes, after, (hash) => readStoredBlob(storage, hash)), + }; + } + const checkouts = [ + ...readCheckoutRepositories(storage, selection.checkoutPaths), + ...readCheckoutWorktrees(storage, selection.checkoutPaths), + ]; + return { + ...head, + ...page(checkouts, after, (entry) => entry.value), + }; +} + +/** + * One page out of an ordered selection, bounded by count and by bytes. + * + * The cursor is the position of the last member carried, which is meaningful + * only inside the anchor that pins this selection: the selection cannot have + * moved under a position without the anchor changing first. The page also says + * where it begins, which is what makes a whole sequence checkable against the + * size the selection declared. + */ +function page( + members: readonly T[], + after: number | null, + valueOf: (member: T, at: number) => V, +): Record { + if (after !== null && after >= members.length) { + // A position this selection does not hold. Under one anchor that can only + // be a cursor from somewhere else. + throw new CommandError("stale-journal"); + } + const from = after === null ? 0 : after + 1; + const carried: V[] = []; + let bytes = 0; + let at = from; + for (; at < members.length && carried.length < READ_PAGE_ENTRIES; at += 1) { + const member = members[at]; + if (member === undefined) { + break; + } + const value = valueOf(member, at); + const size = new TextEncoder().encode(JSON.stringify(value)).length; + if (carried.length > 0 && bytes + size > READ_PAGE_BYTES) { + break; + } + if (size > READ_PAGE_BYTES) { + // One member larger than a whole page. There is no page that could + // carry it, so the read refuses rather than answering with something the + // runner must reject. + throw new CommandError("too-large"); + } + carried.push(value); + bytes += size; + } + return { + rows: carried, + // Where this page begins in the selection, so a reader can tell that a + // sequence covered every member exactly once rather than trusting that it + // did. + from, + // Where it ends, which is where the next one continues from. + cursor: carried.length === 0 ? after : at - 1, + done: at >= members.length, + total: members.length, + }; +} + +function readStoredRoot(storage: OwnerStorage, rootId: string): Record { + const row = rows( + storage, + "SELECT root_id, format_version, manifest FROM workspace_roots WHERE root_id = ?", + rootId, + )[0]; + if (row === undefined) { + throw new CommandError("stale-root"); + } + return { + rootId: retainedDigest(row, "root_id"), + formatVersion: retainedCount(row, "format_version"), + manifest: retainedText(row, "manifest"), + // In the one order a root is retained with, which is what a destination + // compares its own derivation against element for element. + manifestHashes: referenced( + storage, + "workspace_root_manifest_refs", + "manifest_hash", + rootId, + ).sort(compareUtf8), + blobHashes: referenced(storage, "workspace_root_blob_refs", "blob_hash", rootId).sort( + compareUtf8, + ), + }; +} + +function readStoredManifest(storage: OwnerStorage, hash: string): Record { + const row = rows( + storage, + "SELECT size, encoded, last_seen FROM vfs_manifests WHERE lower(hex(hash)) = ?", + hash, + )[0]; + if (row === undefined) { + throw new CommandError("stale-root"); + } + return { + hash, + size: retainedCount(row, "size"), + lastSeen: retainedCount(row, "last_seen"), + encoded: encodeBase64(retainedBytes(row, "encoded")), + }; +} + +/** + * One blob's retained metadata, without its bytes. + * + * The anchor needs what a destination will copy beside the content; the + * content itself is already named by the digest, and hashing megabytes into an + * anchor recomputed on every page would cost what it does not prove. + */ +function blobMetadata(storage: OwnerStorage, hash: string): Record { + const row = rows( + storage, + "SELECT size, last_seen FROM vfs_blobs WHERE lower(hex(hash)) = ?", + hash, + )[0]; + if (row === undefined) { + throw new CommandError("stale-root"); + } + return { + hash, + size: retainedCount(row, "size"), + lastSeen: retainedCount(row, "last_seen"), + }; +} + +function readStoredBlob(storage: OwnerStorage, hash: string): Record { + const row = rows( + storage, + `SELECT b.size AS size, b.last_seen AS last_seen, x.bytes AS bytes + FROM vfs_blobs AS b JOIN vfs_blob_bytes AS x ON x.hash = b.hash + WHERE lower(hex(b.hash)) = ?`, + hash, + )[0]; + if (row === undefined) { + throw new CommandError("stale-root"); + } + return { + hash, + size: retainedCount(row, "size"), + lastSeen: retainedCount(row, "last_seen"), + content: encodeBase64(retainedBytes(row, "bytes")), + }; +} + +/** + * One checkout's identity, as a key nothing else can spell. + * + * A Repository name and a Worktree name are retained as 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, + * so distinct tuples spell distinct keys. + */ +/** Only the Repositories whose checkout the checkpoint's Workspace holds. */ +function readCheckoutRepositories( + storage: OwnerStorage, + checkoutPaths: ReadonlySet, +): { cursor: string; value: Record }[] { + return rows( + storage, + `SELECT name, locator, locator_fingerprint, requested_base, creation_commit, + primary_branch, object_format, checkout_path + FROM workspace_repositories ORDER BY name`, + ) + .filter((row) => checkoutPaths.has(retainedPath(row, "checkout_path"))) + .map((row) => ({ + cursor: checkoutKey(["repository", retainedText(row, "name")]), + value: { + kind: "repository", + name: retainedText(row, "name"), + locator: retainedText(row, "locator"), + locatorFingerprint: retainedDigest(row, "locator_fingerprint"), + requestedBase: retainedNullableText(row, "requested_base"), + creationCommit: retainedText(row, "creation_commit"), + primaryBranch: retainedText(row, "primary_branch"), + objectFormat: retainedObjectFormat(row, "object_format"), + checkoutPath: retainedPath(row, "checkout_path"), + }, + })); +} + +function readCheckoutWorktrees( + storage: OwnerStorage, + checkoutPaths: ReadonlySet, +): { cursor: string; value: Record }[] { + return rows( + storage, + `SELECT repository_name, name, requested_branch, requested_base, + creation_commit, checkout_path + FROM workspace_worktrees ORDER BY repository_name, name`, + ) + .filter((row) => checkoutPaths.has(retainedPath(row, "checkout_path"))) + .map((row) => ({ + cursor: checkoutKey([ + "worktree", + retainedText(row, "repository_name"), + retainedText(row, "name"), + ]), + value: { + kind: "worktree", + repositoryName: retainedText(row, "repository_name"), + name: retainedText(row, "name"), + requestedBranch: retainedText(row, "requested_branch"), + requestedBase: retainedNullableText(row, "requested_base"), + creationCommit: retainedText(row, "creation_commit"), + checkoutPath: retainedPath(row, "checkout_path"), + }, + })); +} + +/** Answer one admitted read from one committed reading of this owner. */ +export function answerRead( + storage: OwnerStorage, + runId: string, + read: ReadOperation, +): Record { + if (read.operation === "inspect") { + return readInspection(storage, runId); + } + if (read.operation === "history") { + return readHistoryPage(storage, runId, read.anchor, read.after); + } + return readForkSourcePage( + storage, + runId, + read.checkpointEventId, + read.section, + read.anchor, + read.after, + ); +} diff --git a/packages/workflow/src/cloudflare/recognition.ts b/packages/workflow/src/cloudflare/recognition.ts new file mode 100644 index 000000000..87b6fa2a8 --- /dev/null +++ b/packages/workflow/src/cloudflare/recognition.ts @@ -0,0 +1,241 @@ +/** + * Whether this Durable Object's storage is a version-1 workflow run, and how it + * becomes one. + * + * The conditions are the ones the Deno host distinguishes, because they are + * what a caller acts on differently: storage nobody has written yet may be + * initialized; storage belonging to something else, or claiming a version this + * build does not implement, must be left alone; and storage that claims version + * 1 and is not shaped like it is damaged. Collapsing them would leave a host + * guessing whether to create, refuse, or report damage. + * + * What differs from Deno is only where the claim is written. The pragmas that + * carry it in a file are refused here, so `_xmd_workflow_schema` carries it + * instead. Nothing initializes, migrates, repairs or replaces storage this + * module refuses. + */ + +import { initializeSchema as initializeDofsSchema } from "../../vendor/cloudflare-computer-dofs/generated/schema/index.js"; +import { + APPLICATION_ID, + declaredStructureFailure, + hasAnyDeclaredObject, + SCHEMA_SQL, + SCHEMA_VERSION, + type SchemaObject, +} from "../sqlite/workflow-schema.ts"; +import { isSchemaMarker, MARKER_SQL, MARKER_TABLE, readMarker } from "./marker.ts"; +import { + initializePrivateSchema, + PRIVATE_OBJECT_NAMES, + privateStructureFailure, +} from "./private-schema.ts"; +import type { OwnerTransaction, OwnerTransactions } from "./owner-transaction.ts"; +import type { OwnerStorage } from "./storage.ts"; + +/** Why storage could not be read as a version-1 workflow run. */ +export type RecognitionFailure = + | { readonly kind: "foreign"; readonly detail: string } + | { readonly kind: "unsupported-version"; readonly schemaVersion: number } + | { readonly kind: "corrupt"; readonly detail: string }; + +export class WorkflowObjectStorageError extends Error { + override name = "WorkflowObjectStorageError"; + + constructor(readonly failure: RecognitionFailure) { + super(describeFailure(failure)); + } +} + +function describeFailure(failure: RecognitionFailure): string { + if (failure.kind === "foreign") { + return `this Durable Object's storage is not a workflow run: ${failure.detail}`; + } + if (failure.kind === "unsupported-version") { + return `this Durable Object's storage declares schema version ${failure.schemaVersion}, which this build does not implement`; + } + return `this Durable Object's storage is damaged: ${failure.detail}`; +} + +/** Every object the storage declares, drained where the cursor is created. */ +export function declaredObjects(storage: OwnerStorage): SchemaObject[] { + const rows = storage.sql + .exec("SELECT type, name, sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY name") + .toArray(); + return rows.map((row) => ({ + type: String(row["type"]), + name: String(row["name"]), + sql: row["sql"] === null || row["sql"] === undefined ? "" : String(row["sql"]), + })); +} + +/** + * Whether this storage holds nothing at all. + * + * Pristine means no object anybody created — not XMD's, not DOFS's, not the + * marker's, and nothing unrelated. Storage carrying any object but no marker is + * foreign or half-initialized, and is refused rather than written into. + */ +export function isPristine(objects: readonly SchemaObject[]): boolean { + return objects.length === 0; +} + +/** + * Whether this store holds a run yet, ignoring this adapter's own scratch. + * + * A destination that has been offered a fork's parts is not pristine any more — + * the private tables are there to hold them — but it holds no run, and the + * command that makes one has to be able to say so. Anything else declared here + * belongs to something else and is not covered by this. + */ +export function holdsNoRun(storage: OwnerStorage): boolean { + return declaredObjects(storage).every((object) => PRIVATE_OBJECT_NAMES.includes(object.name)); +} + +function markerRows(storage: OwnerStorage): Record[] { + return storage.sql.exec(`SELECT application_id, schema_version FROM ${MARKER_TABLE}`).toArray(); +} + +/** + * Make pristine storage into a version-1 workflow run, in one transaction. + * + * The marker is written last. Atomicity means no observer could see the + * ordering, so this is the code saying what the marker means: an identity claim + * over a schema that is already complete. + */ +export function initializeObject( + storage: OwnerStorage, + transactions: OwnerTransactions, + initializeRun: () => void, +): void { + const objects = declaredObjects(storage); + if (!isPristine(objects)) { + throw new WorkflowObjectStorageError({ + kind: "foreign", + detail: "it already holds objects and carries no workflow schema marker", + }); + } + transactions.run(storage, (transaction) => { + initializeInside(storage, transaction, initializeRun); + }); +} + +/** + * The same initialization, inside a transaction the caller already opened. + * + * Beginning a run creates it and records its first execution together, and + * those are one commit; opening a second transaction for the schema would make + * them two, with a window in between holding a run nothing had begun. + */ +export function initializeInside( + storage: OwnerStorage, + transaction: OwnerTransaction, + initializeRun: () => void, +): void { + storage.sql.exec(SCHEMA_SQL); + initializeDofsSchema(transaction.dofs, () => 0); + initializePrivateSchema(storage); + initializeRun(); + storage.sql.exec(MARKER_SQL); + storage.sql.exec( + `INSERT INTO ${MARKER_TABLE} (id, application_id, schema_version) VALUES (1, ?, ?)`, + APPLICATION_ID, + SCHEMA_VERSION, + ); +} + +/** + * Refuse anything that is not a version-1 workflow run. + * + * Structure only. Whether the rows describe the run that was asked for is a + * separate question, asked after this one succeeds. + */ +export function recognizeObject(storage: OwnerStorage): void { + const objects = declaredObjects(storage); + if (isPristine(objects)) { + throw new WorkflowObjectStorageError({ + kind: "foreign", + detail: "it holds nothing at all", + }); + } + + const carriesMarker = objects.some((object) => object.name === MARKER_TABLE); + if (!carriesMarker) { + throw new WorkflowObjectStorageError({ + kind: "foreign", + detail: hasAnyDeclaredObject(objects) + ? "it declares workflow tables without the schema marker that identifies them" + : "it belongs to something else", + }); + } + + const marker = readMarker(markerRows(storage)); + if (!isSchemaMarker(marker)) { + if (marker.kind === "unknown-version") { + throw new WorkflowObjectStorageError({ + kind: "unsupported-version", + schemaVersion: marker.schemaVersion, + }); + } + if (marker.kind === "foreign-application") { + throw new WorkflowObjectStorageError({ + kind: "foreign", + detail: "its schema marker carries another application's identity", + }); + } + throw new WorkflowObjectStorageError({ + kind: "corrupt", + detail: + marker.kind === "absent" + ? "its schema marker table holds no identity row" + : marker.kind === "duplicated" + ? "its schema marker table holds more than one identity row" + : marker.kind === "incomplete-version" + ? "it carries the XMD application identity without a complete version-1 schema" + : "its schema marker row does not describe an identity", + }); + } + + const privateObjects = objects.filter((object) => PRIVATE_OBJECT_NAMES.includes(object.name)); + const privateFailure = privateStructureFailure(privateObjects); + if (privateFailure !== undefined) { + throw new WorkflowObjectStorageError({ + kind: "corrupt", + detail: + privateFailure.kind === "missing" + ? `it is missing the table ${privateFailure.name}` + : `its ${privateFailure.name} object is not shaped the way version ${SCHEMA_VERSION} declares it`, + }); + } + + const privateNames = new Set(PRIVATE_OBJECT_NAMES); + const declared = objects.filter( + (object) => object.name !== MARKER_TABLE && !privateNames.has(object.name), + ); + const failure = declaredStructureFailure(declared); + if (failure === undefined) { + return; + } + if (failure.kind === "incomplete-pre-release") { + throw new WorkflowObjectStorageError({ + kind: "corrupt", + detail: "it holds an incomplete pre-release of version 1", + }); + } + if (failure.kind === "undeclared-object") { + throw new WorkflowObjectStorageError({ + kind: "corrupt", + detail: `it declares an object that version ${SCHEMA_VERSION} does not`, + }); + } + if (failure.kind === "misshapen-object") { + throw new WorkflowObjectStorageError({ + kind: "corrupt", + detail: `its ${failure.name} object is not shaped the way version ${SCHEMA_VERSION} declares it`, + }); + } + throw new WorkflowObjectStorageError({ + kind: "corrupt", + detail: `it is missing the table ${failure.names.join(", ")}`, + }); +} diff --git a/packages/workflow/src/cloudflare/release.ts b/packages/workflow/src/cloudflare/release.ts new file mode 100644 index 000000000..7397eb3b6 --- /dev/null +++ b/packages/workflow/src/cloudflare/release.ts @@ -0,0 +1,62 @@ +/** + * Which build is allowed to talk to which owner. + * + * The runner client and the Durable Object owner ship as one software-factory + * release, so the messages between them are not a compatibility boundary and + * carry no version negotiation. What replaces one is this: admission compares + * an exact immutable fingerprint the deployment supplied on both sides, and a + * mismatch refuses closed — before any private message is parsed, before an + * acquisition exists, and before any run state is read. + * + * Two builds disagreeing about what was committed is the failure this exists to + * prevent rather than to survive, so there is no downgrade path and nothing + * adapts. + */ + +/** Why a build was not admitted. */ +export type ReleaseRefusal = "release-absent" | "release-malformed" | "release-mismatch"; + +export class ReleaseIdentityError extends Error { + override name = "ReleaseIdentityError"; + + constructor(readonly refusal: ReleaseRefusal) { + // The configured and presented fingerprints are deployment facts, and a + // refusal that printed them would put them in every log that saw one. + super(`this runner build is not admitted by this owner (${refusal})`); + } +} + +/** + * A fingerprint is opaque, non-empty and bounded. + * + * Bounded because it arrives from outside admission and is compared before + * anything else has looked at it; opaque because what a deployment derives it + * from — a commit, a container digest, a build id — is the deployment's + * business and never this module's. + */ +const FINGERPRINT = /^[A-Za-z0-9._:-]{1,200}$/; + +export function admitReleaseFingerprint(value: unknown): string { + if (typeof value !== "string" || value === "") { + throw new ReleaseIdentityError("release-absent"); + } + if (!FINGERPRINT.test(value)) { + throw new ReleaseIdentityError("release-malformed"); + } + return value; +} + +/** + * Compare a presented fingerprint with the configured one. + * + * Exactness rather than secrecy is the point: a fingerprint proves nothing by + * itself, and this is the one check that stops a build the owner never agreed + * to from parsing a private message. + */ +export function requireSameRelease(configured: string, presented: unknown): string { + const admitted = admitReleaseFingerprint(presented); + if (admitted !== configured) { + throw new ReleaseIdentityError("release-mismatch"); + } + return admitted; +} diff --git a/packages/workflow/src/cloudflare/retained.ts b/packages/workflow/src/cloudflare/retained.ts new file mode 100644 index 000000000..69245920c --- /dev/null +++ b/packages/workflow/src/cloudflare/retained.ts @@ -0,0 +1,90 @@ +/** + * Reading a retained SQLite value, rather than converting one. + * + * `String(null)` is `"null"` and `Number(null)` is `0`. Both are plausible + * values, and once a damaged row has been converted into one, nothing further + * down can tell that the store held the wrong type — a client checking shapes + * sees a well-formed answer. So a retained member is read as the type it is + * declared to be, and a row that is not that is damage, reported as damage. + * + * These are the same rules the accepted owner reads already hold their rows to; + * they live here so the read plane holds its rows to them too rather than + * keeping a second, weaker set. + */ + +import { WorkflowRecordMalformedError } from "../storage/errors.ts"; +import { SHA256 } from "../workspace/root-manifest.ts"; +import { bytesOf } from "./encoding.ts"; + +/** One row as the runtime hands it over. */ +export type RetainedRow = Record; + +export function damaged(reason: string): never { + throw new WorkflowRecordMalformedError("workflow owner storage", reason); +} + +/** Non-empty text, or damage. */ +export function retainedText(row: RetainedRow, column: string): string { + const value = row[column]; + if (typeof value !== "string" || value === "") { + return damaged(`expected ${column} to be non-empty text`); + } + return value; +} + +/** Non-empty text or a real null, and nothing else. */ +export function retainedNullableText(row: RetainedRow, column: string): string | null { + const value = row[column]; + if (value === null) { + return null; + } + if (typeof value !== "string" || value === "") { + return damaged(`expected ${column} to be non-empty text or null`); + } + return value; +} + +/** A nonnegative whole number, or damage. */ +export function retainedCount(row: RetainedRow, column: string): number { + const value = row[column]; + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + return damaged(`expected ${column} to be a nonnegative whole number`); + } + return value; +} + +/** A lowercase hex content identity, or damage. */ +export function retainedDigest(row: RetainedRow, column: string): string { + const value = retainedText(row, column); + if (!SHA256.test(value)) { + return damaged(`expected ${column} to be a content identity`); + } + return value; +} + +/** A stored byte sequence, copied so the caller holds no cursor memory. */ +export function retainedBytes(row: RetainedRow, column: string): Uint8Array { + try { + return bytesOf(row[column]); + } catch { + return damaged(`expected ${column} to be a byte sequence`); + } +} + +/** One of the object formats this build writes, or damage. */ +export function retainedObjectFormat(row: RetainedRow, column: string): "sha1" | "sha256" { + const value = retainedText(row, column); + if (value !== "sha1" && value !== "sha256") { + return damaged(`expected ${column} to name an object format`); + } + return value; +} + +/** A Workspace-relative path, which is absolute within the Workspace. */ +export function retainedPath(row: RetainedRow, column: string): string { + const value = retainedText(row, column); + if (!value.startsWith("/")) { + return damaged(`expected ${column} to be a Workspace path`); + } + return value; +} diff --git a/packages/workflow/src/cloudflare/routes.ts b/packages/workflow/src/cloudflare/routes.ts new file mode 100644 index 000000000..e0a6b52ee --- /dev/null +++ b/packages/workflow/src/cloudflare/routes.ts @@ -0,0 +1,101 @@ +/** + * Where a request reaches one run's owner, and what it carries outside its body. + * + * Three planes, three paths, and the path is what says which. A gateway routes + * on the run id in it and forwards the request whole; nothing here parses a + * command, verifies a token or decides anything about a run — the owner does + * all three, and a boundary that pre-decided any of them would be a second + * authority the owner would have to trust. + * + * Private to one release, like everything else that crosses. The paths and the + * header names are this build talking to itself: they are journaled by neither + * side, exported by neither, and named in no public type. What is public is the + * endpoint an operator configures and the three planes' behavior. + * + * The executor plane carries its admission in `Sec-WebSocket-Protocol` because + * that is the one header a standard `WebSocket` client can set. It is a + * transport header on a request that is never retained, and the alternative — + * a token in the URL — would put a credential somewhere URLs get written down. + */ + +/** The one path prefix every plane hangs from. */ +const RUNS = "runs"; + +/** Which plane a path names. */ +export type OwnerPlane = "executor" | "read" | "delivery"; + +/** The order the subprotocol carries admission in. */ +const PROTOCOL = "executablemd.workflow.owner.v1"; + +/** What a request said about itself, outside its body. */ +export interface RouteAdmission { + readonly release: string | null; + readonly token: string | null; + readonly runId: string | null; +} + +/** The header a release identity travels in. */ +export const RELEASE_HEADER = "x-executablemd-workflow-release"; + +/** + * The one subprotocol this build offers, and the two values beside it. + * + * Offered in a fixed order so the owner reads by position rather than by + * guessing which value is which, and the name is first so the owner can echo + * exactly one selected protocol back — a handshake that selected none is one a + * standard client fails. + */ +export function upgradeProtocols(release: string, token: string): readonly string[] { + return [PROTOCOL, release, token]; +} + +/** The admission one upgrade request carries, read back out of its protocols. */ +export function upgradeAdmission(header: string | null, runId: string | null): RouteAdmission { + const offered = (header ?? "").split(",").map((value) => value.trim()); + const [name, release, token] = offered; + return name === PROTOCOL + ? { release: release ?? null, token: token ?? null, runId } + : { release: null, token: null, runId }; +} + +/** The protocol an owner selects, so the handshake completes. */ +export function selectedProtocol(): string { + return PROTOCOL; +} + +/** Where one plane of one run's owner is, under a normalized endpoint. */ +export function planePath(runId: string, plane: OwnerPlane): string { + return `/${RUNS}/${encodeURIComponent(runId)}/${plane}`; +} + +/** + * Which run and plane a path names, or nothing. + * + * Read from the end, because a configured endpoint may carry a path of its own + * and what is in front of the route belongs to the deployment. The three + * segments the route is made of are exact all the same: a tail this build does + * not write names no plane, and the only sender is this build. + */ +export function routeOf(pathname: string): { runId: string; plane: OwnerPlane } | undefined { + const segments = pathname.split("/").filter((segment) => segment !== ""); + const route = segments.slice(-3); + if (route.length !== 3 || route[0] !== RUNS) { + return undefined; + } + const [, encoded, plane] = route; + if (encoded === undefined || encoded === "" || plane === undefined) { + return undefined; + } + if (plane !== "executor" && plane !== "read" && plane !== "delivery") { + return undefined; + } + let runId: string; + try { + runId = decodeURIComponent(encoded); + } catch { + // A percent sequence this build never wrote. The id it would name is not + // one to guess at. + return undefined; + } + return { runId, plane }; +} diff --git a/packages/workflow/src/cloudflare/routing.ts b/packages/workflow/src/cloudflare/routing.ts new file mode 100644 index 000000000..d9b59053e --- /dev/null +++ b/packages/workflow/src/cloudflare/routing.ts @@ -0,0 +1,68 @@ +/** + * Which Durable Object owns one run. + * + * The public run ID selects it arithmetically, through the namespace's own + * `idFromName`. There is no registry, no lookup table and nothing to keep in + * agreement with the objects themselves: a second authority that could disagree + * with the arithmetic is exactly what "one issue, one run, one owner" cannot + * have. + * + * The id is admitted before it is used. A malformed one must not reach + * `idFromName` at all — that call answers with an object for any string, so a + * mistyped id would silently address a fresh, empty owner rather than fail. + */ + +/** What a run ID has to be to address an owner. */ +export type RunIdRefusal = "run-id-absent" | "run-id-empty" | "run-id-has-nul" | "run-id-too-long"; + +export class RunIdError extends Error { + override name = "RunIdError"; + + constructor(readonly refusal: RunIdRefusal) { + super(`this run id cannot address a workflow owner (${refusal})`); + } +} + +/** + * The longest run ID this host routes. + * + * Public run IDs are opaque and caller-selectable, so a bound belongs here + * rather than in the derivation: the factory's own is 52 characters, and this + * leaves room for an authorized caller's without letting an unbounded string + * reach the runtime. + */ +const MAX_RUN_ID = 512; + +/** Hold a run ID to what storage requires of one, changing nothing about it. */ +export function admitRunId(value: unknown): string { + if (typeof value !== "string") { + throw new RunIdError("run-id-absent"); + } + if (value === "") { + throw new RunIdError("run-id-empty"); + } + if (value.includes("\0")) { + throw new RunIdError("run-id-has-nul"); + } + if (value.length > MAX_RUN_ID) { + throw new RunIdError("run-id-too-long"); + } + return value; +} + +/** The one namespace operation this host routes through. */ +export interface OwnerNamespace { + idFromName(name: string): { toString(): string }; + get(id: { toString(): string }): Stub; +} + +/** + * The owner for one run. + * + * Deterministic in the run ID and in nothing else: the same id reaches the same + * object from any worker, on any request, without either side having recorded + * where it went. + */ +export function ownerFor(namespace: OwnerNamespace, runId: unknown): Stub { + return namespace.get(namespace.idFromName(admitRunId(runId))); +} diff --git a/packages/workflow/src/cloudflare/storage.ts b/packages/workflow/src/cloudflare/storage.ts new file mode 100644 index 000000000..aa68916a9 --- /dev/null +++ b/packages/workflow/src/cloudflare/storage.ts @@ -0,0 +1,53 @@ +/** + * A Durable Object's storage, as the vendored DOFS layer expects to see it. + * + * The vendor describes storage structurally — `sql.exec()` answering a cursor + * whose rows are a caller-chosen `object` subtype — while the runtime types the + * same call concretely as `Record`. The two are + * compatible in fact and not in the type system, so this is the one place the + * shapes are reconciled, rather than every call site asserting it. + * + * Nothing is converted: the cursor is drained with `toArray()` exactly where + * the caller asks for it, because Cloudflare's SQL cursor does not survive an + * `await` and draining it late would read a different result than the query + * asked for. + */ + +import type { + DurableObjectStorageLike, + SQLCursorLike, + SQLStorageLike, +} from "../../vendor/cloudflare-computer-dofs/generated/types.d.ts"; + +/** The subset of the runtime's storage this adapter uses. */ +export interface OwnerStorage { + readonly sql: { + exec(query: string, ...bindings: unknown[]): { toArray(): Record[] }; + }; + transactionSync(closure: () => T): T; +} + +/** + * Present one Durable Object's storage as the vendored DOFS storage shape. + * + * `transactionSync` is deliberately *not* forwarded here. The owner opens + * exactly one real transaction of its own and enlists DOFS inside it; a wrapper + * that forwarded this method would let a nested call reach the runtime, which + * refuses transaction statements from `sql.exec()`. + */ +export function dofsStorage(storage: OwnerStorage): DurableObjectStorageLike { + const sql: SQLStorageLike = { + exec>( + query: string, + ...bindings: unknown[] + ): SQLCursorLike { + const rows = storage.sql.exec(query, ...bindings).toArray(); + return { + toArray(): Row[] { + return rows as Row[]; + }, + }; + }, + }; + return { sql }; +} diff --git a/packages/workflow/src/cloudflare/token.ts b/packages/workflow/src/cloudflare/token.ts new file mode 100644 index 000000000..1139ebf66 --- /dev/null +++ b/packages/workflow/src/cloudflare/token.ts @@ -0,0 +1,247 @@ +/** + * Verifying the token a runner presents. + * + * This is the authority boundary, so it takes bytes rather than a claim set. A + * caller that could hand over decoded claims would be a caller that could + * assert whatever the policy asks for, and no amount of equality checking after + * that point would mean anything — which is exactly the hole this module + * closes. + * + * What it does is ordinary compact-JWS verification, narrowed hard: one + * algorithm family, keys the deployment configured, and temporal validity + * checked before any payload member is read as a claim. Everything about the + * token stops here. The raw JWT, the key material, the header, the claims the + * policy does not name and the reason a signature failed are all provider + * state: none of it is retained, attached, journaled, logged, or returned. + */ + +import { type Operation, until } from "effection"; + +/** Why a token was not accepted. */ +export type TokenRefusal = + | "token-absent" + | "token-malformed" + | "token-too-large" + | "unsupported-algorithm" + | "unsupported-type" + | "unknown-key" + | "bad-signature" + | "malformed-claims" + | "expired" + | "not-yet-valid" + | "misconfigured-clock"; + +export class TokenError extends Error { + override name = "TokenError"; + + constructor(readonly refusal: TokenRefusal) { + super(`this runner's token was not accepted (${refusal})`); + } +} + +/** + * The one signature family this accepts. + * + * GitHub Actions signs with RS256. An allowlist rather than a lookup, because + * reading the algorithm out of the header and trusting it is how a token comes + * to be "verified" with `none` or with a symmetric key an attacker chose. + */ +const SUPPORTED = "RS256"; + +/** + * The longest token this reads at all, and the longest segment inside one. + * + * Bounded before anything is decoded, because decoding is the first work an + * unauthenticated caller can make this owner do. + */ +const MAX_TOKEN = 16 * 1024; +const MAX_SEGMENT = 8 * 1024; + +/** The most skew a deployment may configure. */ +const MAX_SKEW_SECONDS = 300; + +/** A NumericDate: a finite integer count of seconds. */ +function numericDate(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) { + throw new TokenError("malformed-claims"); + } + return value; +} + +/** What a deployment configures before any token can be verified. */ +export interface TokenVerification { + /** + * The issuer's public keys. Fetched and rotated by the host. + * + * `kid` is carried beside the key rather than read off it: the runtime's + * `JsonWebKey` does not declare one, and a key set that narrows by id is what + * a JWKS is for. + */ + readonly keys: readonly VerificationKey[]; + /** + * How much clock skew to tolerate, in seconds. + * + * Adapter policy, not a user setting and never a request field. Bounded above + * because a large tolerance is indistinguishable from not checking, and below + * because a negative one would reject tokens for being on time. + */ + readonly skewSeconds: number; + /** Now, in seconds since the epoch. Injected so a test can be exact. */ + readonly now: () => number; +} + +/** One configured public key, and the id a token may name it by. */ +export interface VerificationKey { + readonly kid?: string; + readonly jwk: JsonWebKey; +} + +function decodeSegment(segment: string): unknown { + // base64url, without the padding a compact JWS omits. + const padded = segment.replaceAll("-", "+").replaceAll("_", "/"); + const filled = padded + "=".repeat((4 - (padded.length % 4)) % 4); + let text: string; + try { + const bytes = Uint8Array.from(atob(filled), (character) => character.charCodeAt(0)); + text = new TextDecoder().decode(bytes); + } catch { + throw new TokenError("token-malformed"); + } + try { + return JSON.parse(text); + } catch { + throw new TokenError("token-malformed"); + } +} + +function object(value: unknown): Map { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TokenError("token-malformed"); + } + const members: Map = new Map(Object.entries(value)); + return members; +} + +function signatureBytes(segment: string): Uint8Array { + const padded = segment.replaceAll("-", "+").replaceAll("_", "/"); + const filled = padded + "=".repeat((4 - (padded.length % 4)) % 4); + try { + return Uint8Array.from(atob(filled), (character) => character.charCodeAt(0)); + } catch { + throw new TokenError("token-malformed"); + } +} + +/** + * Verify one compact JWS and answer with its payload. + * + * The order is the contract: shape, then algorithm, then signature, then time. + * A payload member is not a claim until every one of those has passed, which is + * why nothing here returns early with something a caller could mistake for one. + */ +export function* verifyToken( + configured: TokenVerification, + token: unknown, +): Operation> { + const skew = configured.skewSeconds; + if (!Number.isFinite(skew) || skew < 0 || skew > MAX_SKEW_SECONDS) { + throw new TokenError("misconfigured-clock"); + } + if (typeof token !== "string" || token === "") { + throw new TokenError("token-absent"); + } + if (token.length > MAX_TOKEN) { + throw new TokenError("token-too-large"); + } + const parts = token.split("."); + if (parts.length !== 3) { + throw new TokenError("token-malformed"); + } + if (parts.some((part) => part.length === 0 || part.length > MAX_SEGMENT)) { + throw new TokenError("token-malformed"); + } + const [encodedHeader, encodedPayload, encodedSignature] = parts; + if ( + encodedHeader === undefined || + encodedPayload === undefined || + encodedSignature === undefined + ) { + throw new TokenError("token-malformed"); + } + + const header = object(decodeSegment(encodedHeader)); + if (header.get("alg") !== SUPPORTED) { + throw new TokenError("unsupported-algorithm"); + } + // GitHub's Actions tokens carry `typ: "JWT"`. Requiring it is cheap and stops + // a token minted for another purpose from being read as one of these. + const type = header.get("typ"); + if (typeof type !== "string" || type.toUpperCase() !== "JWT") { + throw new TokenError("unsupported-type"); + } + + const signed = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`); + const signature = signatureBytes(encodedSignature); + // The token names exactly one configured key. Falling back to an unkeyed + // candidate when the id matched nothing would mean an unrecognized key id + // still got a signature check against whatever else was configured. + const keyId = header.get("kid"); + if (typeof keyId !== "string" || keyId === "") { + throw new TokenError("unknown-key"); + } + const candidates = configured.keys.filter((key) => key.kid === keyId); + if (candidates.length !== 1) { + // None means the id is unrecognized; more than one means the configuration + // cannot say which key that id is. + throw new TokenError("unknown-key"); + } + + let verified = false; + for (const candidate of candidates) { + const key = yield* until( + crypto.subtle.importKey( + "jwk", + candidate.jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ), + ); + const matched = yield* until(crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, signed)); + if (matched) { + verified = true; + break; + } + } + if (!verified) { + throw new TokenError("bad-signature"); + } + + const payload = object(decodeSegment(encodedPayload)); + const now = configured.now(); + if (!Number.isFinite(now)) { + throw new TokenError("misconfigured-clock"); + } + + // All three are required. Checking a temporal claim only when it happens to + // be a number means a token that omits it is treated as one that satisfies + // it, which is the opposite of what the claim is for. + const expiry = numericDate(payload.get("exp")); + const issued = numericDate(payload.get("iat")); + const notBefore = numericDate(payload.get("nbf")); + + // RFC 7519 §4.1.4: the current time must be *before* the expiration, so the + // boundary itself is expired rather than the last valid instant. + if (now >= expiry + skew) { + throw new TokenError("expired"); + } + if (now + skew < notBefore) { + throw new TokenError("not-yet-valid"); + } + if (now + skew < issued) { + // Issued in the future by more than the tolerance: the token and this clock + // disagree about when now is, and nothing here can tell which is wrong. + throw new TokenError("not-yet-valid"); + } + return payload; +} diff --git a/packages/workflow/src/composition/locator.ts b/packages/workflow/src/composition/locator.ts new file mode 100644 index 000000000..af989e6c1 --- /dev/null +++ b/packages/workflow/src/composition/locator.ts @@ -0,0 +1,111 @@ +/** + * Admitting a Git locator, and naming one without publishing it. + * + * Two different questions. **Admission** decides whether a locator may be handed + * to Git at all. **Fingerprinting** produces the stable name the journal, the + * record and every compatibility comparison use, so a changed locator diverges + * without the bytes of either one being retained outside the single column that + * holds them. + * + * Admission is a closed allowlist rather than a search for bad shapes. Git's + * locator grammar reaches well past URLs — `ext::sh -c …` runs a command, a + * leading `-` is read as an option, and a transport helper is whatever is on + * `PATH` — so anything not recognized as one of the admitted forms is refused. + * Credentials in the string are refused rather than stripped: a locator that + * carries one is a secret a caller put in a durable input, and quietly editing + * it would retain a run nobody asked for. + * + * Both rules are shared because both hosts need them and neither may be more + * permissive than the other. The local host refuses a locator before Git sees + * it; the remote owner must refuse the same one before it becomes durable + * state, or an authenticated proposal could retain something the local host + * would never have produced. A second copy of an allowlist is the copy that + * ends up longer. + * + * Nothing here reaches a runtime. `URL` is the platform's, and the digest is + * the shared one. + */ + +import { sha256Hex } from "../workspace/sha256.ts"; + +/** Schemes this provider hands to Git. Everything else is refused. */ +const SCHEMES = new Set(["https", "http", "ssh", "git", "file"]); + +/** `user@host:path`, Git's scp-like form. A colon in the userinfo is a password. */ +const SCP_LIKE = /^([^/@:]+)@([^/@:]+):(.+)$/; + +function hasControlCharacters(value: string): boolean { + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) { + return true; + } + } + return false; +} + +function admitUrl(locator: string): string | undefined { + let url: URL; + try { + url = new URL(locator); + } catch { + return undefined; + } + const scheme = url.protocol.replace(/:$/, ""); + if (!SCHEMES.has(scheme)) { + return undefined; + } + if (url.username !== "" || url.password !== "") { + return undefined; + } + // A query or a fragment is refused whole rather than searched for credentials. + // `?access_token=…` is the ordinary way a token is written into a URL, and a + // rule that named the parameters worth refusing would be a list of the ones + // somebody thought of — the same open-ended guessing this module rejects + // everywhere else. Git is given a repository's location, and neither part + // carries any of that location for the transports admitted here. + if (url.search !== "" || url.hash !== "") { + return undefined; + } + return locator; +} + +/** + * The locator this string is, or `undefined` when this provider will not use it. + * + * The answer is the original bytes, never a rewritten form: what is admitted is + * what Git is given and what the fingerprint names, so the three cannot drift. + */ +export function admitLocator(locator: string): string | undefined { + if (locator === "" || hasControlCharacters(locator) || /\s/.test(locator)) { + return undefined; + } + if (locator.startsWith("-")) { + return undefined; + } + if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(locator)) { + return admitUrl(locator); + } + const scpLike = SCP_LIKE.exec(locator); + if (scpLike !== null) { + return locator; + } + // A local path. Absolute only: a relative one would name a different + // repository depending on which directory the host happened to run in, and a + // workflow's retained identity must not depend on that. + if (locator.startsWith("/")) { + return locator; + } + return undefined; +} + +/** + * The stable name an admitted locator is known by everywhere but its own column. + * + * Shared because both hosts retain it and both must derive it identically: a + * fingerprint is what a journal event carries in place of the locator, and two + * derivations would be two names for one repository. + */ +export function locatorFingerprintOf(locator: string): string { + return sha256Hex(locator); +} diff --git a/packages/workflow/src/deno/artifact-frontier.ts b/packages/workflow/src/deno/artifact-frontier.ts index c8957363d..e0f3aa5d8 100644 --- a/packages/workflow/src/deno/artifact-frontier.ts +++ b/packages/workflow/src/deno/artifact-frontier.ts @@ -44,7 +44,7 @@ import type { RetainedBlob, RetainedManifest } from "./fork-source.ts"; import { readForkLineage } from "./fork-write.ts"; import { readRepositories, readRetainedRows, readWorktrees } from "./fork-source.ts"; import { reading } from "./reading.ts"; -import { readDocumentExecution, readRetrieval } from "./rows.ts"; +import { readDocumentExecution, readRetrieval } from "../sqlite/rows.ts"; import { readAllAgentSessions } from "./workspace/agent-sessions.ts"; import { bytes, integer } from "./workspace/manifest.ts"; import { diff --git a/packages/workflow/src/deno/artifact/records.ts b/packages/workflow/src/deno/artifact/records.ts index 08cf5b18c..af2f1e6ae 100644 --- a/packages/workflow/src/deno/artifact/records.ts +++ b/packages/workflow/src/deno/artifact/records.ts @@ -28,7 +28,7 @@ import { createHash } from "node:crypto"; import { Buffer } from "node:buffer"; import type { Operation } from "effection"; -import { prepareElicitation, validateParsed } from "@executablemd/core"; +import { prepareElicitation } from "@executablemd/core"; import { AGENT_PROMPT, parsePromptRecord } from "@executablemd/core/host"; import type { PromptRecord } from "@executablemd/core/host"; import { parseDurableEvent } from "@executablemd/durable-streams"; @@ -86,8 +86,8 @@ import { workspaceRoot, type WorkspaceRootManifest, } from "../workspace/manifest.ts"; -import { decodeDofsManifest } from "../workspace/root.ts"; -import type { DofsManifest } from "../workspace/root.ts"; +import { decodeContentManifest } from "../workspace/root.ts"; +import type { ContentManifest } from "../workspace/root.ts"; import { gitBlobIdentity } from "./source.ts"; import { canonicalJsonBytes, canonicalJsonText, entryKey } from "./manifest.ts"; import type { @@ -1335,7 +1335,7 @@ function verifyLifecycle( function verifyContentStore( contents: XmdArtifactContents, reject: Reject, -): ReadonlyMap { +): ReadonlyMap { const blobs = new Map(); for (const blob of contents.blobs) { const hash = toHex(blob.hash); @@ -1348,7 +1348,7 @@ function verifyContentStore( blobs.set(hash, blob.size); } - const manifests = new Map(); + const manifests = new Map(); for (const manifest of contents.manifests) { const hash = toHex(manifest.hash); if (manifests.has(hash)) { @@ -1357,7 +1357,7 @@ function verifyContentStore( if (toHex(sha256(manifest.encoded)) !== hash) { reject("a DOFS manifest's identity does not match its bytes"); } - const decoded = decodeDofsManifest(manifest.encoded, reject); + const decoded = decodeContentManifest(manifest.encoded, reject); if (decoded.size !== manifest.size) { reject("a DOFS manifest's declared size does not equal its chunks"); } @@ -1382,7 +1382,7 @@ function verifyContentStore( */ function verifyRoots( contents: XmdArtifactContents, - manifests: ReadonlyMap, + manifests: ReadonlyMap, path: string, reject: Reject, ): void { @@ -1395,7 +1395,7 @@ function verifyRoots( reject("a Workspace root identity does not match its manifest bytes"); } - const declared = new Map(); + const declared = new Map(); for (const entry of parsed.entries) { if (entry.kind !== "file") { continue; @@ -1695,7 +1695,7 @@ function* judgeRetainedAnswer( let issues; try { const prepared = yield* prepareElicitation(wait.responseSchema, "artifact answer"); - issues = validateParsed(prepared.validate, answer); + issues = prepared.validator.judge(answer); } catch { reject("a retained response schema cannot judge the answer stored against it"); } diff --git a/packages/workflow/src/deno/composition/effects.ts b/packages/workflow/src/deno/composition/effects.ts index efc86fc88..3d04ed0b8 100644 --- a/packages/workflow/src/deno/composition/effects.ts +++ b/packages/workflow/src/deno/composition/effects.ts @@ -21,9 +21,10 @@ import { RepositoryCompositionProtocolError, } from "../../composition/errors.ts"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; +import { workspaceHostFor } from "../../workspace/effects.ts"; +import type { WorkspaceFilesystem } from "../../workspace/filesystem.ts"; import { WorkflowStorageError } from "../../storage/errors.ts"; import { savepoint } from "../transaction.ts"; -import { createWorkspaceEffect } from "../workspace/effect.ts"; import { isJournalableWorkspaceFailure } from "../workspace/errors.ts"; import type { DenoWorkspaceFilesystem } from "../workspace/filesystem.ts"; import type { WorkspaceMetadata } from "../workspace/repositories.ts"; @@ -153,11 +154,11 @@ function* compositionEffect( database: WorkflowRunDatabase, description: EffectDescription, perform: ( - filesystem: DenoWorkspaceFilesystem, + filesystem: WorkspaceFilesystem, metadata: WorkspaceMetadata, ) => Operation, ): Workflow { - return yield createWorkspaceEffect(database, description, perform); + return yield workspaceHostFor(database).create(description, perform); } /** diff --git a/packages/workflow/src/deno/composition/locator.ts b/packages/workflow/src/deno/composition/locator.ts index 7b9198d60..5d0f1dbe9 100644 --- a/packages/workflow/src/deno/composition/locator.ts +++ b/packages/workflow/src/deno/composition/locator.ts @@ -18,80 +18,7 @@ * quietly editing it would retain a run nobody asked for. */ -import { createHash } from "node:crypto"; - -/** Schemes this provider hands to Git. Everything else is refused. */ -const SCHEMES = new Set(["https", "http", "ssh", "git", "file"]); - -/** `user@host:path`, Git's scp-like form. A colon in the userinfo is a password. */ -const SCP_LIKE = /^([^/@:]+)@([^/@:]+):(.+)$/; - -function hasControlCharacters(value: string): boolean { - for (const character of value) { - const code = character.codePointAt(0) ?? 0; - if (code < 0x20 || code === 0x7f) { - return true; - } - } - return false; -} - -function admitUrl(locator: string): string | undefined { - let url: URL; - try { - url = new URL(locator); - } catch { - return undefined; - } - const scheme = url.protocol.replace(/:$/, ""); - if (!SCHEMES.has(scheme)) { - return undefined; - } - if (url.username !== "" || url.password !== "") { - return undefined; - } - // A query or a fragment is refused whole rather than searched for credentials. - // `?access_token=…` is the ordinary way a token is written into a URL, and a - // rule that named the parameters worth refusing would be a list of the ones - // somebody thought of — the same open-ended guessing this module rejects - // everywhere else. Git is given a repository's location, and neither part - // carries any of that location for the transports admitted here. - if (url.search !== "" || url.hash !== "") { - return undefined; - } - return locator; -} - -/** - * The locator this string is, or `undefined` when this provider will not use it. - * - * The answer is the original bytes, never a rewritten form: what is admitted is - * what Git is given and what the fingerprint names, so the three cannot drift. - */ -export function admitLocator(locator: string): string | undefined { - if (locator === "" || hasControlCharacters(locator) || /\s/.test(locator)) { - return undefined; - } - if (locator.startsWith("-")) { - return undefined; - } - if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(locator)) { - return admitUrl(locator); - } - const scpLike = SCP_LIKE.exec(locator); - if (scpLike !== null) { - return locator; - } - // A local path. Absolute only: a relative one would name a different - // repository depending on which directory the host happened to run in, and a - // workflow's retained identity must not depend on that. - if (locator.startsWith("/")) { - return locator; - } - return undefined; -} - -/** The stable name an admitted locator is known by everywhere but its own column. */ -export function locatorFingerprint(locator: string): string { - return createHash("sha256").update(locator, "utf8").digest("hex"); -} +export { + admitLocator, + locatorFingerprintOf as locatorFingerprint, +} from "../../composition/locator.ts"; diff --git a/packages/workflow/src/deno/composition/provider.ts b/packages/workflow/src/deno/composition/provider.ts index 404d1de25..d5242d4bb 100644 --- a/packages/workflow/src/deno/composition/provider.ts +++ b/packages/workflow/src/deno/composition/provider.ts @@ -38,13 +38,12 @@ import type { import type { GitPushOutcome, GitPushRequest } from "../../composition/git-push-records.ts"; import type { RepositoryRecord, WorktreeRecord } from "../../composition/records.ts"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { transactWorkspaceRoots } from "../workspace/private.ts"; -import type { PrivateWorkspaceTransaction } from "../workspace/private.ts"; +import { workspaceHostFor } from "../../workspace/effects.ts"; +import type { WorkspaceAttachmentView } from "../../workspace/effects.ts"; import { gitSession, type GitSession } from "./git.ts"; import { denoRepositoryHost, type RepositoryHost } from "./host.ts"; import type { GitAuthentication } from "./authentication.ts"; import type { HelperAssembly } from "./credential-helper.ts"; -import { WORKSPACE_REPOSITORY, WORKSPACE_WORKTREE } from "./effects.ts"; import { stale, type Attached, type StaleReason } from "./identity.ts"; import { createRepository, @@ -109,11 +108,15 @@ function* attach( database: WorkflowRunDatabase, host: RepositoryHost, subject: string, - prepare: (workspace: PrivateWorkspaceTransaction, root: string) => Operation, + prepare: (workspace: WorkspaceAttachmentView, root: string) => Operation, disagreement: (git: GitSession, attached: Attached) => Operation, ): Operation { const root = yield* host.useDirectory(); - const prepared = yield* transactWorkspaceRoots(database, (workspace) => prepare(workspace, root)); + // Through whichever host attached this run. Locally that is the validated + // lease and its transaction; on a runner it is one coherent owner snapshot + // materialized into a directory this invocation owns. Either way the read is + // over before Git runs, so nothing is held open across a subprocess. + const prepared = yield* workspaceHostFor(database).read((workspace) => prepare(workspace, root)); if (!prepared.ok) { throw prepared.error; } diff --git a/packages/workflow/src/deno/composition/repository.ts b/packages/workflow/src/deno/composition/repository.ts index a52a7f643..4162c7926 100644 --- a/packages/workflow/src/deno/composition/repository.ts +++ b/packages/workflow/src/deno/composition/repository.ts @@ -23,7 +23,7 @@ import { type RepositoryCreationRequest, type RepositoryRecord, } from "../../composition/records.ts"; -import type { PrivateWorkspaceTransaction } from "../workspace/private.ts"; +import type { WorkspaceAttachmentView } from "../../workspace/effects.ts"; import { checkoutPrimary, checkoutReadable, @@ -165,7 +165,7 @@ export function* performRepository( } export function* prepareRepositoryAttachment( - workspace: PrivateWorkspaceTransaction, + workspace: WorkspaceAttachmentView, root: string, record: RepositoryRecord, subject: string, diff --git a/packages/workflow/src/deno/composition/worktree.ts b/packages/workflow/src/deno/composition/worktree.ts index 3c26bd456..d314c9663 100644 --- a/packages/workflow/src/deno/composition/worktree.ts +++ b/packages/workflow/src/deno/composition/worktree.ts @@ -23,7 +23,7 @@ import { type WorktreeRecord, } from "../../composition/records.ts"; import type { StoredRepository } from "../workspace/repositories.ts"; -import type { PrivateWorkspaceTransaction } from "../workspace/private.ts"; +import type { WorkspaceAttachmentView } from "../../workspace/effects.ts"; import { addWorktree, checkoutReadable, @@ -189,7 +189,7 @@ export function* performWorktree( } export function* prepareWorktreeAttachment( - workspace: PrivateWorkspaceTransaction, + workspace: WorkspaceAttachmentView, root: string, record: WorktreeRecord, subject: string, diff --git a/packages/workflow/src/deno/database.ts b/packages/workflow/src/deno/database.ts index 2b439d65f..d540facb6 100644 --- a/packages/workflow/src/deno/database.ts +++ b/packages/workflow/src/deno/database.ts @@ -49,6 +49,8 @@ import { } from "../storage/record.ts"; import { insertJournalEvent, readJournalEntries } from "./journal.ts"; import { routeWorkflowRunJournal } from "./journal-route.ts"; +import { denoWorkspaceHost } from "./workspace/effect.ts"; +import { useWorkspaceHost } from "../workspace/effects.ts"; import type { RunConnection, RunConnectionLease, @@ -61,7 +63,7 @@ import { holdsTransactionOn, useTransactionSavepoints, } from "./transaction.ts"; -import { readDocumentExecution, readRetrieval, readRunRecord } from "./rows.ts"; +import { readDocumentExecution, readRetrieval, readRunRecord } from "../sqlite/rows.ts"; import { reading } from "./reading.ts"; import { translateSqliteError } from "./schema.ts"; @@ -93,6 +95,11 @@ export function openWorkflowRunDatabase( yield* ensure(() => { handle.close(); }); + // This host's answers for this exact handle, for as long as the handle is + // open. An attachment binds a narrower one over the top while a document + // runs; a caller that only opened storage still has the two reads a + // retained mapping and an ephemeral attachment need. + yield* useWorkspaceHost(handle.database, denoWorkspaceHost(handle.database)); yield* provide(handle.database); }); } diff --git a/packages/workflow/src/deno/delivery.ts b/packages/workflow/src/deno/delivery.ts index 6c3e345b9..6511d7b74 100644 --- a/packages/workflow/src/deno/delivery.ts +++ b/packages/workflow/src/deno/delivery.ts @@ -30,7 +30,6 @@ import { prepareElicitation, SecretDetectedError, type SecretFinding, - validateParsed, } from "@executablemd/core"; import { serializeDurableEvent } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; @@ -41,6 +40,7 @@ import { } from "../suspension/api.ts"; import { SUSPENSION_ANSWER } from "../suspension/answer.ts"; import { + parseAnswerDelivery, type WorkflowAnswerDelivery, WorkflowAnswerDeliveryError, type WorkflowAnswerRetention, @@ -48,7 +48,6 @@ import { } from "../suspension/delivery.ts"; import { SUSPENSION_REQUEST } from "../suspension/suspend.ts"; import { - WorkflowRequestError, WorkflowRunIdMismatchError, WorkflowRunNotFoundError, WorkflowStorageError, @@ -61,7 +60,7 @@ import { readRunRow } from "./database.ts"; import { useHostConnections } from "./host-connections.ts"; import { readJournalEntries } from "./journal.ts"; import { workflowRunPath } from "./path.ts"; -import { authorizedRoot, checkRunId } from "./provider.ts"; +import { authorizedRoot } from "./provider.ts"; import { readTransaction } from "./reading.ts"; import { translateSqliteError, verifySchema } from "./schema.ts"; @@ -92,14 +91,6 @@ export function* installWorkflowInputDelivery( ); } -/** A delivery whose every member has been checked rather than believed. */ -interface CheckedDelivery { - readonly runId: string; - readonly suspensionId: string; - readonly value: Json; - readonly secretDetection: boolean; -} - /** The wait a run is at, read from what it retained. */ interface RetainedWait { readonly record: WorkflowRunRecord; @@ -113,7 +104,7 @@ function* deliverAnswer( connections: WorkflowRunConnections, request: WorkflowAnswerDelivery, ): Operation> { - const checked = checkDelivery(request); + const checked = parseAnswerDelivery(request); if (!checked.ok) { return checked; } @@ -262,7 +253,7 @@ function* judgeAnswer( let issues; try { const prepared = yield* prepareElicitation(waiting.request.responseSchema, "workflow answer"); - issues = validateParsed(prepared.validate, value); + issues = prepared.validator.judge(value); } catch (error) { return Err( new WorkflowAnswerDeliveryError( @@ -429,90 +420,6 @@ function rollback(database: DatabaseSync): void { } } -const DELIVERY_MEMBERS = ["runId", "suspensionId", "value", "secretDetection"]; - -/** - * The whole request, parsed as a closed shape before any member is read. - * - * The type describes what a caller meant; what arrives is whatever the language - * allows. A suspension id is opaque and every character of it is part of it, so - * the only thing asked of it is that it is a non-empty string this run could - * have derived. - */ -function checkDelivery(offered: WorkflowAnswerDelivery): Result { - if (typeof offered !== "object" || offered === null || Array.isArray(offered)) { - return Err(new WorkflowRequestError("a delivery takes an object describing one answer.")); - } - const names = new Set(Object.keys(offered)); - const missing = DELIVERY_MEMBERS.filter((name) => !names.has(name)); - if (missing.length > 0) { - return Err(new WorkflowRequestError(`the delivery is missing ${missing.join(", ")}.`)); - } - - const runId = checkRunId(Reflect.get(offered, "runId")); - if (!runId.ok) { - return runId; - } - - const suspensionId = Reflect.get(offered, "suspensionId"); - if (typeof suspensionId !== "string" || suspensionId === "") { - return Err( - new WorkflowRequestError( - "a delivery names the wait it answers, and a suspension id is a non-empty string.", - ), - ); - } - - const secretDetection = Reflect.get(offered, "secretDetection"); - if (typeof secretDetection !== "boolean") { - return Err( - new WorkflowRequestError("a delivery says whether it crosses the secret gate, as a boolean."), - ); - } - - const value = retainableJson(Reflect.get(offered, "value")); - if (value === undefined) { - return Err( - new WorkflowRequestError( - "an answer is retained in this run's storage, so it must be JSON this run can store.", - ), - ); - } - - return Ok({ runId: runId.value, suspensionId, value, secretDetection }); -} - -/** The value, if every part of it is JSON this run can retain. */ -function retainableJson(value: unknown): Json | undefined { - let encoded: string | undefined; - try { - encoded = JSON.stringify(value); - } catch { - return undefined; - } - if (encoded === undefined) { - return undefined; - } - const parsed: unknown = JSON.parse(encoded); - return isJson(parsed) ? parsed : undefined; -} - -function isJson(value: unknown): value is Json { - if (value === null || typeof value === "string" || typeof value === "number") { - return true; - } - if (typeof value === "boolean") { - return true; - } - if (Array.isArray(value)) { - return value.every(isJson); - } - if (typeof value === "object") { - return Object.values(value).every(isJson); - } - return false; -} - /** * Report a storage refusal as itself, and let anything else propagate. * diff --git a/packages/workflow/src/deno/lifecycle.ts b/packages/workflow/src/deno/lifecycle.ts index 22eced242..a43c7d98f 100644 --- a/packages/workflow/src/deno/lifecycle.ts +++ b/packages/workflow/src/deno/lifecycle.ts @@ -104,7 +104,7 @@ import { readRetrievalMetadata, } from "./artifact-frontier.ts"; import type { WorkflowExportRequest, WorkflowExportResult } from "../lifecycle/export.ts"; -import { readDocumentExecution, readRetrieval, readRunRecord } from "./rows.ts"; +import { readDocumentExecution, readRetrieval, readRunRecord } from "../sqlite/rows.ts"; import { translateSqliteError, verifySchema, WorkflowReadonlyRollbackError } from "./schema.ts"; import { holdRecoveryCoordination } from "./recovery-coordination.ts"; diff --git a/packages/workflow/src/deno/provider.ts b/packages/workflow/src/deno/provider.ts index cdf10fe29..e3d89037b 100644 --- a/packages/workflow/src/deno/provider.ts +++ b/packages/workflow/src/deno/provider.ts @@ -55,7 +55,9 @@ import { parseMembers, requireMemberNames, } from "../storage/members.ts"; -import { canonicalJson, parseRunId, type WorkflowRunRecord } from "../storage/record.ts"; +import { canonicalJson, type WorkflowRunRecord } from "../storage/record.ts"; +import { type CheckedRequest, checkRunId, parseCreateRequest } from "../storage/create-request.ts"; +export { checkRunId } from "../storage/create-request.ts"; import { openWorkflowRunDatabase, readRunRow } from "./database.ts"; import { type RunConnection, @@ -162,20 +164,12 @@ export function authorizedRoot(root: string): string { return root; } -/** A request whose every member has been checked rather than believed. */ -interface CheckedRequest { - readonly runId: string; - readonly definition: WorkflowDefinition; - readonly base: string; - readonly props: JsonObject; -} - function* createWorkflowRun( root: string, connections: WorkflowRunConnections, request: CreateWorkflowRunRequest, ): Operation> { - const checked = checkRequest(request); + const checked = parseCreateRequest(request); if (!checked.ok) { return checked; } @@ -368,78 +362,3 @@ const REQUEST_MEMBERS = ["runId", "definition", "base", "props"]; * without types, or one that read the id out of a file, can hand over anything * at all, and hashing that would fail somewhere far less legible. */ -export function checkRunId(runId: unknown): Result { - try { - return Ok(parseRunId(runId, "$", runIdFailure)); - } catch (error) { - if (error instanceof WorkflowRequestError) { - return Err(error); - } - throw error; - } -} - -function runIdFailure(reason: string): Error { - return new WorkflowRequestError(`${reason}.`); -} - -/** - * The whole request, parsed as a closed shape before any member is read. - * - * The type describes what a caller meant. What arrives is whatever the - * language allows, and reading `.runId` off `null` fails as a `TypeError` - * rather than as an answer about the request. - */ -function checkRequest(offered: CreateWorkflowRunRequest): Result { - let members: Members; - try { - members = parseMembers(offered, "$", requestFailure); - requireMemberNames(members, REQUEST_MEMBERS, "$", requestFailure); - } catch (error) { - if (error instanceof WorkflowRequestError) { - return Err(error); - } - throw error; - } - - const runId = checkRunId(members.get("runId")); - if (!runId.ok) { - return runId; - } - - const base = members.get("base"); - if (typeof base !== "string" || base === "") { - return Err( - new WorkflowRequestError("a base is required: it is what the run's starting state is."), - ); - } - - const definition = parseWorkflowDefinition(members.get("definition")); - if (!definition.ok) { - return definition; - } - - let props: JsonObject; - try { - props = parseJsonObject(members.get("props"), "$", propsFailure); - } catch (error) { - if (error instanceof WorkflowRequestError) { - return Err(error); - } - throw error; - } - - return Ok({ runId: runId.value, definition: definition.value, base, props }); -} - -function requestFailure(reason: string, path: string): Error { - return new WorkflowRequestError( - `the request does not describe a workflow run: ${reason} at ${path}`, - ); -} - -function propsFailure(reason: string, path: string): Error { - return new WorkflowRequestError( - `the normalized props are not a JSON value: ${reason} at ${path}`, - ); -} diff --git a/packages/workflow/src/deno/remote-files.ts b/packages/workflow/src/deno/remote-files.ts new file mode 100644 index 000000000..8244385d8 --- /dev/null +++ b/packages/workflow/src/deno/remote-files.ts @@ -0,0 +1,213 @@ +/** + * The runner's own filesystem, as materialization needs to see it. + * + * `@effectionx/fs` covers the ordinary work but not the whole Workspace + * contract: a retained root carries symbolic links, hardlink groups, modes and + * modification times, and preserving those is what makes an untouched + * materialization capture back to the root it came from. The operations it + * lacks are adapted here from the runtime's own asynchronous primitives with + * `until`, which is the sanctioned way to reach one — not by making production + * code asynchronous and not by reaching for a synchronous call. + * + * `node:fs/promises` rather than a runtime global, because the same adapter has + * to work wherever the runner runs. Nothing above this module names a runtime, + * and nothing in this module decides anything about a Workspace: it moves bytes + * and metadata where it is told, and the rules live in shared code. + */ + +import { + chmod, + link, + lstat, + lchmod, + lutimes, + mkdir, + readdir, + readFile, + mkdtemp, + readlink, + rm, + symlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ensure, type Operation, resource, until } from "effection"; +import type { RunnerFiles, RunnerNode } from "../remote/materialize.ts"; +import type { TemporaryTrees } from "../remote/invocation.ts"; + +/** + * Whole milliseconds, which is the unit a retained entry records. + * + * Not seconds. The Workspace format carries whatever the retaining host's clock + * produced, and that clock is `Date.now`, so an adapter that reported seconds + * would describe every retained tree as a different one — and setting a + * millisecond value as though it were seconds would put the file tens of + * thousands of years from now, where the filesystem cannot keep it. + */ +function milliseconds(value: number): number { + return Math.round(value); +} + +function describeStats( + name: string, + stats: { + isDirectory(): boolean; + isSymbolicLink(): boolean; + mode: number; + mtimeMs: number; + size: number; + ino: number | bigint; + nlink: number | bigint; + }, + target: string | undefined, +): RunnerNode { + const kind = stats.isSymbolicLink() ? "symlink" : stats.isDirectory() ? "directory" : "file"; + return { + name, + kind, + // The permission bits only. The type bits are what `kind` already said, and + // a retained mode that carried them would not round-trip through the + // format's own bound. + mode: stats.mode & 0o7777, + mtime: milliseconds(stats.mtimeMs), + size: kind === "file" ? stats.size : 0, + // Only a file reached by more than one name can be part of a group, so + // anything else reports no identity and is captured on its own. + identity: kind === "file" && Number(stats.nlink) > 1 ? String(stats.ino) : undefined, + target, + }; +} + +/** + * `lchmod`, when the platform actually has it. + * + * BSD-derived systems do; Linux does not, and Node exposes the export + * regardless on some releases. Probing the export is the only honest test + * available before a real call. + */ +function lchmodOf(): ((path: string, mode: number) => Operation) | undefined { + if (typeof lchmod !== "function") { + return undefined; + } + return function* (path: string, mode: number): Operation { + yield* until(lchmod(path, mode)); + }; +} + +/** The runner's filesystem operations, for one materialized tree. */ +export function runnerFiles(): RunnerFiles { + return { + *makeDirectory(path: string, mode: number): Operation { + yield* until(mkdir(path, { recursive: false, mode })); + }, + + *removeTree(path: string): Operation { + yield* until(rm(path, { recursive: true, force: true })); + }, + + *writeFile(path: string, bytes: Uint8Array, mode: number): Operation { + yield* until(writeFile(path, bytes, { mode })); + }, + + *makeSymlink(target: string, path: string): Operation { + yield* until(symlink(target, path)); + }, + + *makeHardlink(existing: string, path: string): Operation { + yield* until(link(existing, path)); + }, + + *setMode(path: string, mode: number): Operation { + // Explicit rather than relying on the creation mode, which the process + // umask narrows. A retained mode is durable identity. + yield* until(chmod(path, mode)); + }, + + *setModifiedAt(path: string, mtime: number): Operation { + // `utimes` speaks seconds; the format speaks milliseconds. + yield* until(utimes(path, mtime / 1000, mtime / 1000)); + }, + + /** + * A link's own time, set without following it. + * + * `lutimes` is what makes this possible at all: `utimes` would follow the + * link and rewrite whatever it points at, which may be outside the tree + * entirely. + */ + *setLinkModifiedAt(path: string, mtime: number): Operation { + yield* until(lutimes(path, mtime / 1000, mtime / 1000)); + }, + + /** + * A link's own permissions, where the platform has them. + * + * Linux ignores symbolic-link permission bits and offers no `lchmod`, so + * this is deliberately absent there rather than faked. Materialization + * checks what it actually got and refuses a root this host cannot + * represent, which is the honest outcome; quietly writing a different mode + * would change durable identity. + */ + setLinkMode: lchmodOf(), + + *readFile(path: string): Operation { + return new Uint8Array(yield* until(readFile(path))); + }, + + *list(path: string): Operation { + const names = yield* until(readdir(path)); + const found: RunnerNode[] = []; + for (const name of names) { + const entry = join(path, name); + const stats = yield* until(lstat(entry)); + // Read, never resolved: what a retained link points at is part of the + // Workspace's description of itself, not somewhere to go looking. + const target: string | undefined = stats.isSymbolicLink() + ? yield* until(readlink(entry)) + : undefined; + found.push(describeStats(name, stats, target)); + } + return found; + }, + + *describe(path: string): Operation { + const stats = yield* until(lstat(path)); + return describeStats("", stats, undefined); + }, + }; +} + +/** + * Temporary trees for one invocation, owned by the scope that asked for them. + * + * Every tree this hands out is removed when that scope ends, however it ends. + * A run that left one behind would leave a materialized Workspace on a machine + * that has stopped being responsible for it. + */ +export function useRunnerTrees(): Operation { + return resource(function* (provide) { + const roots: string[] = []; + yield* ensure(function* () { + // In reverse, so a nested tree goes before whatever contains it. + for (const root of roots.toReversed()) { + yield* until(rm(root, { recursive: true, force: true })); + } + }); + yield* provide({ + *create(purpose: string): Operation { + const root = yield* until(mkdtemp(join(tmpdir(), `xmd-workflow-${purpose}-`))); + roots.push(root); + return root; + }, + *remove(path: string): Operation { + yield* until(rm(path, { recursive: true, force: true })); + const found = roots.indexOf(path); + if (found >= 0) { + roots.splice(found, 1); + } + }, + }); + }); +} diff --git a/packages/workflow/src/deno/remote-host.ts b/packages/workflow/src/deno/remote-host.ts new file mode 100644 index 000000000..ec4e422d0 --- /dev/null +++ b/packages/workflow/src/deno/remote-host.ts @@ -0,0 +1,67 @@ +/** + * The remote lifecycle provider, with this runner's own local facilities. + * + * The provider is host-neutral: it knows how to take an acquisition, hold a + * lock, move a run's lifecycle and assemble a fork, and it knows none of what + * those need underneath. This is where a Deno runner supplies them — reaching + * an owner, reading a source without acquiring it, and building a fork + * candidate on its own disk — so nothing host-specific reaches the shared + * modules and the staging facility is a real implementation rather than a seam + * waiting for one. + * + * Internal on purpose. Assembling this into a configured host is a later + * slice's; what exists here is the wiring the lifecycle needs to work at all. + */ + +import { randomUUID } from "node:crypto"; +import type { Operation, Result } from "effection"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import type { WorkflowExecutionTransitions, WorkflowForkRequest } from "../lifecycle/execution.ts"; +import type { WorkflowRunDatabase } from "../storage/api.ts"; +import type { RemoteExecutorConnection } from "../remote/lifecycle-link.ts"; +import type { RemoteForkSource, RemoteReadPlane } from "../remote/read.ts"; +import { useRemoteLifecycle } from "../remote/lifecycle.ts"; +import { useWorkflowRunConnections, type WorkflowRunConnections } from "./connections.ts"; +import { stageRemoteFork } from "./remote-staging.ts"; +import { authorizedRoot } from "./provider.ts"; + +/** What a Deno runner supplies the remote lifecycle beyond its own disk. */ +export interface RemoteWorkflowLifecycleOptions { + /** Where fork candidates are assembled. Nothing else is kept here. */ + readonly root: string; + /** Admit one executor connection for this run, owned by the calling scope. */ + admit(runId: string): Operation>; + /** The no-acquisition read plane for one run. */ + source(runId: string): Operation>; +} + +/** + * Install the remote executor lifecycle for a Deno runner. + * + * The connections it stages through belong to this scope, so a candidate's + * database handles and files go when the scope that asked for them ends. + */ +export function* installRemoteWorkflowLifecycle( + options: RemoteWorkflowLifecycleOptions, + connections?: WorkflowRunConnections, +): Operation { + const root = authorizedRoot(options.root); + const held = connections ?? (yield* useWorkflowRunConnections()); + return yield* useRemoteLifecycle({ + admit: options.admit, + source: options.source, + *stage( + request: WorkflowForkRequest, + source: RemoteForkSource, + head: { readonly runRecord: DurableEvent; readonly rootImport: DurableEvent }, + ): Operation> { + return yield* stageRemoteFork(held, root, request, source, head); + }, + ids: { + // Opaque and minted here, never taken from a caller: an identity a + // document could choose is one two runs could share. + execution: () => randomUUID(), + command: () => randomUUID(), + }, + }); +} diff --git a/packages/workflow/src/deno/remote-runner.ts b/packages/workflow/src/deno/remote-runner.ts new file mode 100644 index 000000000..ac8ea83f2 --- /dev/null +++ b/packages/workflow/src/deno/remote-runner.ts @@ -0,0 +1,228 @@ +/** + * One runner, for one run whose storage is somewhere else. + * + * The four things a host has to be able to do — move the run's lifecycle, read + * it, deliver into it, and attach it to a document execution — over + * one configured owner client. Everything underneath is already built and + * proved: the provider-neutral executor lifecycle, the no-acquisition read and + * delivery planes, and the runner's Workspace coordinator. What this adds is + * the composition, and the one thing composition has to get right. + * + * That one thing is the handoff from the lifecycle to the attachment. A begin + * transition hands back a storage handle; an attachment needs the Workspace + * runtime for the *same* run, over the same connection. Matching a run id, a + * root or an anchor would be enough for two clients on two owners to satisfy — + * their records can be identical — so nothing here matches a field. The handle + * says which link it was opened from, this runner remembers the links its own + * acquisitions produced, and both answers have to be the same object. + * + * Native work stays here. Materialization, the invocation-owned temporary + * trees and the containment-checked filesystem are the runner's own, and the + * owner runs none of them. + * + * One seam is deliberately not installed. The remote run-storage provider + * answers `create` and `lookup` over an executor link, and a link is an + * acquisition — so installing it here would mean holding one run's executor + * connection open for as long as the host lives, whether or not anything ever + * executed. Creation and lookup happen where the acquisition already is: the + * begin transition takes one, creates or finds the run inside it, and gives it + * back when its scope ends. + */ + +import type { Operation, Result } from "effection"; +import type { DurableEffect, EffectDescription, Json } from "@executablemd/durable-streams"; +import type { WorkflowExecutionTransitions } from "../lifecycle/execution.ts"; +import type { WorkflowRunDatabase } from "../storage/api.ts"; +import { + remoteRunOrigin, + type RemoteRunLink, + type RemoteWorkspaceLink, +} from "../remote/database.ts"; +import type { RemoteExecutorConnection } from "../remote/lifecycle-link.ts"; +import type { RemoteDeliveryLink } from "../remote/answer-link.ts"; +import type { RemoteReadPlane } from "../remote/read.ts"; +import { installRemoteInputDelivery } from "../remote/delivery.ts"; +import { useRemoteLifecycleReads } from "../remote/inspection.ts"; +import { + createRemoteWorkspaceEffect, + readRemoteWorkspace, + transactRemoteAgentSessions, + useRemoteRun, + useRemoteWorkspaceEffects, + withRemoteWorkspaceEffects, +} from "../remote/workspace.ts"; +import { + useWorkspaceHost, + type WorkspaceAttachmentView, + type WorkspaceMutation, +} from "../workspace/effects.ts"; +import type { AgentSessions } from "../storage/agent-session.ts"; +import { withDocumentCapabilities } from "./workspace/host.ts"; +import { permittedWorkspaceOptions } from "./workspace/published.ts"; +import type { WorkflowWorkspaceOptions } from "./workspace/published.ts"; +import { WorkflowRequestError } from "../storage/errors.ts"; +import { installRemoteWorkflowLifecycle } from "./remote-host.ts"; +import { createRemoteWorkspaceFilesystem } from "./remote-workspace-files.ts"; +import { runnerFiles, useRunnerTrees } from "./remote-files.ts"; + +/** The owner this runner reaches, as a configured client supplies it. */ +export interface RemoteRunnerOwner { + /** The run this client is bound to. */ + readonly runId: string; + /** Admit one executor connection for this run, owned by the calling scope. */ + 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; +} + +/** What a trusted host supplies to assemble one runner. */ +export interface RemoteWorkflowRunnerOptions { + /** The configured client for the one owner this runner works against. */ + readonly owner: RemoteRunnerOwner; + /** + * Where this runner assembles fork candidates. + * + * Runner-local scratch, and nothing durable: an absolute directory this + * process may write, supplied explicitly rather than read from anywhere. + */ + readonly scratchRoot: string; + /** + * What a live or partial attachment installs beyond the run's own Workspace. + * + * The host-owned inputs the optional capabilities need — the credential + * helper, the Issue and pull-request configuration, and the Agent profile + * installer — supplied explicitly by whoever assembled this runner. Nothing + * here is read from a flag, an environment variable, a prop or a global, and + * an absent member installs the capability's unconfigured behavior rather + * than a different one. + * + * The published options, not the broad internal ones. A substituted + * repository host, a Git-host transport or an invocation observer is a seam + * through which a credential this run acquires would become visible to + * whoever supplied it, and this is a public export: what it accepts is what + * a *host* owns, and the projection into the internal shape is explicit + * rather than a spread of whatever arrived. + */ + readonly capabilities?: WorkflowWorkspaceOptions; +} + +/** What a host installs for a run whose storage is somewhere else. */ +export interface RemoteWorkflowRunner { + /** Install the executor lifecycle for this owner; hand back its transitions. */ + useRunHost(): Operation; + /** Install status, list and history over the no-acquisition read plane. */ + useLifecycle(): Operation; + /** Install typed answer delivery over the no-acquisition delivery plane. */ + useDelivery(): Operation; + /** Attach this run's Workspace to one live or partial document execution. */ + attach(database: WorkflowRunDatabase, operation: Operation): Operation; +} + +/** + * Assemble one runner for one owner. + * + * The scope that asks owns everything this installs: the acquisition it takes, + * the temporary trees it materializes into, and the handles it opens all end + * when that scope does. + */ +export function* useRemoteWorkflowRunner( + options: RemoteWorkflowRunnerOptions, +): Operation { + const { owner } = options; + /** + * The links this runner's own acquisitions produced. + * + * Keyed by the link object, so membership is identity. A link from another + * client — or a value shaped like one — is not in here, and a handle opened + * from it cannot be attached however closely its record matches. + */ + const admitted = new WeakMap(); + + return { + *useRunHost(): Operation { + const transitions = yield* installRemoteWorkflowLifecycle({ + root: options.scratchRoot, + *admit(runId: string): Operation> { + const connection = yield* owner.admit(runId); + if (connection.ok && connection.value !== "already-running") { + admitted.set(connection.value.link, connection.value.link); + } + return connection; + }, + source: (runId: string) => owner.reads(runId), + }); + return transitions; + }, + + *useLifecycle(): Operation { + const plane = yield* owner.reads(owner.runId); + if (!plane.ok) { + throw plane.error; + } + yield* useRemoteLifecycleReads(plane.value); + }, + + *useDelivery(): Operation { + yield* installRemoteInputDelivery(owner.delivery); + }, + + *attach(database: WorkflowRunDatabase, operation: Operation): Operation { + const origin = remoteRunOrigin(database); + const link = origin === undefined ? undefined : admitted.get(origin.link); + if (link === undefined) { + // Not a handle this runner's own lifecycle opened. Nothing about the + // handle is quoted back: what is wrong is which handle it is, and a + // diagnostic naming a run would name the wrong one. + throw new WorkflowRequestError( + "this workflow run storage was not opened by this remote host, and cannot be attached.", + ); + } + const host = runnerFiles(); + const trees = yield* useRunnerTrees(); + const run = yield* useRemoteRun({ + link, + database, + files: host, + trees, + createFilesystem: (at, authorize) => createRemoteWorkspaceFilesystem(at, authorize), + }); + yield* useRemoteWorkspaceEffects(run); + // The document's own capabilities, over this exact binding. Installed + // together because either half alone is wrong: the rules without the + // binding would reach whatever filesystem an entrypoint left in scope, + // and the binding without the rules would be a coordinator nothing asks. + yield* useWorkspaceHost(database, { + create( + description: EffectDescription, + mutate: WorkspaceMutation, + ): DurableEffect { + return createRemoteWorkspaceEffect(run, description, (filesystem, metadata) => + mutate(filesystem, metadata), + ); + }, + + read( + body: (view: WorkspaceAttachmentView) => Operation, + ): Operation> { + return readRemoteWorkspace(run, body); + }, + + sessions( + body: (sessions: AgentSessions) => Operation, + ): Operation> { + return transactRemoteAgentSessions(run, body); + }, + }); + return yield* withRemoteWorkspaceEffects( + run, + withDocumentCapabilities( + database, + operation, + permittedWorkspaceOptions(options.capabilities ?? {}), + ), + ); + }, + }; +} diff --git a/packages/workflow/src/deno/remote-staging.ts b/packages/workflow/src/deno/remote-staging.ts new file mode 100644 index 000000000..c1fefe664 --- /dev/null +++ b/packages/workflow/src/deno/remote-staging.ts @@ -0,0 +1,132 @@ +/** + * Assembling a remote fork's candidate on this runner's own disk. + * + * A fork is admitted by replaying it, and a replay needs the fork's own + * Workspace: a `` resolves through the run's filesystem, and a candidate + * without one produces effects of a different kind and diverges for a reason + * that has nothing to do with the candidate. So the snapshot read from a remote + * source is assembled here, in full, at a staging path — and thrown away when + * the scope that asked for it ends. + * + * This is the same assembly a committed fork gets. It is the local host's own + * `stageFork()` kernel, handed the same snapshot shape it always takes; nothing + * here writes a second fork writer, and nothing re-reads the source. What this + * module is, is the translation: a `RemoteForkSource` says its digests in hex + * because that is what crossed a wire, and the local snapshot says them in + * bytes because that is what SQLite holds. + */ + +import { type Operation, type Result } from "effection"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import type { WorkflowForkRequest } from "../lifecycle/execution.ts"; +import type { WorkflowRunDatabase } from "../storage/api.ts"; +import type { RemoteForkSource } from "../remote/read.ts"; +import type { ForkSourceSnapshot } from "./fork-source.ts"; +import type { WorkflowRunConnections } from "./connections.ts"; +import { stageFork } from "./transitions.ts"; +import { workflowForkStaging } from "./path.ts"; + +/** One digest, as the store holds it rather than as a wire spells it. */ +function bytesOfHex(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let at = 0; at < bytes.length; at += 1) { + bytes[at] = Number.parseInt(hex.slice(at * 2, at * 2 + 2), 16); + } + return bytes; +} + +/** + * The same snapshot, said the way this host says it. + * + * Nothing is recomputed and nothing is dropped: the retained record strings + * cross byte for byte, the roots keep their canonical reference arrays, the + * content keeps the watermarks copied beside it, and the checkouts keep every + * member the destination will retain. + */ +export function localForkSnapshot(source: RemoteForkSource): ForkSourceSnapshot { + return { + sourceRunId: source.sourceRunId, + checkpointEventId: source.checkpointEventId, + checkpointWorkspaceRootId: source.checkpointWorkspaceRootId, + runRecordWorkspaceRootId: source.runRecordWorkspaceRootId, + rootImportWorkspaceRootId: source.rootImportWorkspaceRootId, + inherited: source.inherited.map((row) => ({ + eventId: row.eventId, + record: row.record, + workspaceRootId: row.workspaceRootId, + })), + roots: source.roots.map((root) => ({ + rootId: root.rootId, + formatVersion: root.formatVersion, + manifest: root.manifest, + manifestHashes: [...root.manifestHashes], + blobHashes: [...root.blobHashes], + })), + manifests: source.manifests.map((manifest) => ({ + hash: bytesOfHex(manifest.hash), + size: manifest.size, + encoded: manifest.encoded, + lastSeen: manifest.lastSeen, + })), + blobs: source.blobs.map((blob) => ({ + hash: bytesOfHex(blob.hash), + size: blob.size, + lastSeen: blob.lastSeen, + content: blob.content, + })), + repositories: source.checkouts.flatMap((checkout) => + checkout.kind === "repository" + ? [ + { + name: checkout.name, + locator: checkout.locator, + locatorFingerprint: checkout.locatorFingerprint, + requestedBase: checkout.requestedBase, + creationCommit: checkout.creationCommit, + primaryBranch: checkout.primaryBranch, + objectFormat: checkout.objectFormat, + checkoutPath: checkout.checkoutPath, + }, + ] + : [], + ), + worktrees: source.checkouts.flatMap((checkout) => + checkout.kind === "worktree" + ? [ + { + repositoryName: checkout.repositoryName, + name: checkout.name, + requestedBranch: checkout.requestedBranch, + requestedBase: checkout.requestedBase, + creationCommit: checkout.creationCommit, + checkoutPath: checkout.checkoutPath, + }, + ] + : [], + ), + }; +} + +/** + * Build one remote fork's candidate locally, owned by the calling scope. + * + * The staging file is scratch: a leftover from an attempt that did not finish + * is replaced rather than continued, and whatever happens — success, failure or + * cancellation — it goes when the scope ends. Nothing about it is a run: no + * lock is taken anywhere, no owner is contacted, and no host discovers it. + */ +export function stageRemoteFork( + connections: WorkflowRunConnections, + root: string, + request: WorkflowForkRequest, + source: RemoteForkSource, + head: { readonly runRecord: DurableEvent; readonly rootImport: DurableEvent }, +): Operation> { + return stageFork( + connections, + workflowForkStaging(root, request.runId), + request, + localForkSnapshot(source), + head, + ); +} diff --git a/packages/workflow/src/deno/remote-workspace-files.ts b/packages/workflow/src/deno/remote-workspace-files.ts new file mode 100644 index 000000000..af9a727b5 --- /dev/null +++ b/packages/workflow/src/deno/remote-workspace-files.ts @@ -0,0 +1,341 @@ +/** + * The Workspace filesystem, over the attempt directory this invocation owns. + * + * The runner's Workspace is a real directory it materialized from the owner, so + * the operations are the runtime's own asynchronous primitives adapted with + * `until`. Nothing above this module names a runtime, and nothing in it decides + * anything about a Workspace: it moves bytes where it is told, and refuses to + * be told anywhere outside the attempt. + * + * ## Why lexical admission is not containment here + * + * The Deno host's Workspace is rows in a database, so a path there has no + * outside to reach and admission is arithmetic. This one is a real directory on + * a host that has an outside, and a symbolic link is a path the kernel follows + * on its own. Comparing the *spelling* of a path with the attempt root admits + * `/link` while the syscall that follows reads whatever `/link` points at. + * + * So every operation resolves before it acts, on the same terms + * `packages/runtime/host-files.ts` states for the host provider: a complete + * `..` segment leaves and `..notes.md` does not; the existing prefix is walked + * so a path that does not exist yet can still be judged by its deepest + * existing ancestor; an operation that acts on a link does not follow it, and + * one whose contract follows a link follows only where that link lands inside + * the attempt. + * + * ## A link's target is a Workspace path, not a host path + * + * A retained symbolic link carries its target as text, and that text is + * interpreted in the Workspace it belongs to. An absolute target names the + * logical Workspace root — the root of the tree this invocation owns — not the + * runner host's root. Letting the kernel interpret `/etc/passwd` would turn a + * retained Workspace entry into authority over the machine, so resolution is + * done here, one segment at a time, and the host is asked only about paths that + * are already known to be inside. + * + * The stable-host-namespace limitation the host provider documents applies here + * too: another process can replace a directory between the moment this resolves + * a path and the moment it uses one. That window is not what this closes. + */ + +import { + chmod, + link, + lstat, + mkdir, + readdir, + readFile, + readlink, + rename, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import type { Stats } from "node:fs"; +import { type Operation, until } from "effection"; +import type { + WorkspaceEntry, + WorkspaceFilesystem, + WorkspaceStat, +} from "../workspace/filesystem.ts"; +import { throwWorkspaceFilesystemFailure } from "./workspace/errors.ts"; +import type { HostPath } from "../remote/materialize.ts"; + +/** A path no Workspace operation may reach, whatever it names. */ +export class WorkspacePathError extends Error { + override name = "WorkspacePathError"; + + constructor() { + // No path, no target and no host directory: what a document may learn is + // that it asked for somewhere it does not own. + super("this Workspace path is outside the tree this invocation owns."); + } +} + +/** How many links one resolution will follow before calling it a loop. */ +const MAX_LINKS = 32; + +/** + * The logical segments this path names, or `undefined` if it leaves the root. + * + * Pure arithmetic on POSIX segments, decided before anything touches the host. + * `.` and an empty segment are nothing; a complete `..` pops, and popping past + * the root is the escape. A segment that merely begins with two dots is an + * ordinary name and stays. + */ +function segmentsOf(base: readonly string[], path: string): string[] | undefined { + const segments = path.startsWith("/") ? [] : [...base]; + for (const segment of path.split("/")) { + if (segment === "" || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length === 0) { + return undefined; + } + segments.pop(); + continue; + } + segments.push(segment); + } + return segments; +} + +/** What one operation may act on: where it is, and whether a link was left alone. */ +interface Resolved { + readonly segments: readonly string[]; +} + +/** + * Walk the path, following the links inside it, and refuse the ones that leave. + * + * `followFinal` is the difference between an operation about a file and an + * operation about a link. A read follows the last link to the file it names, + * because replacing or reporting the link would surprise a caller that asked + * for the file; `lstat`, `readlink`, a removal and a rename act on the entry + * the caller named, so the last segment is left exactly as written. + * + * A path that does not exist is not an error: the walk stops at the deepest + * existing ancestor and keeps the rest, which is what lets a write name a file + * it is about to create and still be judged. + */ +function* resolve(root: string, path: string, followFinal: boolean): Operation { + if (path === "" || path.includes("\u0000")) { + throw new WorkspacePathError(); + } + const admitted = segmentsOf([], path); + if (admitted === undefined) { + throw new WorkspacePathError(); + } + let segments: string[] = admitted; + + for (let followed = 0; ; followed += 1) { + if (followed > MAX_LINKS) { + throw new WorkspacePathError(); + } + const crossing = yield* firstLink(root, segments, followFinal); + if (crossing === undefined) { + return { segments }; + } + // The target is read in the Workspace this link belongs to: absolute means + // the Workspace root, and relative means beside the link. + const next = segmentsOf(segments.slice(0, crossing.depth - 1), crossing.target); + if (next === undefined) { + throw new WorkspacePathError(); + } + segments = [...next, ...segments.slice(crossing.depth)]; + } +} + +/** + * The shallowest segment of this path that is a symbolic link, if any. + * + * Shallowest rather than any, because substituting a link's target changes + * every segment beneath it — resolving a deeper one first would resolve it + * against a prefix that is about to be replaced. + */ +function* firstLink( + root: string, + segments: readonly string[], + followFinal: boolean, +): Operation<{ depth: number; target: string } | undefined> { + const last = followFinal ? segments.length : segments.length - 1; + for (let depth = 1; depth <= last; depth += 1) { + const host = hostPath(root, segments.slice(0, depth)); + const entry: Stats | undefined = yield* describing(host); + if (entry === undefined) { + // Nothing here, so nothing below it exists either. What the caller named + // is judged by the ancestor that does exist, which this walk has passed. + return undefined; + } + if (entry.isSymbolicLink()) { + return { depth, target: yield* until(readlink(host)) }; + } + } + return undefined; +} + +/** What this entry is, or nothing when there is no entry here. */ +function* describing(host: string): Operation { + try { + return yield* until(lstat(host)); + } catch { + return undefined; + } +} + +function hostPath(root: string, segments: readonly string[]): string { + return segments.length === 0 ? root : `${root}/${segments.join("/")}`; +} + +function described(value: { + mode: number; + mtimeMs: number; + size: number; + isFile(): boolean; + isDirectory(): boolean; +}): WorkspaceStat { + const kind = value.isFile() ? "file" : value.isDirectory() ? "directory" : "symlink"; + // The retained mode is the permission bits; the type bits belong to the + // node's kind, which is reported beside it. + return { kind, mode: value.mode & 0o7777, mtime: Math.trunc(value.mtimeMs), size: value.size }; +} + +/** + * A runtime failure, named the way the shared classifier reads one. + * + * The classifier asks for a `WorkspaceFsError` carrying a documented code, + * because that is what the other host raises. Renaming here rather than + * widening the classifier keeps one list of documented conditions. The + * message and the host path inside it are dropped: what reaches a document is + * the condition, never where this invocation happened to put its tree. + */ +function named(error: unknown): unknown { + const code = error instanceof Error ? Reflect.get(error, "code") : undefined; + if (error instanceof Error && typeof code === "string") { + const renamed = new Error(`the Workspace operation failed (${code})`); + renamed.name = "WorkspaceFsError"; + Reflect.set(renamed, "code", code); + return renamed; + } + return error; +} + +export function createRemoteWorkspaceFilesystem( + at: HostPath, + authorize: () => void, +): WorkspaceFilesystem { + // The attempt's own root, taken from the same resolver every other path goes + // through. Every host path this module builds is this root plus segments it + // has already admitted, so no authored text reaches a syscall unexamined. + const root = at("/"); + + function* run( + path: string, + followFinal: boolean, + body: (host: string) => Promise, + ): Operation { + authorize(); + // Resolved immediately before the operation it authorizes, never cached: a + // path admitted once is not a capability to use later. + const resolved = yield* resolve(root, path, followFinal); + try { + return yield* until(body(hostPath(root, resolved.segments))); + } catch (error) { + // The same classification the Deno host applies: a documented filesystem + // condition is the effect's own outcome, and everything else is the run + // failing. + return throwWorkspaceFilesystemFailure(named(error)); + } + } + + /** Both ends of a two-path operation, each admitted at the time of use. */ + function* pair( + from: string, + to: string, + followFrom: boolean, + body: (source: string, destination: string) => Promise, + ): Operation { + authorize(); + const source = yield* resolve(root, from, followFrom); + const destination = yield* resolve(root, to, false); + try { + return yield* until( + body(hostPath(root, source.segments), hostPath(root, destination.segments)), + ); + } catch (error) { + return throwWorkspaceFilesystemFailure(named(error)); + } + } + + return { + *readFile(path): Operation { + return yield* run(path, true, (host) => readFile(host)); + }, + + *readTextFile(path): Operation { + const bytes = yield* run(path, true, (host) => readFile(host)); + return new TextDecoder().decode(bytes); + }, + + *stat(path): Operation { + return described(yield* run(path, true, (host) => stat(host))); + }, + + *lstat(path): Operation { + // About the entry, so the last segment stays what it is. + return described(yield* run(path, false, (host) => lstat(host))); + }, + + *readlink(path): Operation { + // The retained target, exactly as it was written. It is a Workspace path, + // and reading it back is not resolving it. + return yield* run(path, false, (host) => readlink(host)); + }, + + *readdir(path): Operation { + const entries = yield* run(path, true, (host) => readdir(host, { withFileTypes: true })); + return entries.map((entry) => ({ + name: entry.name, + kind: entry.isFile() ? "file" : entry.isDirectory() ? "directory" : "symlink", + })); + }, + + *writeFile(path, content, mode): Operation { + const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content; + // Follows an internal link to the file it names: replacing the link would + // be the surprising outcome, and an outward one never got this far. + yield* run(path, true, (host) => writeFile(host, bytes, mode === undefined ? {} : { mode })); + }, + + *mkdir(path, options = {}): Operation { + yield* run(path, true, (host) => mkdir(host, options).then(() => undefined)); + }, + + *remove(path, options = {}): Operation { + // A removal takes the entry the caller named. Following a final link + // would remove something never mentioned. + yield* run(path, false, (host) => rm(host, options)); + }, + + *rename(from, to): Operation { + yield* pair(from, to, false, (source, destination) => rename(source, destination)); + }, + + *chmod(path, mode): Operation { + yield* run(path, true, (host) => chmod(host, mode)); + }, + + *symlink(target, path): Operation { + // The target is not resolved: a link's target is retained text, and it is + // interpreted when the link is walked. What is admitted here is where the + // link itself is created. + yield* run(path, false, (host) => symlink(target, host)); + }, + + *link(existingPath, newPath): Operation { + yield* pair(existingPath, newPath, true, (source, destination) => link(source, destination)); + }, + }; +} diff --git a/packages/workflow/src/deno/schema.ts b/packages/workflow/src/deno/schema.ts index 3b3fdc4c2..7073c5e7e 100644 --- a/packages/workflow/src/deno/schema.ts +++ b/packages/workflow/src/deno/schema.ts @@ -32,462 +32,20 @@ import { WorkflowIncompleteVersionOneError, WorkflowSchemaVersionError, } from "../storage/errors.ts"; +import { + APPLICATION_ID, + declaredStructureFailure, + EXPECTED_SCHEMA, + hasAnyDeclaredObject, + REQUIRED_OBJECTS, + REQUIRED_TABLES, + SCHEMA_SQL, + SCHEMA_VERSION, + type SchemaObject, +} from "../sqlite/workflow-schema.ts"; import { reading } from "./reading.ts"; import { initializeEmptyWorkspace, verifyWorkspace } from "./workspace/root.ts"; -/** - * The bytes `XMD1` as a 32-bit integer, written into the SQLite header. - * - * A database carries what wrote it, so a file that is perfectly valid SQLite - * and belongs to something else is refused on sight rather than through the - * confusing shape of its missing tables. - */ -export const APPLICATION_ID = 0x584d4431; - -/** The only schema version this build reads or writes. */ -export const SCHEMA_VERSION = 1; - -const STATUSES = "'running', 'suspended', 'interrupted', 'completed', 'failed', 'cancelled'"; - -/** - * A stop reason is three columns wide and has three legal shapes. - * - * Spreading the variant across columns is what lets SQLite hold the invariant - * rather than the code that writes rows: a host reason with an event id, or a - * journal reason with a code, is refused by the database itself. - */ -function coherentStopReason(): string { - return `CHECK ( - (stop_reason_kind IS NULL AND stop_reason_code IS NULL AND stop_reason_event_id IS NULL) - OR (stop_reason_kind = 'host' AND stop_reason_code IS NOT NULL AND stop_reason_event_id IS NULL) - OR (stop_reason_kind = 'journal' AND stop_reason_code IS NULL AND stop_reason_event_id IS NOT NULL) - )`; -} - -/** - * Version 1, one table at a time. - * - * Kept as separate definitions so verification can compare what a file holds - * with what this build writes, rather than settling for the table's name. - * - * The complete version-1 shape includes the pinned DOFS objects, retained - * Workspace roots, journal and metadata. Dependency order is explicit: DOFS - * content precedes root references, and roots precede the journal rows that - * name them. - */ -interface DeclaredObject { - readonly type: "table" | "index"; - readonly sql: string; -} - -const OBJECTS: ReadonlyMap = new Map([ - [ - "vfs_meta", - { - type: "table", - sql: `CREATE TABLE vfs_meta ( - k TEXT PRIMARY KEY, - v INTEGER NOT NULL - )`, - }, - ], - [ - "vfs_nodes", - { - type: "table", - sql: `CREATE TABLE vfs_nodes ( - inode INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL CHECK(type IN ('file','dir','symlink')), - mode INTEGER NOT NULL DEFAULT 493, - mtime INTEGER NOT NULL, - rev INTEGER NOT NULL DEFAULT 0, - mount_root TEXT, - stub_size INTEGER, - manifest_hash BLOB, - link_target TEXT, - size INTEGER NOT NULL DEFAULT 0 - )`, - }, - ], - [ - "vfs_dirents", - { - type: "table", - sql: `CREATE TABLE vfs_dirents ( - parent_inode INTEGER NOT NULL, - name TEXT NOT NULL, - child_inode INTEGER NOT NULL, - PRIMARY KEY (parent_inode, name) - ) WITHOUT ROWID`, - }, - ], - [ - "vfs_dirents_by_child", - { - type: "index", - sql: "CREATE INDEX vfs_dirents_by_child ON vfs_dirents(child_inode)", - }, - ], - [ - "vfs_nodes_by_rev", - { - type: "index", - sql: "CREATE INDEX vfs_nodes_by_rev ON vfs_nodes(rev)", - }, - ], - [ - "vfs_nodes_by_manifest_hash", - { - type: "index", - sql: `CREATE INDEX vfs_nodes_by_manifest_hash - ON vfs_nodes(manifest_hash) WHERE manifest_hash IS NOT NULL`, - }, - ], - [ - "vfs_blobs", - { - type: "table", - sql: `CREATE TABLE vfs_blobs ( - hash BLOB PRIMARY KEY, - size INTEGER NOT NULL, - last_seen INTEGER NOT NULL - )`, - }, - ], - [ - "vfs_blob_bytes", - { - type: "table", - sql: `CREATE TABLE vfs_blob_bytes ( - hash BLOB PRIMARY KEY REFERENCES vfs_blobs(hash) ON DELETE CASCADE, - bytes BLOB NOT NULL - )`, - }, - ], - [ - "vfs_chunks", - { - type: "table", - sql: `CREATE TABLE vfs_chunks ( - inode INTEGER NOT NULL, - idx INTEGER NOT NULL, - hash BLOB NOT NULL, - size INTEGER NOT NULL, - PRIMARY KEY (inode, idx) - ) WITHOUT ROWID`, - }, - ], - [ - "vfs_chunks_by_hash", - { - type: "index", - sql: "CREATE INDEX vfs_chunks_by_hash ON vfs_chunks(hash)", - }, - ], - [ - "vfs_manifests", - { - type: "table", - sql: `CREATE TABLE vfs_manifests ( - hash BLOB PRIMARY KEY, - size INTEGER NOT NULL, - encoded BLOB NOT NULL, - last_seen INTEGER NOT NULL DEFAULT 0 - )`, - }, - ], - [ - "vfs_changes", - { - type: "table", - sql: `CREATE TABLE vfs_changes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - rev INTEGER NOT NULL, - path TEXT NOT NULL, - op TEXT NOT NULL CHECK(op IN ('delete')) - )`, - }, - ], - [ - "vfs_changes_by_rev", - { - type: "index", - sql: "CREATE INDEX vfs_changes_by_rev ON vfs_changes(rev)", - }, - ], - [ - "vfs_changes_by_path", - { - type: "index", - sql: "CREATE INDEX vfs_changes_by_path ON vfs_changes(path, id DESC)", - }, - ], - [ - "_vfs_watermark", - { - type: "table", - sql: `CREATE TABLE _vfs_watermark ( - k TEXT NOT NULL, - backend TEXT NOT NULL DEFAULT 'default', - v INTEGER NOT NULL, - PRIMARY KEY (k, backend) - )`, - }, - ], - [ - "_vfs_fetch_cursor", - { - type: "table", - sql: `CREATE TABLE _vfs_fetch_cursor ( - k TEXT NOT NULL CHECK(k = 'fetch'), - backend TEXT NOT NULL DEFAULT 'default', - path TEXT, - PRIMARY KEY (k, backend) - )`, - }, - ], - [ - "_vfs_mounts", - { - type: "table", - sql: `CREATE TABLE _vfs_mounts ( - root TEXT PRIMARY KEY, - kind TEXT NOT NULL, - indexed INTEGER NOT NULL DEFAULT 0, - mode TEXT NOT NULL DEFAULT 'read-only' - CHECK(mode IN ('read-only', 'read-write')) - )`, - }, - ], - [ - "workspace_roots", - { - type: "table", - sql: `CREATE TABLE workspace_roots ( - root_id TEXT PRIMARY KEY CHECK ( - length(root_id) = 64 AND root_id NOT GLOB '*[^0-9a-f]*' - ), - format_version INTEGER NOT NULL CHECK (format_version = 1), - manifest TEXT NOT NULL CHECK (json_valid(manifest)) -) STRICT`, - }, - ], - [ - "workspace_root_manifest_refs", - { - type: "table", - sql: `CREATE TABLE workspace_root_manifest_refs ( - root_id TEXT NOT NULL REFERENCES workspace_roots(root_id) ON DELETE CASCADE, - manifest_hash BLOB NOT NULL REFERENCES vfs_manifests(hash) ON DELETE RESTRICT, - PRIMARY KEY (root_id, manifest_hash) -) STRICT, WITHOUT ROWID`, - }, - ], - [ - "workspace_root_blob_refs", - { - type: "table", - sql: `CREATE TABLE workspace_root_blob_refs ( - root_id TEXT NOT NULL REFERENCES workspace_roots(root_id) ON DELETE CASCADE, - blob_hash BLOB NOT NULL, - PRIMARY KEY (root_id, blob_hash), - FOREIGN KEY (blob_hash) REFERENCES vfs_blobs(hash) ON DELETE RESTRICT, - FOREIGN KEY (blob_hash) REFERENCES vfs_blob_bytes(hash) ON DELETE RESTRICT -) STRICT, WITHOUT ROWID`, - }, - ], - [ - "agent_sessions", - { - type: "table", - sql: `CREATE TABLE agent_sessions ( - session_key TEXT PRIMARY KEY, - provider TEXT NOT NULL, - agent_command TEXT NOT NULL, - session_identity TEXT NOT NULL, - policy TEXT NOT NULL, - assertion_kind TEXT NOT NULL, - assertion_value TEXT NOT NULL, - created_at TEXT NOT NULL -) STRICT`, - }, - ], - [ - "workspace_state", - { - type: "table", - sql: `CREATE TABLE workspace_state ( - singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), - current_root_id TEXT NOT NULL REFERENCES workspace_roots(root_id) ON DELETE RESTRICT -) STRICT`, - }, - ], - [ - "journal_events", - { - type: "table", - sql: `CREATE TABLE journal_events ( - sequence INTEGER PRIMARY KEY AUTOINCREMENT, - event_id TEXT NOT NULL UNIQUE, - record TEXT NOT NULL CHECK (json_valid(record)), - workspace_root_id TEXT NOT NULL REFERENCES workspace_roots(root_id) ON DELETE RESTRICT -) STRICT`, - }, - ], - [ - "workflow_run", - { - type: "table", - sql: `CREATE TABLE workflow_run ( - id INTEGER PRIMARY KEY CHECK (id = 1), - run_id TEXT NOT NULL, - definition TEXT NOT NULL CHECK (json_valid(definition)), - base TEXT NOT NULL, - props TEXT NOT NULL CHECK (json_valid(props) AND json_type(props) = 'object'), - status TEXT NOT NULL CHECK (status IN (${STATUSES})), - stop_reason_kind TEXT CHECK (stop_reason_kind IS NULL OR stop_reason_kind IN ('host', 'journal')), - stop_reason_code TEXT, - stop_reason_event_id TEXT REFERENCES journal_events (event_id), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - ${coherentStopReason()} -) STRICT`, - }, - ], - [ - "definition_retrieval", - { - type: "table", - sql: `CREATE TABLE definition_retrieval ( - id INTEGER PRIMARY KEY CHECK (id = 1), - metadata TEXT NOT NULL CHECK (json_valid(metadata)), - revision INTEGER NOT NULL CHECK (revision >= 1 AND revision <= 9007199254740991), - updated_at TEXT NOT NULL -) STRICT`, - }, - ], - [ - "document_executions", - { - type: "table", - sql: `CREATE TABLE document_executions ( - sequence INTEGER PRIMARY KEY AUTOINCREMENT, - execution_id TEXT NOT NULL UNIQUE, - started_at TEXT NOT NULL, - stopped_at TEXT, - stop_status TEXT CHECK (stop_status IS NULL OR stop_status IN (${STATUSES})), - stop_reason_kind TEXT CHECK (stop_reason_kind IS NULL OR stop_reason_kind IN ('host', 'journal')), - stop_reason_code TEXT, - stop_reason_event_id TEXT REFERENCES journal_events (event_id), - CHECK ((stopped_at IS NULL) = (stop_status IS NULL)), - CHECK (stop_status IS NOT NULL OR stop_reason_kind IS NULL), - ${coherentStopReason()} -) STRICT`, - }, - ], - [ - "workspace_repositories", - { - type: "table", - sql: `CREATE TABLE workspace_repositories ( - name TEXT PRIMARY KEY CHECK (length(name) > 0), - locator TEXT NOT NULL CHECK (length(locator) > 0), - locator_fingerprint TEXT NOT NULL CHECK ( - length(locator_fingerprint) = 64 AND locator_fingerprint NOT GLOB '*[^0-9a-f]*' - ), - requested_base TEXT CHECK (requested_base IS NULL OR length(requested_base) > 0), - creation_commit TEXT NOT NULL CHECK (length(creation_commit) > 0), - primary_branch TEXT NOT NULL CHECK (length(primary_branch) > 0), - object_format TEXT NOT NULL CHECK (object_format IN ('sha1', 'sha256')), - checkout_path TEXT NOT NULL UNIQUE CHECK ( - length(checkout_path) > 0 AND substr(checkout_path, 1, 1) = '/' - ) -) STRICT`, - }, - ], - [ - "workspace_worktrees", - { - type: "table", - sql: `CREATE TABLE workspace_worktrees ( - repository_name TEXT NOT NULL REFERENCES workspace_repositories(name) ON DELETE RESTRICT, - name TEXT NOT NULL CHECK (length(name) > 0), - requested_branch TEXT NOT NULL CHECK (length(requested_branch) > 0), - requested_base TEXT CHECK (requested_base IS NULL OR length(requested_base) > 0), - creation_commit TEXT NOT NULL CHECK (length(creation_commit) > 0), - checkout_path TEXT NOT NULL UNIQUE CHECK ( - length(checkout_path) > 0 AND substr(checkout_path, 1, 1) = '/' - ), - PRIMARY KEY (repository_name, name) -) STRICT, WITHOUT ROWID`, - }, - ], - [ - "workflow_suspension_answers", - { - type: "table", - sql: `CREATE TABLE workflow_suspension_answers ( - suspension_id TEXT PRIMARY KEY, - request_event_id TEXT NOT NULL REFERENCES journal_events(event_id) ON DELETE RESTRICT, - request_fingerprint TEXT NOT NULL CHECK ( - length(request_fingerprint) = 64 AND request_fingerprint NOT GLOB '*[^0-9a-f]*' - ), - answer TEXT NOT NULL CHECK (json_valid(answer)), - state TEXT NOT NULL CHECK (state IN ('pending', 'consumed')), - created_at TEXT NOT NULL, - consumed_at TEXT, - CHECK ((state = 'consumed') = (consumed_at IS NOT NULL)) -) STRICT`, - }, - ], - [ - "workflow_fork_lineage", - { - type: "table", - sql: `CREATE TABLE workflow_fork_lineage ( - id INTEGER PRIMARY KEY CHECK (id = 1), - source_run_id TEXT NOT NULL CHECK (length(source_run_id) > 0), - checkpoint_event_id TEXT NOT NULL CHECK (length(checkpoint_event_id) > 0), - checkpoint_workspace_root_id TEXT NOT NULL - REFERENCES workspace_roots(root_id) ON DELETE RESTRICT, - created_at TEXT NOT NULL -) STRICT`, - }, - ], - [ - "journal_event_provenance", - { - type: "table", - sql: `CREATE TABLE journal_event_provenance ( - event_id TEXT PRIMARY KEY REFERENCES journal_events(event_id) ON DELETE RESTRICT, - source_run_id TEXT NOT NULL CHECK (length(source_run_id) > 0), - source_event_id TEXT NOT NULL CHECK (length(source_event_id) > 0) -) STRICT, WITHOUT ROWID`, - }, - ], -]); - -export const EXPECTED_SCHEMA = Object.freeze( - [...OBJECTS.entries()].map(([name, object]) => - Object.freeze({ name, type: object.type, sql: normalize(object.sql) }), - ), -); - -/** Objects version 1 declares, including the pinned Cloudflare structure. */ -export const REQUIRED_OBJECTS: readonly string[] = Object.freeze([...OBJECTS.keys()]); - -/** Tables version 1 declares. */ -export const REQUIRED_TABLES: readonly string[] = Object.freeze( - [...OBJECTS.entries()].filter(([, object]) => object.type === "table").map(([name]) => name), -); - -/** Version 1 in full. */ -export const SCHEMA_SQL = [...OBJECTS.values()] - .filter((object) => object.type === "table" && !object.sql.startsWith("CREATE TABLE vfs_")) - .filter((object) => !object.sql.startsWith("CREATE TABLE _vfs_")) - .map((object) => `${object.sql};`) - .join("\n\n"); - /** * Write the version-1 schema into a database that holds nothing. * @@ -495,6 +53,15 @@ export const SCHEMA_SQL = [...OBJECTS.values()] * and the tables appear together or not at all — a half-initialized file would * be indistinguishable from one this build must refuse. */ +export { + APPLICATION_ID, + EXPECTED_SCHEMA, + REQUIRED_OBJECTS, + REQUIRED_TABLES, + SCHEMA_SQL, + SCHEMA_VERSION, +}; + export function initializeSchema( database: DatabaseSync, dofs: CloudflareDatabase, @@ -571,98 +138,33 @@ export function verifySchema(database: DatabaseSync, path: string, dofs: Cloudfl * has not learned yet. */ function verifyStructure(database: DatabaseSync, path: string): void { - const objects = schemaObjects(database, path); - if (isIncompletePreReleaseShape(objects)) { + const failure = declaredStructureFailure(schemaObjects(database, path)); + if (failure === undefined) { + return; + } + if (failure.kind === "incomplete-pre-release") { throw new WorkflowIncompleteVersionOneError(path); } - - for (const object of objects) { - const expected = OBJECTS.get(object.name); - if (expected === undefined) { - throw new WorkflowDatabaseCorruptError( - path, - `it declares an object that version ${SCHEMA_VERSION} does not`, - ); - } - if (object.type !== expected.type || normalize(object.sql) !== normalize(expected.sql)) { - throw new WorkflowDatabaseCorruptError( - path, - `its ${object.name} object is not shaped the way version ${SCHEMA_VERSION} declares it`, - ); - } + if (failure.kind === "undeclared-object") { + throw new WorkflowDatabaseCorruptError( + path, + `it declares an object that version ${SCHEMA_VERSION} does not`, + ); } - - const present = new Set(objects.map((object) => object.name)); - const missing = REQUIRED_OBJECTS.filter((name) => !present.has(name)); - if (missing.length > 0) { - throw new WorkflowDatabaseCorruptError(path, `it is missing the table ${missing.join(", ")}`); + if (failure.kind === "misshapen-object") { + throw new WorkflowDatabaseCorruptError( + path, + `its ${failure.name} object is not shaped the way version ${SCHEMA_VERSION} declares it`, + ); } + throw new WorkflowDatabaseCorruptError( + path, + `it is missing the table ${failure.names.join(", ")}`, + ); } function hasDeclaredVersionOneObjects(database: DatabaseSync, path: string): boolean { - return schemaObjects(database, path).some((object) => OBJECTS.has(object.name)); -} - -/** - * Every in-place amendment to version 1, newest first. - * - * Each entry names what that amendment added. Peeling them off in order is what - * reconstructs the shapes that once claimed to be a complete version 1, so a - * database an earlier build produced is refused as an incomplete pre-release - * rather than as arbitrary damage. - */ -const AMENDMENTS: readonly (readonly string[])[] = Object.freeze([ - Object.freeze(["workflow_fork_lineage", "journal_event_provenance"]), - Object.freeze(["workflow_suspension_answers"]), - Object.freeze(["workspace_repositories", "workspace_worktrees"]), -]); - -/** What the newest amendment added. Its presence marks a current-shape database. */ -const LATEST_AMENDMENT: readonly string[] = AMENDMENTS[0] ?? []; - -/** The very first pre-release shape, before Workspace root retention existed. */ -const EARLIEST_PRE_RELEASE_SHAPE: readonly string[] = [ - "definition_retrieval", - "document_executions", - "journal_events", - "workflow_run", -]; - -/** - * Every later shape that once claimed to be a complete version 1. - * - * Newest first: version 1 minus the newest amendment, then minus the one before - * it, and so on. - */ -const PRIOR_COMPLETE_SHAPES: readonly (readonly string[])[] = Object.freeze( - AMENDMENTS.map((_, index) => { - const removed = new Set(AMENDMENTS.slice(0, index + 1).flat()); - return Object.freeze(REQUIRED_OBJECTS.filter((name) => !removed.has(name))); - }), -); - -/** - * Whether these declarations describe an earlier shape that once claimed to be - * a complete version 1. - * - * The very first pre-release held only the run, journal and execution tables. - * Every shape after it is version 1 minus whichever amendments had not been - * made yet, and each is named here so the refusal reads as an incomplete - * pre-release rather than as corruption. - */ -function isIncompletePreReleaseShape(objects: readonly SchemaObject[]): boolean { - const present = new Set(objects.map((object) => object.name)); - if (LATEST_AMENDMENT.some((name) => present.has(name))) { - return false; - } - const earliest = new Set(EARLIEST_PRE_RELEASE_SHAPE); - if (present.size === earliest.size && [...present].every((name) => earliest.has(name))) { - return objects.every((object) => object.type === "table"); - } - return PRIOR_COMPLETE_SHAPES.some((shape) => { - const expected = new Set(shape); - return present.size === expected.size && [...present].every((name) => expected.has(name)); - }); + return hasAnyDeclaredObject(schemaObjects(database, path)); } /** @@ -693,12 +195,6 @@ function checkForeignKeys(database: DatabaseSync, path: string): void { } } -interface SchemaObject { - readonly type: string; - readonly name: string; - readonly sql: string; -} - /** * Everything somebody declared in this database. * @@ -725,11 +221,6 @@ function schemaObjects(database: DatabaseSync, path: string): SchemaObject[] { return objects; } -/** One statement's shape, independent of how it was laid out. */ -function normalize(sql: string): string { - return sql.replace(/\s+/g, " ").trim(); -} - function readPragmaNumber(database: DatabaseSync, pragma: string, path: string): number { const rows = query(database, `PRAGMA ${pragma}`, path); const value = rows[0]?.[pragma]; diff --git a/packages/workflow/src/deno/suspension.ts b/packages/workflow/src/deno/suspension.ts index a7b32c751..8b8cc5a69 100644 --- a/packages/workflow/src/deno/suspension.ts +++ b/packages/workflow/src/deno/suspension.ts @@ -41,32 +41,19 @@ * not. */ +import { call, type Operation, race, scoped, spawn, suspend, withResolvers } from "effection"; import { - call, - ensure, - type Operation, - race, - scoped, - spawn, - suspend, - withResolvers, -} from "effection"; -import { canonicalFingerprint } from "@executablemd/core"; -import type { EffectDescription } from "@executablemd/durable-streams"; -import { - parseSuspensionRequest, suspensionRequestFingerprint, WorkflowSuspension, type WorkflowSuspensionRequest, } from "../suspension/api.ts"; -import { durablePosition } from "@executablemd/durable-streams"; import type { Json } from "@executablemd/durable-streams"; -import { SUSPENSION_REQUEST, suspensionId } from "../suspension/suspend.ts"; import { type SuspensionAnswerAuthority, type SuspensionAnswerProvider, useSuspensionAnswerProvider, } from "../suspension/answer.ts"; +import { atOwnRequest } from "../suspension/position.ts"; import type { WorkflowRunDatabase } from "../storage/api.ts"; import { WorkflowRequestError, WorkflowTransactionError } from "../storage/errors.ts"; import { consumeRetainedAnswer, readRetainedAnswer } from "./answers.ts"; @@ -110,99 +97,6 @@ export interface SuspensionController { entered(error: unknown): boolean; } -/** - * Whether this execution is, right now, at the wait it says it is. - * - * Authority is the *current* execution reaching its own request, not the - * existence of a matching row. Retained history alone cannot decide this: on a - * resume the request from the previous execution is already in the journal, so - * a caller that ran before replay reached it could present its identifier and be - * believed. What separates the real wait from that is where the execution is. - * - * `suspendFor()` publishes its request and then enters, so by the time it gets - * here the coroutine has settled exactly one more durable yield than it had when - * the request was made — the request's own. The identifier is therefore the one - * this run derives for the position immediately behind this one, and a caller - * standing anywhere else derives a different identifier and is refused. - * - * The journal is then read to confirm that the yield at that exact position is - * this request, describing what is being presented. That is publication - * evidence, and it is checked at one position rather than searched for. - */ -function* atOwnRequest( - database: WorkflowRunDatabase, - suspension: string, - request: WorkflowSuspensionRequest, -): Operation { - const position = yield* durablePosition(); - if (position.index === 0) { - return NOT_AT_A_WAIT; - } - const published = { - coroutineId: position.coroutineId, - index: position.index - 1, - }; - if (suspensionId(database.record.runId, published) !== suspension) { - return NOT_AT_A_WAIT; - } - - const entries = yield* database.readJournalEntries(); - if (!entries.ok) { - return NOT_AT_A_WAIT; - } - - const counts = new Map(); - let found: EffectDescription | undefined; - for (const entry of entries.value) { - if (entry.event.type !== "yield") { - continue; - } - const coroutineId = entry.event.coroutineId; - const index = counts.get(coroutineId) ?? 0; - counts.set(coroutineId, index + 1); - if (coroutineId === published.coroutineId && index === published.index) { - found = entry.event.description; - } - } - if (found === undefined || found.type !== SUSPENSION_REQUEST || found.name !== suspension) { - return NOT_AT_A_WAIT; - } - // Parsed, not merely read. A retained description is journal data, and this - // one is reached through a public durable operation any document can publish, - // so what it holds is a claim about a request rather than a request. Comparing - // raw fields would let a row that could never have come from `suspendFor()` — - // a `responseSchema` that is an array, say — admit a wait whose schema nothing - // could later validate an answer against. - let retained: WorkflowSuspensionRequest; - try { - retained = parseSuspensionRequest({ - request: found.request, - responseSchema: found.responseSchema, - }); - } catch (error) { - return ( - "the request retained at this position is not one a durable wait can be entered " + - `for: ${error instanceof Error ? error.message : String(error)}` - ); - } - - const same = - canonicalFingerprint({ - request: request.request, - responseSchema: request.responseSchema, - }) === - canonicalFingerprint({ - request: retained.request, - responseSchema: retained.responseSchema, - }); - return same ? undefined : NOT_AT_A_WAIT; -} - -const NOT_AT_A_WAIT = - "this execution is not at that durable wait. A wait is entered by the execution that has " + - "just published its request, at the position that request was made — not by presenting an " + - "identifier a run retains somewhere else."; - export function createSuspensionController( options: SuspensionControllerOptions, ): SuspensionController { diff --git a/packages/workflow/src/deno/transaction.ts b/packages/workflow/src/deno/transaction.ts index 1d979f8a6..f46b9db55 100644 --- a/packages/workflow/src/deno/transaction.ts +++ b/packages/workflow/src/deno/transaction.ts @@ -15,9 +15,7 @@ * database, so nothing above this boundary can reach SQLite through it. */ -import { type Api, createApi } from "@effectionx/context-api"; import { type Context, createContext, type Operation } from "effection"; -import { WorkflowTransactionError } from "../storage/errors.ts"; import type { RunTransaction } from "./connections.ts"; import type { SavepointManager } from "./savepoints.ts"; @@ -63,41 +61,17 @@ export function* holdsTransactionOn(path: string): Operation { return false; } -export interface TransactionApi { - /** - * Run `body` inside a savepoint, discarding its work if it fails. - * - * Answers with what the body answered. A failure rolls the savepoint back - * and propagates, leaving the surrounding transaction open and free to - * continue or to fail on its own terms. - */ - savepoint(body: Operation): Operation; -} - -/** No transaction is open in this scope, so there is nothing to nest inside. */ -export class NoOpenTransactionError extends WorkflowTransactionError { - override name = "NoOpenTransactionError"; - - constructor() { - super( - "a savepoint needs a transaction to be inside, and this scope is not inside one. " + - "Take savepoints within the body a transaction hands you.", - ); - } -} - -export const Transaction: Api = createApi( - "executablemd.workflow.deno.savepoint", - { - // deno-lint-ignore require-yield - *savepoint(_body: Operation): Operation { - throw new NoOpenTransactionError(); - }, - }, -); +import { Transaction } from "../workspace/undoable.ts"; -/** The savepoint operation, for whoever is inside a transaction. */ -export const savepoint: TransactionApi["savepoint"] = Transaction.operations.savepoint; +// The shared contract calls it an undo, because a savepoint is this host's own +// answer to it rather than the question. Inside this adapter it is a savepoint, +// which is what it is here. +export { + NoOpenTransactionError, + Transaction, + type TransactionApi, + undoable as savepoint, +} from "../workspace/undoable.ts"; /** What the open transaction installs so `savepoint()` can answer. */ export function useTransactionSavepoints( @@ -106,7 +80,7 @@ export function useTransactionSavepoints( ): Operation { return Transaction.around( { - *savepoint([body]: [Operation]): Operation { + *undoable([body]: [Operation]): Operation { return yield* savepoints.operation(transaction, body); }, }, diff --git a/packages/workflow/src/deno/transitions.ts b/packages/workflow/src/deno/transitions.ts index 1c365834d..6d093ea0f 100644 --- a/packages/workflow/src/deno/transitions.ts +++ b/packages/workflow/src/deno/transitions.ts @@ -36,6 +36,15 @@ import type { } from "../lifecycle/execution.ts"; import type { WorkflowRunDatabase } from "../storage/api.ts"; import { conflictingFields } from "../storage/compatibility.ts"; +import { + admissionRefusal, + type Closing, + closingOutcome, + damagedTerminalRefusal, + INTERRUPTED, + rootOutcome, + terminal, +} from "../lifecycle/policy.ts"; import { definitionToJson } from "../storage/definition.ts"; import { WorkflowDocumentExecutionError, @@ -60,7 +69,7 @@ import { reading } from "./reading.ts"; import { readJournalEntries } from "./journal.ts"; import type { ForkSourceSnapshot } from "./fork-source.ts"; import { readForkLineage, writeForkInheritance, type ForkHeadEvents } from "./fork-write.ts"; -import { readDocumentExecution, readRetrieval, stopReasonColumns } from "./rows.ts"; +import { readDocumentExecution, readRetrieval, stopReasonColumns } from "../sqlite/rows.ts"; import { initializeSchema, isSqliteForeignKeyConstraint, @@ -182,6 +191,14 @@ interface Recovery { /** Absent when there is no run yet, which only a `start` may go on from. */ readonly status?: WorkflowRunStatus; readonly closed?: DocumentExecutionRecord; + /** + * Whether this run's own terminal is one this build cannot read. + * + * Carried out of recovery rather than collapsed into the stored status: an + * unreadable terminal is not a run to go on with, and a caller that saw only + * `running` would begin another execution over it. + */ + readonly damaged?: boolean; } interface Refused { @@ -240,6 +257,10 @@ function beginOnce( const recovery = recover(connection, path, hold, request); + if (recovery.damaged === true) { + return { kind: "refused", reason: damagedTerminalRefusal() }; + } + // A file can exist and hold nothing — created by an interrupted attempt, or // left empty by something else. Existence is not a run, so a resume that // reaches one refuses here rather than letting the creation it happens to @@ -493,8 +514,15 @@ export function* settleExecution( throw new WorkflowDocumentExecutionError(completion.executionId); } const { database } = connection; + const stored = readRunRow(database, path); finish(database, path, completion); - publish(database, path, completion.status, completion.reason); + // A replay closes only its own envelope: the terminal outcome it observed + // is not made mutable again, and nothing about the run — its status, its + // reason or when it last moved — is rewritten by an execution that was + // only ever going to restore what was already there. + if (!terminal(stored.status)) { + publish(database, path, completion.status, completion.reason); + } const record = readRunRow(database, path); if (record.runId !== hold.runId) { throw new WorkflowRunIdMismatchError(hold.runId, path); @@ -604,39 +632,6 @@ function firstExecution( return { execution: insertExecution(database, path), replay: false }; } -/** - * Why this action may not continue from this status, or nothing when it may. - * - * Answered rather than raised, and asked outside the transaction that recovered - * the run: refusing is this caller's outcome, not a reason to undo what the - * previous workflow executor's execution was found to have become. - */ -function admissionRefusal( - action: "start" | "resume", - status: WorkflowRunStatus | undefined, -): Error | undefined { - if (status === undefined) { - return undefined; - } - if (action === "resume" && (status === "failed" || status === "cancelled")) { - return new WorkflowRequestError( - `workflow run ${status}: a run that ${ - status === "failed" ? "failed" : "was cancelled" - } is not resumed. The run is left exactly as it is.`, - ); - } - if (status === "cancelled") { - return new WorkflowRequestError( - "workflow run cancelled: a cancelled run reports its retained state and is not advanced.", - ); - } - return undefined; -} - -function terminal(status: WorkflowRunStatus): boolean { - return status === "completed" || status === "failed"; -} - interface Reconciled { readonly status: WorkflowRunStatus; readonly of: { recovered?: DocumentExecutionRecord }; @@ -651,13 +646,19 @@ interface Reconciled { * addressed to; failing both, the execution was interrupted. */ function reconcile(database: DatabaseSync, path: string, stored: WorkflowRunRecord): Recovery { + const closing = closingOutcome(stored.status, rootOutcome(readJournalEntries(database))); + if (closing.damaged) { + // Nothing is decided here and nothing is written: whatever the previous + // executor left stays exactly as it left it, because this build cannot say + // what the document it ran did. + return { status: stored.status, damaged: true }; + } + const unfinished = reading(database, SELECT_UNFINISHED).all().map(readDocumentExecution); if (unfinished.length === 0) { return { status: stored.status }; } - const closing = closingOutcome(database, stored); - let last: DocumentExecutionRecord | undefined; for (const execution of unfinished) { finish(database, path, { @@ -675,62 +676,6 @@ function reconcile(database: DatabaseSync, path: string, stored: WorkflowRunReco return { status: closing.status, ...closed }; } -interface Closing { - readonly status: WorkflowRunStatus; - readonly reason: DocumentExecutionCompletion["reason"]; - readonly publishes: boolean; -} - -/** - * What the previous workflow executor's execution became, on the evidence the run holds. - * - * A retained root Close proves the canonical outcome won before anything could - * interrupt it, so it is restored. Failing that, the execution was interrupted: - * the workflow executor went away without recording an outcome, and that is what happened. - */ -function closingOutcome(database: DatabaseSync, stored: WorkflowRunRecord): Closing { - // A replay whose terminal state was preserved closes only its own execution, - // and the authoritative outcome stays exactly as it was. - if (terminal(stored.status)) { - return { status: "interrupted", reason: interrupted, publishes: false }; - } - - const canonical = rootOutcome(database); - if (canonical !== undefined) { - return { status: canonical.status, reason: canonical.reason, publishes: true }; - } - - return { status: "interrupted", reason: interrupted, publishes: true }; -} - -const interrupted = { kind: "host", code: "executor-interrupted" } as const; - -/** - * The canonical outcome the root recorded, when it recorded one. - * - * A root Close is what proves the document itself finished. Its result decides - * the run's terminal status, and its own event identity is the reason — the - * journal already filtered it, so nothing new is retained to say why. - */ -function rootOutcome( - database: DatabaseSync, -): { status: WorkflowRunStatus; reason: DocumentExecutionCompletion["reason"] } | undefined { - for (const entry of readJournalEntries(database)) { - const { event } = entry; - if (event.type !== "close" || event.coroutineId !== "root") { - continue; - } - if (event.result.status === "ok") { - return { status: "completed", reason: undefined }; - } - return { - status: event.result.status === "cancelled" ? "cancelled" : "failed", - reason: { kind: "journal", eventId: entry.eventId }, - }; - } - return undefined; -} - function insertExecution(database: DatabaseSync, path: string): DocumentExecutionRecord { const executionId = randomUUID(); database.prepare(INSERT_EXECUTION).run(executionId, new Date().toISOString()); @@ -916,7 +861,13 @@ export function* cancelRun( ); } - const canonical = rootOutcome(database); + const canonical = rootOutcome(readJournalEntries(database)); + if (canonical?.kind === "damaged") { + // The document finished and this build cannot read what it finished + // as. Cancelling it would replace a result rather than end a run that + // had none, so nothing here changes anything. + return { kind: "refused" as const, reason: damagedTerminalRefusal() }; + } if (canonical !== undefined) { // The document finished before its workflow executor disappeared. Restoring what it // recorded is not cancelling it. diff --git a/packages/workflow/src/deno/workspace/agent-sessions.ts b/packages/workflow/src/deno/workspace/agent-sessions.ts index ff55877b3..e3a418153 100644 --- a/packages/workflow/src/deno/workspace/agent-sessions.ts +++ b/packages/workflow/src/deno/workspace/agent-sessions.ts @@ -38,62 +38,33 @@ */ import type { DatabaseSync } from "node:sqlite"; -import { createHash } from "node:crypto"; - -/** A retained Agent session this host will not continue under. */ -export class WorkflowAgentSessionError extends Error { - override name = "WorkflowAgentSessionError"; -} - -/** - * One durable identity a provider asserted, and what kind of thing it is. - * - * Tagged, because "the adapter's own session id" and "an ACP session id" and "a - * record id in some store" are different claims that happen to be strings. A - * host comparing them without the tag would accept one for another. - */ -export interface ProviderAssertion { - readonly kind: string; - readonly value: string; -} - -/** What identifies one logical Agent session. */ -export interface AgentSessionIdentity { - /** Which provider holds the conversation, as that provider names itself. */ - readonly provider: string; - /** The resolved agent command, not the name a document wrote. */ - readonly agentCommand: string; - /** The engine-derived Agent/Session expansion identity. Never authored. */ - readonly sessionIdentity: string; -} - -/** One retained mapping, as the run's database holds it. */ -export interface AgentSessionRecord extends AgentSessionIdentity { - readonly sessionKey: string; - /** The session policy in force when the provider created this session. */ - readonly policy: string; - readonly assertion: ProviderAssertion; - readonly createdAt: string; -} - -function digest(value: string): string { - return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 32); -} /** - * The key one logical session is retained under, within this run. + * The shape and the key derivation are the shared rule, not this adapter's. * - * The engine-derived Session expansion identity and nothing else. The provider - * and the resolved agent command are compatibility attributes stored beside it: - * changing either refuses reattachment rather than addressing a second mapping, - * because a `` element that changed agent is the same element asking - * for something this run cannot give it. - * - * Digested so it stays bounded, and namespaced so a row is recognizable. + * Both hosts retain these mappings, and two derivations would be two keys for + * one session — reattachment would quietly start a new conversation instead of + * finding the old one. What stays here is the storage: the columns, the + * statements, and the transaction they run in. */ -export function agentSessionKey(identity: AgentSessionIdentity): string { - return ["xmd", "workflow", "v1", digest(identity.sessionIdentity)].join(":"); -} +import { + type AgentSessionRecord, + type AgentSessions, + WorkflowAgentSessionError, +} from "../../storage/agent-session.ts"; + +export { + agentSessionKey, + parseAgentSessionRecord, + resolveAgentSession, + WorkflowAgentSessionError, +} from "../../storage/agent-session.ts"; +export type { AgentSessionResolution, AgentSessions } from "../../storage/agent-session.ts"; +export type { + AgentSessionIdentity, + AgentSessionRecord, + ProviderAssertion, +} from "../../storage/agent-session.ts"; const COLUMNS = `session_key, provider, agent_command, session_identity, policy, assertion_kind, assertion_value, created_at`; @@ -106,12 +77,6 @@ const INSERT = `INSERT INTO agent_sessions (session_key, provider, agent_command session_identity, policy, assertion_kind, assertion_value, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`; -/** Every retained mapping this run holds. Reading is not a transaction. */ -export interface AgentSessions { - read(sessionKey: string): AgentSessionRecord | undefined; - commit(record: AgentSessionRecord): void; -} - function text(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } @@ -191,82 +156,3 @@ export function createAgentSessions(database: DatabaseSync, authorize: () => voi }, }; } - -/** What a continuation may do with the session a key names. */ -export type AgentSessionResolution = - | { readonly kind: "create"; readonly sessionKey: string } - | { readonly kind: "reattach"; readonly record: AgentSessionRecord }; - -/** - * Decide what this attachment may do with the session this identity names. - * - * `asserted` is every canonical identity the provider currently asserts for that - * key — none, one, or more than one. It is deliberately not "does the provider - * hold this key": occupancy says something is there, not what conversation it - * is, and adopting one on that basis is how a run continues a session it cannot - * name. - */ -export function resolveAgentSession( - retained: AgentSessionRecord | undefined, - policy: string, - asserted: readonly ProviderAssertion[], - identity: AgentSessionIdentity, -): AgentSessionResolution { - const sessionKey = agentSessionKey(identity); - if (asserted.length > 1) { - throw new WorkflowAgentSessionError( - "the provider asserts more than one durable identity for this run's Agent session, so " + - "this host cannot tell which conversation it would be continuing. Start a new run " + - "rather than continuing this one.", - ); - } - const current = asserted[0]; - - if (retained === undefined) { - if (current === undefined) { - // Neither side holds anything: nothing was ever established here. - return { kind: "create", sessionKey }; - } - // The pre-commit window. An attempt was interrupted between the provider - // asserting an identity and this run recording it, and exactly one - // canonical assertion is what reconciles it — nothing else may. - return { - kind: "reattach", - record: { - sessionKey, - ...identity, - policy, - assertion: current, - createdAt: new Date().toISOString(), - }, - }; - } - - if ( - retained.provider !== identity.provider || - retained.agentCommand !== identity.agentCommand || - retained.sessionIdentity !== identity.sessionIdentity || - retained.policy !== policy - ) { - throw new WorkflowAgentSessionError( - "this run's Agent session was established under a different provider, agent or session " + - "policy than this host states, and a session created under one ceiling is not " + - "continued under another. Start a new run rather than continuing this one.", - ); - } - if (current === undefined) { - throw new WorkflowAgentSessionError( - "the provider asserts no durable identity for the Agent session this run retained, and " + - "this host does not reconstruct a conversation by replaying it into a new session. " + - "Start a new run rather than continuing this one.", - ); - } - if (current.kind !== retained.assertion.kind || current.value !== retained.assertion.value) { - throw new WorkflowAgentSessionError( - "the provider asserts a different durable identity than the Agent session this run " + - "retained, so it did not resume the conversation this run was having. This host does " + - "not continue under a replacement session.", - ); - } - return { kind: "reattach", record: retained }; -} diff --git a/packages/workflow/src/deno/workspace/effect.ts b/packages/workflow/src/deno/workspace/effect.ts index c0e58086c..3f903d2c9 100644 --- a/packages/workflow/src/deno/workspace/effect.ts +++ b/packages/workflow/src/deno/workspace/effect.ts @@ -6,7 +6,7 @@ import { type Result as DurableResult, serializeError, } from "@executablemd/durable-streams"; -import { ensure, type Operation, scoped } from "effection"; +import { ensure, type Operation, scoped, type Result } from "effection"; import type { WorkflowRunDatabase, WorkflowRunTransaction } from "../../storage/api.ts"; import { WorkflowTransactionError } from "../../storage/errors.ts"; import { @@ -22,8 +22,16 @@ import { savepoint } from "../transaction.ts"; import { isJournaledEffectFailure } from "./errors.ts"; import type { DenoWorkspaceFilesystem } from "./filesystem.ts"; import type { WorkspaceMetadata } from "./repositories.ts"; +import type { + WorkspaceAttachmentView, + WorkspaceHostBinding, + WorkspaceMutation, +} from "../../workspace/effects.ts"; +import type { AgentSessions } from "../../storage/agent-session.ts"; import { type PrivateWorkspaceTransaction, + transactAgentSessions as transactDenoAgentSessions, + transactWorkspaceRoots, withPrivateWorkspaceTransaction, workflowRunTransactionToken, } from "./private.ts"; @@ -270,3 +278,36 @@ export function createWorkspaceEffect( workspaceEffectOwners.claim(executionIdentity, database); return createOwnedDurableWorkspaceOperation(description, execute, executionIdentity); } + +/** + * What this host answers for one of its own handles. + * + * The three things the shared document rules ask a host for, over the lease + * this handle already validated. Built for the handle rather than for a scope, + * because the lease is the authority: a handle another provider opened is not + * one this can answer for, and validating it is what says so. + */ +export function denoWorkspaceHost(database: WorkflowRunDatabase): WorkspaceHostBinding { + return { + create( + description: EffectDescription, + mutate: WorkspaceMutation, + ): DurableEffect { + return createWorkspaceEffect(database, description, mutate); + }, + + read( + body: (view: WorkspaceAttachmentView) => Operation, + ): Operation> { + // The same transaction this host has always used for an ephemeral + // attachment, handing the body only the two members it may see. + return transactWorkspaceRoots(database, (workspace) => + body({ filesystem: workspace.filesystem, metadata: workspace.metadata }), + ); + }, + + sessions(body: (sessions: AgentSessions) => Operation): Operation> { + return transactDenoAgentSessions(database, body); + }, + }; +} diff --git a/packages/workflow/src/deno/workspace/errors.ts b/packages/workflow/src/deno/workspace/errors.ts index 571fd1fb0..7b65f09cb 100644 --- a/packages/workflow/src/deno/workspace/errors.ts +++ b/packages/workflow/src/deno/workspace/errors.ts @@ -13,31 +13,9 @@ const JOURNALABLE_CODES = new Set([ "ELOOP", ]); -/** - * A failure this effect publishes as its own durable outcome instead of raising. - * - * The distinction the effect layer needs is not "what went wrong" but "who - * this belongs to". A failure of this kind is part of what the effect *did*: it - * is written into the journal as the effect's result, the Workspace root stays - * where it was, and a replay reproduces it without performing anything. Every - * other failure is the run failing, and travels as an ordinary raise. - * - * It is a base class rather than a predicate over shapes so that being publishable - * is something a failure declares by construction. A module that wants its own - * refusal published extends this; nothing acquires the property by resembling - * something. - */ -export abstract class JournaledEffectFailure extends Error {} +export { isJournaledEffectFailure, JournaledEffectFailure } from "../../workspace/failure.ts"; -/** - * Whether this failure is the effect's outcome rather than the run's failure. - * - * Asked by the one place that has to choose between writing a result and - * letting a failure through. - */ -export function isJournaledEffectFailure(error: unknown): error is Error { - return error instanceof JournaledEffectFailure; -} +import { JournaledEffectFailure } from "../../workspace/failure.ts"; class JournalableWorkspaceFailure extends JournaledEffectFailure { override name = "WorkspaceFsError"; diff --git a/packages/workflow/src/deno/workspace/files.ts b/packages/workflow/src/deno/workspace/files.ts index 6f2a72b30..5ba455905 100644 --- a/packages/workflow/src/deno/workspace/files.ts +++ b/packages/workflow/src/deno/workspace/files.ts @@ -73,8 +73,9 @@ import type { } from "@executablemd/runtime"; import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; +import { workspaceHostFor } from "../../workspace/effects.ts"; +import type { WorkspaceFilesystem } from "../../workspace/filesystem.ts"; import { savepoint } from "../transaction.ts"; -import { createWorkspaceEffect } from "./effect.ts"; import { journalableWorkspaceCode } from "./errors.ts"; import type { DenoWorkspaceFilesystem, DenoWorkspaceStat } from "./filesystem.ts"; import { @@ -277,9 +278,12 @@ function* describeFileEffect( function* fileEffect( database: WorkflowRunDatabase, description: EffectDescription, - perform: (filesystem: DenoWorkspaceFilesystem) => Operation>, + perform: (filesystem: WorkspaceFilesystem) => Operation>, ): Workflow { - return yield createWorkspaceEffect(database, description, (filesystem) => perform(filesystem)); + // The binding the host attached for this exact handle. Which host answers is + // decided by the handle; that the handle is this run's was proved when the + // attachment registered it. + return yield workspaceHostFor(database).create(description, (filesystem) => perform(filesystem)); } /** diff --git a/packages/workflow/src/deno/workspace/filesystem.ts b/packages/workflow/src/deno/workspace/filesystem.ts index 1ae4087a1..357adaf82 100644 --- a/packages/workflow/src/deno/workspace/filesystem.ts +++ b/packages/workflow/src/deno/workspace/filesystem.ts @@ -1,4 +1,9 @@ import { type Operation } from "effection"; +import type { + WorkspaceEntry, + WorkspaceFilesystem, + WorkspaceStat, +} from "../../workspace/filesystem.ts"; import { chmod as chmodPath } from "../../../vendor/cloudflare-computer-dofs/generated/fs/chmod.js"; import { link as linkFile } from "../../../vendor/cloudflare-computer-dofs/generated/fs/link.js"; import { mkdir as mkdirPath } from "../../../vendor/cloudflare-computer-dofs/generated/fs/mkdir.js"; @@ -17,33 +22,15 @@ import { writeFileSync } from "../../../vendor/cloudflare-computer-dofs/generate import type { RunConnection } from "../connections.ts"; import { throwWorkspaceFilesystemFailure } from "./errors.ts"; -export interface DenoWorkspaceEntry { - readonly name: string; - readonly kind: "file" | "directory" | "symlink"; -} - -export interface DenoWorkspaceStat { - readonly kind: "file" | "directory" | "symlink"; - readonly mode: number; - readonly mtime: number; - readonly size: number; -} - -export interface DenoWorkspaceFilesystem { - readFile(path: string): Operation; - readTextFile(path: string): Operation; - stat(path: string): Operation; - lstat(path: string): Operation; - readlink(path: string): Operation; - readdir(path: string): Operation; - writeFile(path: string, content: string | Uint8Array, mode?: number): Operation; - mkdir(path: string, options?: { recursive?: boolean; mode?: number }): Operation; - remove(path: string, options?: { recursive?: boolean; force?: boolean }): Operation; - rename(from: string, to: string): Operation; - chmod(path: string, mode: number): Operation; - symlink(target: string, path: string): Operation; - link(existingPath: string, newPath: string): Operation; -} +/** + * The names this host has always used, for the one shared contract. + * + * The interface moved rather than changed: this adapter is one implementation + * of it, and the runner's attempt-backed adapter is the other. + */ +export type DenoWorkspaceEntry = WorkspaceEntry; +export type DenoWorkspaceStat = WorkspaceStat; +export type DenoWorkspaceFilesystem = WorkspaceFilesystem; export function createDenoWorkspaceFilesystem( connection: RunConnection, diff --git a/packages/workflow/src/deno/workspace/host.ts b/packages/workflow/src/deno/workspace/host.ts index cba1bf0fd..05a866abb 100644 --- a/packages/workflow/src/deno/workspace/host.ts +++ b/packages/workflow/src/deno/workspace/host.ts @@ -143,45 +143,65 @@ export interface WorkflowAgentAttachment { export type WorkflowAgentInstaller = (attachment: WorkflowAgentAttachment) => Operation; +/** + * Run `operation` with the document's own capabilities installed for this run. + * + * Everything an authored document reaches, and nothing about where the run's + * storage lives. The caller has already bound this run's Workspace effects — + * locally to a validated lease, on a runner to the exact remote run its own + * acquisition opened — and every rule below builds its effects through that one + * binding. Which means both hosts get the same ``, ``, + * ``, `` and Git behavior, with authority staying in whichever + * binding was attached. + * + * The set is inseparable for the reason the module note above gives: the Files + * provider alone would resolve a document's paths against whatever working + * directory the surrounding host adapter answers with. + */ +export function withDocumentCapabilities( + database: WorkflowRunDatabase, + operation: Operation, + options: WorkflowWorkspaceOptions = {}, +): Operation { + return scoped(function* () { + yield* useLogicalWorkspaceCwd(); + yield* useWorkflowFiles(database); + const composition = { + ...options.composition, + ...(options.helper === undefined ? {} : { helper: options.helper }), + }; + yield* useRepositoryComposition(database, composition); + yield* useGitComposition(database, composition); + if (options.gitHubIssues !== undefined) { + yield* useGitHubIssues(options.gitHubIssues); + } + yield* useCompositionComponents(); + // Ordinary middleware, installed the way the Issue adapter is: it owns + // the URLs it recognizes and delegates the rest. + // Installed on every live or partial attachment, configured or not: the + // configuration governs URL reads, and `` must keep working + // on a host that authorizes none. + yield* useGitHubPullRequests( + database, + composition.host ?? denoRepositoryHost(), + options.gitHubPullRequests ?? {}, + ); + // After the composition components and inside this attachment: a + // completed replay never reaches here, so it registers no second `Elicit` + // and installs no provider for work that is not going to happen. + yield* useWorkflowElicitation(); + if (options.agent !== undefined) { + yield* options.agent({ runId: database.record.runId, database }); + } + return yield* operation; + }); +} + /** Run `operation` with this run's Workspace attached to the document. */ export function withWorkflowWorkspace( database: WorkflowRunDatabase, operation: Operation, options: WorkflowWorkspaceOptions = {}, ): Operation { - return withWorkspaceEffects( - database, - scoped(function* () { - yield* useLogicalWorkspaceCwd(); - yield* useWorkflowFiles(database); - const composition = { - ...options.composition, - ...(options.helper === undefined ? {} : { helper: options.helper }), - }; - yield* useRepositoryComposition(database, composition); - yield* useGitComposition(database, composition); - if (options.gitHubIssues !== undefined) { - yield* useGitHubIssues(options.gitHubIssues); - } - yield* useCompositionComponents(); - // Ordinary middleware, installed the way the Issue adapter is: it owns - // the URLs it recognizes and delegates the rest. - // Installed on every live or partial attachment, configured or not: the - // configuration governs URL reads, and `` must keep working - // on a host that authorizes none. - yield* useGitHubPullRequests( - database, - composition.host ?? denoRepositoryHost(), - options.gitHubPullRequests ?? {}, - ); - // After the composition components and inside this attachment: a - // completed replay never reaches here, so it registers no second `Elicit` - // and installs no provider for work that is not going to happen. - yield* useWorkflowElicitation(); - if (options.agent !== undefined) { - yield* options.agent({ runId: database.record.runId, database }); - } - return yield* operation; - }), - ); + return withWorkspaceEffects(database, withDocumentCapabilities(database, operation, options)); } diff --git a/packages/workflow/src/deno/workspace/manifest.ts b/packages/workflow/src/deno/workspace/manifest.ts index 47c0b3893..d729f6f17 100644 --- a/packages/workflow/src/deno/workspace/manifest.ts +++ b/packages/workflow/src/deno/workspace/manifest.ts @@ -1,58 +1,45 @@ import { createHash } from "node:crypto"; -import { z } from "zod"; import { WorkflowDatabaseCorruptError } from "../../storage/errors.ts"; +import { + hasUnpairedSurrogate, + parseWorkspaceRootManifest, + SHA256, + validateCanonicalWorkspacePath, + validateWorkspaceRootEntries, + EMPTY_WORKSPACE_MANIFEST, + WORKSPACE_ROOT_DOMAIN, + WORKSPACE_ROOT_FORMAT, + type WorkspaceRejection, + type WorkspaceRootEntry, + type WorkspaceRootManifest, +} from "../../workspace/root-manifest.ts"; + +export { + compareUtf8, + hasUnpairedSurrogate, + parentFirst, + parentPath, + EMPTY_WORKSPACE_MANIFEST, + WORKSPACE_ROOT_DOMAIN, + WORKSPACE_ROOT_FORMAT, +} from "../../workspace/root-manifest.ts"; +export type { + WorkspaceRejection, + WorkspaceRootEntry, + WorkspaceRootManifest, +} from "../../workspace/root-manifest.ts"; -export const WORKSPACE_ROOT_FORMAT = 1; -export const WORKSPACE_ROOT_DOMAIN = "xmd-workspace-root\0v1\0"; - -const SHA256 = /^[0-9a-f]{64}$/; -const encoder = new TextEncoder(); - -const directoryEntrySchema = z - .object({ - path: z.string(), - kind: z.literal("directory"), - mode: z.number().int().min(0).max(0o7777), - mtime: z.number().int().safe(), - }) - .strict(); - -const fileEntrySchema = z - .object({ - path: z.string(), - kind: z.literal("file"), - mode: z.number().int().min(0).max(0o7777), - mtime: z.number().int().safe(), - size: z.number().int().safe().nonnegative(), - manifest: z.string().regex(SHA256), - hardlink: z - .string() - .regex(/^h[0-9]+$/) - .nullable(), - }) - .strict(); - -const symlinkEntrySchema = z - .object({ - path: z.string(), - kind: z.literal("symlink"), - mode: z.number().int().min(0).max(0o7777), - mtime: z.number().int().safe(), - target: z.string(), - }) - .strict(); - -const rootManifestSchema = z - .object({ - format: z.literal(WORKSPACE_ROOT_FORMAT), - entries: z.array( - z.discriminatedUnion("kind", [directoryEntrySchema, fileEntrySchema, symlinkEntrySchema]), - ), - }) - .strict(); - -export type WorkspaceRootEntry = z.infer["entries"][number]; -export type WorkspaceRootManifest = z.infer; +/** + * How a caller other than a live run reports a Workspace root it cannot accept. + * + * The default names the run database the root was read from, which is what + * every live caller is holding. A sealed XMD artifact is not a run database and + * says so in its own words, so it supplies one of these rather than borrowing a + * sentence that would tell an operator to restore a run from a backup. + */ +function rejecting(databasePath: string): WorkspaceRejection { + return (reason: string) => corrupt(databasePath, reason); +} export interface StoredWorkspaceRoot { readonly rootId: string; @@ -61,9 +48,6 @@ export interface StoredWorkspaceRoot { readonly blobHashes: readonly string[]; } -export const EMPTY_WORKSPACE_MANIFEST = - '{"format":1,"entries":[{"path":"/","kind":"directory","mode":493,"mtime":0}]}'; - export const EMPTY_WORKSPACE_ROOT = workspaceRoot(EMPTY_WORKSPACE_MANIFEST, [], []); export const EMPTY_WORKSPACE_ROOT_ID = EMPTY_WORKSPACE_ROOT.rootId; @@ -96,40 +80,12 @@ export function encodeWorkspaceManifest( return JSON.stringify(manifest); } -/** - * How a caller other than a live run reports a Workspace root it cannot accept. - * - * The default names the run database the root was read from, which is what - * every live caller is holding. A sealed XMD artifact is not a run database and - * says so in its own words, so it supplies one of these rather than borrowing a - * sentence that would tell an operator to restore a run from a backup. - */ -export type WorkspaceRejection = (reason: string) => never; - -function rejecting(databasePath: string): WorkspaceRejection { - return (reason: string) => corrupt(databasePath, reason); -} - export function parseWorkspaceManifest( manifest: string, databasePath: string, reject: WorkspaceRejection = rejecting(databasePath), ): WorkspaceRootManifest { - let offered: unknown; - try { - offered = JSON.parse(manifest); - } catch { - reject("one of its retained Workspace roots is not JSON"); - } - const parsed = rootManifestSchema.safeParse(offered); - if (!parsed.success) { - reject("one of its retained Workspace roots has an invalid manifest"); - } - validateWorkspaceEntries(parsed.data.entries, databasePath, reject); - if (JSON.stringify(parsed.data) !== manifest) { - reject("one of its retained Workspace roots is not canonically encoded"); - } - return parsed.data; + return parseWorkspaceRootManifest(manifest, reject); } export function validateWorkspaceEntries( @@ -137,60 +93,7 @@ export function validateWorkspaceEntries( databasePath: string, reject: WorkspaceRejection = rejecting(databasePath), ): void { - if (entries.length === 0 || entries[0]?.path !== "/" || entries[0]?.kind !== "directory") { - reject("a Workspace root does not begin with its root directory"); - } - - let previous: string | undefined; - let nextHardlink = 0; - const directories = new Set(); - const hardlinkMembers = new Map(); - const hardlinkFirst = new Map(); - - for (const entry of entries) { - validateCanonicalPath(entry.path, databasePath, reject); - if (previous !== undefined && compareUtf8(previous, entry.path) >= 0) { - reject("a Workspace root's paths are duplicated or out of canonical order"); - } - previous = entry.path; - - if (entry.path !== "/" && !directories.has(parentPath(entry.path))) { - reject("a Workspace root contains an entry without a parent directory"); - } - if (entry.kind === "directory") { - directories.add(entry.path); - } - if ( - entry.kind === "symlink" && - (entry.target.includes("\0") || hasUnpairedSurrogate(entry.target)) - ) { - reject("a Workspace root contains an invalid symbolic-link target"); - } - if (entry.kind === "file" && entry.hardlink !== null) { - const first = hardlinkFirst.get(entry.hardlink); - if (first === undefined) { - if (entry.hardlink !== `h${nextHardlink}`) { - reject("a Workspace root's hardlinks are not canonically numbered"); - } - nextHardlink += 1; - hardlinkFirst.set(entry.hardlink, entry); - } else if ( - first.mode !== entry.mode || - first.mtime !== entry.mtime || - first.size !== entry.size || - first.manifest !== entry.manifest - ) { - reject("a Workspace root's hardlink group has inconsistent metadata"); - } - hardlinkMembers.set(entry.hardlink, (hardlinkMembers.get(entry.hardlink) ?? 0) + 1); - } - } - - for (const count of hardlinkMembers.values()) { - if (count < 2) { - reject("a Workspace root contains a one-member hardlink group"); - } - } + validateWorkspaceRootEntries(entries, reject); } export function validateCanonicalPath( @@ -198,22 +101,7 @@ export function validateCanonicalPath( databasePath: string, reject: WorkspaceRejection = rejecting(databasePath), ): void { - if (value === "/") { - return; - } - if ( - !value.startsWith("/") || - value.endsWith("/") || - value.includes("\0") || - hasUnpairedSurrogate(value) - ) { - reject("a Workspace root contains a noncanonical path"); - } - for (const part of value.slice(1).split("/")) { - if (part === "" || part === "." || part === "..") { - reject("a Workspace root contains a noncanonical path component"); - } - } + validateCanonicalWorkspacePath(value, reject); } export function validatePathName(name: string, databasePath: string): void { @@ -229,20 +117,6 @@ export function validatePathName(name: string, databasePath: string): void { } } -export function compareUtf8(left: string, right: string): number { - return Buffer.compare(encoder.encode(left), encoder.encode(right)); -} - -export function parentFirst(left: WorkspaceRootEntry, right: WorkspaceRootEntry): number { - const depth = left.path.split("/").length - right.path.split("/").length; - return depth === 0 ? compareUtf8(left.path, right.path) : depth; -} - -export function parentPath(path: string): string { - const boundary = path.lastIndexOf("/"); - return boundary === 0 ? "/" : path.slice(0, boundary); -} - export function sha256(value: Uint8Array): Uint8Array { return new Uint8Array(createHash("sha256").update(value).digest()); } @@ -292,19 +166,3 @@ export function mode(value: unknown, databasePath: string): number { export function corrupt(databasePath: string, reason: string): never { throw new WorkflowDatabaseCorruptError(databasePath, reason); } - -function hasUnpairedSurrogate(value: string): boolean { - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1); - if (next < 0xdc00 || next > 0xdfff) { - return true; - } - index += 1; - } else if (code >= 0xdc00 && code <= 0xdfff) { - return true; - } - } - return false; -} diff --git a/packages/workflow/src/deno/workspace/published.ts b/packages/workflow/src/deno/workspace/published.ts index e1fb6d601..779073ea1 100644 --- a/packages/workflow/src/deno/workspace/published.ts +++ b/packages/workflow/src/deno/workspace/published.ts @@ -26,7 +26,10 @@ import type { GitHubIssuesOptions } from "../issue/github.ts"; import type { GitHubPullRequestsOptions } from "../composition/pull-request-reads.ts"; import type { HelperAssembly } from "../composition/credential-helper.ts"; import { withWorkflowWorkspace as withBroadWorkspace } from "./host.ts"; -import type { WorkflowAgentInstaller } from "./host.ts"; +import type { + WorkflowAgentInstaller, + WorkflowWorkspaceOptions as BroadWorkflowWorkspaceOptions, +} from "./host.ts"; /** * What a host may configure, and the whole of it. @@ -62,16 +65,19 @@ export interface WorkflowWorkspaceOptions { readonly agent?: WorkflowAgentInstaller; } -/** Run `operation` with this run's Workspace attached, as a host installs it. */ -export function withWorkflowWorkspace( - database: WorkflowRunDatabase, - operation: Operation, - options: WorkflowWorkspaceOptions = {}, -): Operation { - // Projected member by member. A spread would carry whatever else a caller put - // on the object, and reading an unknown property is how a getter somebody - // else wrote gets to run. - return withBroadWorkspace(database, operation, { +/** + * The broad options these narrow ones permit, and nothing else. + * + * Member by member, because a spread would carry whatever else a caller put on + * the object and reading an unknown property is how a getter somebody else + * wrote gets to run. Shared with the runner assembly, which projects the same + * published options into the same internal shape: two projections of one + * boundary would eventually differ, and the difference would be a seam. + */ +export function permittedWorkspaceOptions( + options: WorkflowWorkspaceOptions, +): BroadWorkflowWorkspaceOptions { + return { ...(options.gitHubIssues === undefined ? {} : { gitHubIssues: options.gitHubIssues }), ...(options.gitHubPullRequests === undefined ? {} @@ -89,5 +95,14 @@ export function withWorkflowWorkspace( }), ...(options.helper === undefined ? {} : { helper: options.helper }), ...(options.agent === undefined ? {} : { agent: options.agent }), - }); + }; +} + +/** Run `operation` with this run's Workspace attached, as a host installs it. */ +export function withWorkflowWorkspace( + database: WorkflowRunDatabase, + operation: Operation, + options: WorkflowWorkspaceOptions = {}, +): Operation { + return withBroadWorkspace(database, operation, permittedWorkspaceOptions(options)); } diff --git a/packages/workflow/src/deno/workspace/repositories.ts b/packages/workflow/src/deno/workspace/repositories.ts index 82befb9a7..cb21e7326 100644 --- a/packages/workflow/src/deno/workspace/repositories.ts +++ b/packages/workflow/src/deno/workspace/repositories.ts @@ -29,12 +29,9 @@ import { type WorktreeRecord, } from "../../composition/records.ts"; import { reading } from "../reading.ts"; +import type { StoredRepository, WorkspaceMetadata } from "../../workspace/metadata.ts"; -/** A Repository row: its journal-safe record, and the locator only storage sees. */ -export interface StoredRepository { - readonly record: RepositoryRecord; - readonly locator: string; -} +export type { StoredRepository, WorkspaceMetadata } from "../../workspace/metadata.ts"; const REPOSITORY_COLUMNS = `name, locator, locator_fingerprint, requested_base, creation_commit, primary_branch, object_format, checkout_path`; @@ -195,23 +192,6 @@ export function insertWorktree(database: DatabaseSync, record: WorktreeRecord): ); } -/** - * The metadata one Workspace transaction may read and write. - * - * Handed to a mutation beside the filesystem, so retained Git identity and - * retained Git bytes move together inside one transaction. It is the provider's - * surface and not a document's: a component reaches it only by asking the - * composition provider to perform an effect. - */ -export interface WorkspaceMetadata { - readRepository(name: string): StoredRepository | undefined; - readRepositories(): StoredRepository[]; - insertRepository(stored: StoredRepository): void; - readWorktree(repositoryName: string, name: string): WorktreeRecord | undefined; - readWorktreesForRepository(repositoryName: string): WorktreeRecord[]; - insertWorktree(record: WorktreeRecord): void; -} - export function createWorkspaceMetadata( database: DatabaseSync, authorize: () => void, diff --git a/packages/workflow/src/deno/workspace/restore.ts b/packages/workflow/src/deno/workspace/restore.ts index 375994ef9..524f16592 100644 --- a/packages/workflow/src/deno/workspace/restore.ts +++ b/packages/workflow/src/deno/workspace/restore.ts @@ -13,7 +13,7 @@ import { } from "./manifest.ts"; import { loadWorkspaceRoot, - readDofsManifest, + readContentManifest, setCurrentWorkspaceRoot, snapshotWorkspace, verifyWorkspace, @@ -131,7 +131,7 @@ function materializeNode( .run(entry.mode, entry.mtime, revision, entry.target); inode = Number(result.lastInsertRowid); } else { - const manifest = readDofsManifest(database, entry.manifest, databasePath); + const manifest = readContentManifest(database, entry.manifest, databasePath); if (manifest.size !== entry.size) { corrupt(databasePath, "a retained file size differs from its DOFS manifest"); } diff --git a/packages/workflow/src/deno/workspace/root.ts b/packages/workflow/src/deno/workspace/root.ts index 55db2b482..de87da60c 100644 --- a/packages/workflow/src/deno/workspace/root.ts +++ b/packages/workflow/src/deno/workspace/root.ts @@ -1,5 +1,4 @@ import type { DatabaseSync } from "node:sqlite"; -import { z } from "zod"; import type { Database as CloudflareDatabase } from "../../../vendor/cloudflare-computer-dofs/generated/storage.js"; import { buildManifest } from "../../../vendor/cloudflare-computer-dofs/generated/sync/manifests.js"; import type { RunConnection, RunTransaction } from "../connections.ts"; @@ -25,33 +24,18 @@ import { workspaceRoot, WORKSPACE_ROOT_FORMAT, } from "./manifest.ts"; - -const decoder = new TextDecoder("utf-8", { fatal: true }); -const SHA256 = /^[0-9a-f]{64}$/; - -const dofsManifestSchema = z - .object({ - version: z.literal(1), - chunks: z.array( - z - .object({ - hash: z.string().regex(SHA256), - size: z.number().int().safe().positive(), - }) - .strict(), - ), - }) - .strict(); +import { SHA256 } from "../../workspace/root-manifest.ts"; +import { + type ContentManifest, + decodeContentManifest as decodeSharedContentManifest, +} from "../../workspace/content-manifest.ts"; export interface DofsChunk { readonly hash: Uint8Array; readonly size: number; } -export interface DofsManifest { - readonly size: number; - readonly chunks: readonly { readonly hash: string; readonly size: number }[]; -} +export type { ContentManifest } from "../../workspace/content-manifest.ts"; interface NodeRow { readonly inode: number; @@ -216,9 +200,12 @@ export function snapshotWorkspace( for (const [index, paths] of groups.entries()) { const group = `h${index}`; const members = new Set(paths); - for (const item of entries) { + for (const [position, item] of entries.entries()) { if (item.entry.kind === "file" && members.has(item.entry.path)) { - item.entry.hardlink = group; + // Rebuilt rather than mutated: a manifest entry is what a root is + // hashed over, and a value nobody can edit in place is one nobody can + // edit after it has been counted. + entries[position] = { ...item, entry: { ...item.entry, hardlink: group } }; } } } @@ -425,11 +412,11 @@ export function verifyWorkspace( } } -export function readDofsManifest( +export function readContentManifest( database: DatabaseSync, hash: string, databasePath: string, -): DofsManifest { +): ContentManifest { const hashBytes = fromHex(hash, databasePath, "DOFS manifest identity"); const row = reading( database, @@ -447,7 +434,7 @@ export function readDofsManifest( if (toHex(sha256(encoded)) !== hash) { corrupt(databasePath, "a DOFS manifest hash does not match its bytes"); } - const decoded = decodeDofsManifest(encoded, (reason) => corrupt(databasePath, reason)); + const decoded = decodeContentManifest(encoded, (reason) => corrupt(databasePath, reason)); if (decoded.size !== size) { corrupt(databasePath, "a DOFS manifest size does not equal its chunks"); } @@ -467,24 +454,11 @@ export function readDofsManifest( * whether these bytes are a canonically encoded DOFS manifest at all, and what * size the chunks it lists add up to. */ -export function decodeDofsManifest(encoded: Uint8Array, reject: WorkspaceRejection): DofsManifest { - let text: string; - let offered: unknown; - try { - text = decoder.decode(encoded); - offered = JSON.parse(text); - } catch { - reject("a DOFS manifest is not canonical UTF-8 JSON"); - } - const parsed = dofsManifestSchema.safeParse(offered); - if (!parsed.success || JSON.stringify(parsed.data) !== text) { - reject("a DOFS manifest is not canonically encoded"); - } - const total = parsed.data.chunks.reduce((sum, chunk) => sum + chunk.size, 0); - if (!Number.isSafeInteger(total)) { - reject("a DOFS manifest names more bytes than a size can hold"); - } - return Object.freeze({ size: total, chunks: Object.freeze(parsed.data.chunks) }); +export function decodeContentManifest( + encoded: Uint8Array, + reject: WorkspaceRejection, +): ContentManifest { + return decodeSharedContentManifest(encoded, reject); } function parseStoredRoot( @@ -517,12 +491,12 @@ function rootFromManifest( parsed: ReturnType, databasePath: string, ): StoredWorkspaceRoot { - const manifests = new Map(); + const manifests = new Map(); for (const entry of parsed.entries) { if (entry.kind === "file") { let manifest = manifests.get(entry.manifest); if (manifest === undefined) { - manifest = readDofsManifest(database, entry.manifest, databasePath); + manifest = readContentManifest(database, entry.manifest, databasePath); manifests.set(entry.manifest, manifest); } if (entry.size !== manifest.size) { @@ -576,7 +550,7 @@ function validateFile( corrupt(databasePath, "a Workspace file has an invalid DOFS manifest identity"); } const manifest = toHex(manifestHash); - const encoded = readDofsManifest(database, manifest, databasePath); + const encoded = readContentManifest(database, manifest, databasePath); if ( encoded.size !== node.size || !equalChunks( @@ -616,7 +590,7 @@ function validateDofsContentStore(database: DatabaseSync, databasePath: string): if (hash.byteLength !== 32) { corrupt(databasePath, "a DOFS manifest has an invalid hash length"); } - readDofsManifest(database, toHex(hash), databasePath); + readContentManifest(database, toHex(hash), databasePath); } } diff --git a/packages/workflow/src/fork.ts b/packages/workflow/src/fork.ts index a564fc2e4..d00c394d7 100644 --- a/packages/workflow/src/fork.ts +++ b/packages/workflow/src/fork.ts @@ -38,6 +38,10 @@ import { Err, Ok, type Result } from "effection"; import { describeWorkflowRun, WORKFLOW_RUN, type WorkflowRun } from "./journal.ts"; import type { Forkability } from "./lifecycle/forkability.ts"; import { WorkflowRequestError } from "./storage/errors.ts"; +import { isRootImportEvent, isRunRecordEvent } from "./journal-events.ts"; +import { forkRunRecordEvent } from "./journal-events.ts"; + +export { forkRunRecordEvent, isRootImportEvent, isRunRecordEvent } from "./journal-events.ts"; /** The coroutine a run's own record and canonical outcome belong to. */ const ROOT_COROUTINE = "root"; @@ -119,25 +123,6 @@ export function selectForkPrefix( }); } -/** - * The record the fork writes at position zero, exactly as its own execution - * would have written it. - * - * Composed here rather than in a host, so the value a fork is admitted with and - * the value its first execution replays are the same shape by construction. - */ -export function forkRunRecordEvent(run: WorkflowRun): DurableEvent { - return { - type: "yield", - coroutineId: ROOT_COROUTINE, - description: describeWorkflowRun(run.base), - result: { - status: "ok", - value: { runId: run.runId, base: run.base, pinnedCommit: run.pinnedCommit }, - }, - }; -} - /** * The fork's logical journal: its own two head records, then what it inherited. * @@ -156,25 +141,6 @@ export function forkJournal( ]); } -/** Whether this event is the root coroutine's import of the root document. */ -export function isRootImportEvent(event: DurableEvent): boolean { - return ( - event.type === "yield" && - event.description.type === IMPORT_COMPONENT && - event.description.name === ROOT_DOCUMENT - ); -} - -/** Whether this event is the root coroutine's own `workflow_run` record. */ -export function isRunRecordEvent(event: DurableEvent): boolean { - return ( - event.type === "yield" && - event.coroutineId === ROOT_COROUTINE && - event.description.type === WORKFLOW_RUN && - event.description.name === WORKFLOW_RUN - ); -} - /** Whether this event is the root's Close — the run's canonical outcome. */ function isRootOutcome(event: DurableEvent): boolean { return event.type === "close" && event.coroutineId === ROOT_COROUTINE; diff --git a/packages/workflow/src/git-blob.ts b/packages/workflow/src/git-blob.ts new file mode 100644 index 000000000..2ce920d10 --- /dev/null +++ b/packages/workflow/src/git-blob.ts @@ -0,0 +1,149 @@ +/** + * The object id Git gives one blob, computed rather than asked for. + * + * A workflow definition names each bundled component by the object id of the + * blob it was read from, and that id is identity: changing what a component + * says changes the definition rather than changing what a retained definition + * executes. Holding a retained history to that identity therefore means holding + * the exact bytes it recorded to it — repeating the id beside unrelated bytes + * establishes nothing about the bytes. + * + * A live run has the pinned source in hand and compares bytes with bytes. A + * completed replay has no pinned source and no repository to ask, so it does + * what Git does: frame the content as a blob object and name it. The framing is + * Git's own — the kind, the number of bytes, a NUL, then the content — and the + * number is the length of the *encoded* bytes rather than of the string, so a + * document whose characters and bytes differ in number is named the way Git + * names it. + * + * The arithmetic lives here for the reason `workspace/sha256.ts` gives for + * carrying its own: every host has a SHA-1 already and none of them has one a + * shared module can use. `node:crypto` names a host, and `crypto.subtle` is + * asynchronous — and this runs inside canonical core's synchronous journal + * admission, where there is nothing to await into. FIPS 180-4 is fixed, small + * and has published answers, and the tests hold this to Git's own. + */ + +import { sha256 } from "./workspace/sha256.ts"; +import type { GitObjectFormat } from "./git.ts"; + +const INITIAL = new Uint32Array([0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]); + +const ROUND = new Uint32Array([0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xca62c1d6]); + +function rotate(value: number, bits: number): number { + return ((value << bits) | (value >>> (32 - bits))) >>> 0; +} + +function padded(input: Uint8Array): Uint8Array { + const length = Math.ceil((input.length + 9) / 64) * 64; + const bytes = new Uint8Array(length); + bytes.set(input); + bytes[input.length] = 0x80; + const bits = BigInt(input.length) * 8n; + for (let index = 0; index < 8; index += 1) { + bytes[length - 1 - index] = Number((bits >> BigInt(index * 8)) & 0xffn); + } + return bytes; +} + +/** The mixing function this round uses, by the quarter it falls in. */ +function mixed(round: number, b: number, c: number, d: number): number { + if (round < 20) { + return (b & c) | (~b & d); + } + if (round < 40) { + return b ^ c ^ d; + } + if (round < 60) { + return (b & c) | (b & d) | (c & d); + } + return b ^ c ^ d; +} + +export function sha1(value: Uint8Array | string): Uint8Array { + const input = typeof value === "string" ? new TextEncoder().encode(value) : value; + const bytes = padded(input); + const state = new Uint32Array(INITIAL); + const words = new Uint32Array(80); + for (let offset = 0; offset < bytes.length; offset += 64) { + for (let index = 0; index < 16; index += 1) { + const at = offset + index * 4; + words[index] = + (((bytes[at] ?? 0) << 24) | + ((bytes[at + 1] ?? 0) << 16) | + ((bytes[at + 2] ?? 0) << 8) | + (bytes[at + 3] ?? 0)) >>> + 0; + } + for (let index = 16; index < 80; index += 1) { + words[index] = rotate( + (words[index - 3] ?? 0) ^ + (words[index - 8] ?? 0) ^ + (words[index - 14] ?? 0) ^ + (words[index - 16] ?? 0), + 1, + ); + } + + let a = state[0] ?? 0; + let b = state[1] ?? 0; + let c = state[2] ?? 0; + let d = state[3] ?? 0; + let e = state[4] ?? 0; + for (let round = 0; round < 80; round += 1) { + const mixture = + (rotate(a, 5) + + mixed(round, b, c, d) + + e + + (ROUND[Math.floor(round / 20)] ?? 0) + + (words[round] ?? 0)) >>> + 0; + e = d; + d = c; + c = rotate(b, 30); + b = a; + a = mixture; + } + state[0] = ((state[0] ?? 0) + a) >>> 0; + state[1] = ((state[1] ?? 0) + b) >>> 0; + state[2] = ((state[2] ?? 0) + c) >>> 0; + state[3] = ((state[3] ?? 0) + d) >>> 0; + state[4] = ((state[4] ?? 0) + e) >>> 0; + } + const digest = new Uint8Array(20); + for (let index = 0; index < state.length; index += 1) { + const word = state[index] ?? 0; + digest[index * 4] = word >>> 24; + digest[index * 4 + 1] = word >>> 16; + digest[index * 4 + 2] = word >>> 8; + digest[index * 4 + 3] = word; + } + return digest; +} + +function hex(digest: Uint8Array): string { + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export function sha1Hex(value: Uint8Array | string): string { + return hex(sha1(value)); +} + +/** The separator Git writes between an object's header and its content. */ +const NUL = "\u0000"; + +/** + * The object id one blob has under a repository's object format. + * + * What `git hash-object -t blob` answers, and nothing else. + */ +export function gitBlobId(content: string, objectFormat: GitObjectFormat): string { + const encoder = new TextEncoder(); + const body = encoder.encode(content); + const header = encoder.encode(`blob ${body.length}${NUL}`); + const framed = new Uint8Array(header.length + body.length); + framed.set(header); + framed.set(body, header.length); + return objectFormat === "sha256" ? hex(sha256(framed)) : hex(sha1(framed)); +} diff --git a/packages/workflow/src/journal-events.ts b/packages/workflow/src/journal-events.ts new file mode 100644 index 000000000..c9da3d2d1 --- /dev/null +++ b/packages/workflow/src/journal-events.ts @@ -0,0 +1,63 @@ +/** + * The two events a fork writes for itself, recognized by shape. + * + * A fork inherits a prefix of its source's journal, but not the source's own + * identity and not the import of the document the source was run from — it has + * its own of each. Telling those two rows apart from everything else is a + * property of the event, so it is stated here rather than wherever a prefix + * happens to be selected. + * + * Separate from `fork.ts` because both hosts need it and the owner cannot + * reach that module: selecting a fork's source on a Durable Object must not + * drag in forkability classification and everything it imports. + */ + +import type { DurableEvent } from "@executablemd/durable-streams"; +import { describeWorkflowRun, WORKFLOW_RUN } from "./journal.ts"; + +const ROOT_COROUTINE = "root"; +const IMPORT_COMPONENT = "import_component"; +const ROOT_DOCUMENT = "__root__"; + +/** Whether this event is the import of the run's root document. */ +export function isRootImportEvent(event: DurableEvent): boolean { + return ( + event.type === "yield" && + event.description.type === IMPORT_COMPONENT && + event.description.name === ROOT_DOCUMENT + ); +} + +/** Whether this event is the root coroutine's own `workflow_run` record. */ +export function isRunRecordEvent(event: DurableEvent): boolean { + return ( + event.type === "yield" && + event.coroutineId === ROOT_COROUTINE && + event.description.type === WORKFLOW_RUN && + event.description.name === WORKFLOW_RUN + ); +} + +/** + * The record a fork writes at position zero, exactly as its own execution would + * have written it. + * + * Composed here rather than in a host, so the value a fork is admitted with, + * the value its destination owner validates, and the value its first execution + * replays are the same shape by construction. + */ +export function forkRunRecordEvent(run: { + readonly runId: string; + readonly base: string; + readonly pinnedCommit: string; +}): DurableEvent { + return { + type: "yield", + coroutineId: ROOT_COROUTINE, + description: describeWorkflowRun(run.base), + result: { + status: "ok", + value: { runId: run.runId, base: run.base, pinnedCommit: run.pinnedCommit }, + }, + }; +} diff --git a/packages/workflow/src/lifecycle/policy.ts b/packages/workflow/src/lifecycle/policy.ts new file mode 100644 index 000000000..6acc646e4 --- /dev/null +++ b/packages/workflow/src/lifecycle/policy.ts @@ -0,0 +1,625 @@ +/** + * What a lifecycle transition decides, apart from where the rows live. + * + * Both hosts store a run's lifecycle in the same schema and must reach the same + * conclusions about it: whether a caller may continue, what a dead executor's + * unfinished execution became, and whether beginning publishes `running` or + * leaves an outcome that already won alone. Where those rows are read from is + * the host's business — a local SQLite file, or a Durable Object on the other + * end of a connection — but the conclusions are not, and two copies of them + * would eventually disagree about what a run is. + * + * So the decisions live here, as functions over values. Nothing in this module + * reads or writes anything. + */ + +import type { DurableEvent } from "@executablemd/durable-streams"; +import { recordedRootImport } from "@executablemd/core/host"; +import type { SelectionOutcome } from "@executablemd/core/host"; +import { WorkflowRequestError } from "../storage/errors.ts"; +import type { + DocumentExecutionCompletion, + WorkflowRunStatus, + WorkflowStopReason, +} from "../storage/record.ts"; +import type { JournalEntry } from "../storage/api.ts"; + +/** An outcome that already won. A run in one of these is not made mutable again. */ +export function terminal(status: WorkflowRunStatus): boolean { + return status === "completed" || status === "failed"; +} + +/** What a run says when its executor went without saying anything. */ +export const INTERRUPTED: DocumentExecutionCompletion["reason"] = Object.freeze({ + kind: "host", + code: "executor-interrupted", +}); + +/** + * The one categorical word a failed run has when its journal holds no row that + * says why. + * + * A run that failed always names a reason. Usually that is the exact retained + * row it failed at; a failure the journal has no row for — one raised outside + * any durable operation — has this instead, and nothing else. It is a code + * rather than a message because the alternative is retaining an exception's + * text beside the journal that filtered it. + */ +export const DOCUMENT_FAILED = "document-execution-failed"; + +/** + * Why a failed run stopped, from the history it holds. + * + * The last retained row that failed, and this rule is the whole of it. The + * runner settling a live document, the recovery reading a dead one's journal + * and the admission holding a retained history to its lifecycle row all reach + * it here, because a reason chosen three ways would be three explanations of + * one failure. + */ +export function retainedFailureReason(entries: readonly JournalEntry[]): WorkflowStopReason { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry !== undefined && entry.event.result.status === "err") { + return { kind: "journal", eventId: entry.eventId }; + } + } + return { kind: "host", code: DOCUMENT_FAILED }; +} + +/** + * What the root recorded, as one semantic outcome. + * + * A durable `Close` has two layers and both are load-bearing. The outer one is + * the coroutine's own settlement: it raised, it was cancelled, or it *returned* + * — and returning is what a document does whether it succeeded or failed. So an + * outer `ok` says only that the value beneath it is the document's own result, + * and that result's `status` is what decides whether the run completed or + * failed. Reading the outer layer alone calls every finished document a + * completed one. + * + * A returned value that is not a document result at all is neither: it is a + * terminal this build cannot read, and answering `completed` or `interrupted` + * for it would be inventing an outcome for history nobody can account for. + */ +export type RetainedTerminal = + | { + readonly kind: "outcome"; + readonly status: WorkflowRunStatus; + readonly reason: DocumentExecutionCompletion["reason"]; + } + | { readonly kind: "damaged" }; + +export function rootOutcome(entries: readonly JournalEntry[]): RetainedTerminal | undefined { + const frontier = terminalFrontier(entries); + if (frontier.kind === "absent") { + return undefined; + } + if (frontier.kind === "mixed") { + // Two results, or work recorded after the one result: a history no single + // execution produced. Choosing one of them would be this build deciding + // which execution the run was. + return { kind: "damaged" }; + } + const event: DurableEvent = frontier.entry.event; + if (event.type !== "close") { + return { kind: "damaged" }; + } + if (event.result.status !== "ok") { + return { + kind: "outcome", + status: event.result.status === "cancelled" ? "cancelled" : "failed", + reason: { kind: "journal", eventId: frontier.entry.eventId }, + }; + } + const document = readDocumentResult(event.result.value); + if (document === undefined) { + return { kind: "damaged" }; + } + // The terminal has to agree with the history around it. A binding is written + // only by a run that failed before importing anything, so a history that + // imported its root and then recorded one describes two different executions; + // and an ordinary document result is what a run produces *after* importing, + // so one recorded with no import behind it describes a document nothing + // named. Neither is a history any execution can produce, and reading the + // terminal alone cannot tell either of them apart from the real thing. + const imported = rootImports(entries); + if (document.kind === "bound") { + return imported.kind === "none" ? boundOutcome(entries) : { kind: "damaged" }; + } + if (imported.kind !== "one") { + return { kind: "damaged" }; + } + if (document.kind === "ok") { + // The selection has to be able to lead to the result beside it. A recorded + // selection failure is a document that never ran: canonical execution + // raises it out of the root import, so the only terminal it can reach is a + // failed one. A successful result over it is two histories, not one. + return imported.selection.failed + ? { kind: "damaged" } + : { kind: "outcome", status: "completed", reason: undefined }; + } + return { kind: "outcome", status: "failed", reason: retainedFailureReason(entries) }; +} + +/** The outcome a run that failed before importing anything recorded. */ +function boundOutcome(entries: readonly JournalEntry[]): RetainedTerminal { + return { kind: "outcome", status: "failed", reason: retainedFailureReason(entries) }; +} + +/** + * The root import this history recorded, when it recorded exactly one. + * + * The root's own import is the entry every other record of the run hangs from, + * and canonical core admits a terminal history only when one coroutine — this + * one — recorded exactly one. Both the recovery that publishes an outcome from + * a terminal and the admission that reuses one ask this, so neither can accept + * a history the other refuses. + */ +export type RootImports = + | { readonly kind: "none" } + | { readonly kind: "one"; readonly selection: RetainedRootSelection } + /** More than one retained event names the root import. */ + | { readonly kind: "many" } + /** One does, and it is not a root import this build can read. */ + | { readonly kind: "malformed" }; + +export function rootImports(entries: readonly JournalEntry[]): RootImports { + // Every event that *names* the root import, whichever coroutine claims it. + // Uniqueness is asked of the name, not of the ownership: a child coroutine + // recording one is a second account of the run's own entry, and canonical + // core refuses that history rather than looking past it. + const imports = entries.filter((entry) => namesRootImport(entry.event)); + const only = imports[0]; + if (only === undefined) { + return { kind: "none" }; + } + if (imports.length !== 1 || only.event.coroutineId !== ROOT_COROUTINE) { + return imports.length === 1 ? { kind: "malformed" } : { kind: "many" }; + } + const selection = rootSelection(only.event); + return selection === undefined ? { kind: "malformed" } : { kind: "one", selection }; +} + +function namesRootImport(event: DurableEvent): boolean { + return ( + event.type === "yield" && + event.description.type === "import_component" && + event.description.name === ROOT_COMPONENT + ); +} + +/** + * The document one retained root import selected, as its own record holds it. + * + * Enough of the parsed record to make the same request again, and no more: the + * document itself, the selector it was asked with, and whether that selector + * named a target at all. What proved the record — the outline it was verified + * against — stays with the parser. + */ +export interface RetainedRootSelection { + readonly path: string; + readonly content: string; + /** + * The selector to replay the run's request with: the exact target it ran, or + * the selector whose failure it recorded. Absent for a whole document. + */ + readonly target: string | undefined; + /** Whether the recorded selection is one that named no single target. */ + readonly failed: boolean; +} + +/** + * The selection one retained root import recorded, read the way canonical + * execution reads it. + * + * Not read here at all, in fact: `recordedRootImport()` is the parser canonical + * `admitRootSelection()` admits a partial history through, and this asks it the + * same question about the same event. A record it calls malformed is malformed + * for the lifecycle too, so an unparseable document, a target the retained + * document does not offer, a noncanonical target, and a failure record the same + * selector would not re-derive cannot publish an outcome here after the + * executor refused them there. + * + * What comes back is that parser's own copy of the record, so the document a + * replay is built from is never the object the journal still holds. + */ +function rootSelection(event: DurableEvent): RetainedRootSelection | undefined { + if (event.type !== "yield") { + return undefined; + } + const recorded = recordedRootImport(event); + if (recorded.kind !== "read") { + return undefined; + } + return { + path: recorded.path, + content: recorded.content, + target: selector(recorded.selection), + failed: recorded.selection.kind === "failed", + }; +} + +/** + * What a replay asks for to make the same request again. + * + * A recorded failure hands back the selector rather than nothing: canonical + * execution resolves it against the same retained document, finds the same + * failure, and fails the same way. Handing back nothing would ask for the whole + * document instead — a different request that would succeed. + */ +function selector(selection: SelectionOutcome): string | undefined { + switch (selection.kind) { + case "whole": + return undefined; + case "exact": + return selection.target; + case "failed": + return selection.failure.selector; + } +} + +export function preRootSelection( + entries: readonly JournalEntry[], +): RetainedRootSelection | undefined { + const frontier = terminalFrontier(entries); + if (frontier.kind !== "final") { + return undefined; + } + const settlement = plain(frontier.entry.event.result); + const result = plain(settlement?.["value"]); + const binding = plain(result?.[ROOT_BINDING]); + if (binding === undefined) { + return undefined; + } + const path = binding["path"]; + const source = binding["source"]; + const target = binding["target"]; + if (typeof path !== "string" || typeof source !== "string") { + return undefined; + } + return { + path, + content: source, + target: typeof target === "string" ? target : undefined, + // A binding is what a run that failed *before* importing recorded, so it + // holds the document it was asked for rather than the outcome of selecting + // in it. There is no recorded selection failure to disagree with. + failed: false, + }; +} + +/** The coroutine a document execution's own records belong to. */ +const ROOT_COROUTINE = "root"; + +/** The name canonical execution records the run's own document import under. */ +const ROOT_COMPONENT = "__root__"; + +/** + * Where the root's terminal sits in a history, when it sits anywhere. + * + * One execution records one result, and records it last. A history holding two, + * or holding anything after the one it stands behind, is not one execution's — + * and reading it as though the first of them were authoritative is how a + * lifecycle row comes to be published from history nobody can account for. Both + * the recovery that publishes an outcome and the admission that reuses one ask + * this, so neither can decide the question the other way. + */ +export type TerminalFrontier = + | { readonly kind: "absent" } + | { readonly kind: "mixed" } + | { readonly kind: "final"; readonly entry: JournalEntry }; + +export function terminalFrontier(entries: readonly JournalEntry[]): TerminalFrontier { + const closes = entries.filter( + (entry) => entry.event.type === "close" && entry.event.coroutineId === "root", + ); + const only = closes[0]; + if (only === undefined) { + return { kind: "absent" }; + } + return closes.length === 1 && entries[entries.length - 1] === only + ? { kind: "final", entry: only } + : { kind: "mixed" }; +} + +/** + * The document result a returning root recorded, or nothing when it recorded + * something else. + * + * The shape is canonical core's `DocumentResult` (`packages/core/src/execute.ts`) + * and is parsed as the closed form it is: a success carries its rendered output + * and the value the document produced, a failure carries the output it rendered + * before it failed and the described failure itself, and a terminal core wrote + * before importing anything carries the root it was about beside them. A record + * missing a member, carrying one of the wrong type, or carrying a member this + * form does not have is not a result to reuse. + */ +function readDocumentResult(value: unknown): { kind: "ok" | "err" | "bound" } | undefined { + const result = plain(value); + if (result === undefined || typeof result["output"] !== "string") { + return undefined; + } + if (result["status"] === "ok") { + return names(result, ["status", "output", "value"]) && "value" in result + ? { kind: "ok" } + : undefined; + } + if (result["status"] !== "err") { + return undefined; + } + if (ROOT_BINDING in result) { + // A binding is not decoration a failure may carry. Core writes one in + // exactly one situation — it failed before importing anything — and the + // whole form is what makes that import-free history attributable to one + // document at all. Anything else wearing a binding is a terminal core did + // not write. + return readPreRootTerminal(result) ? { kind: "bound" } : undefined; + } + return names(result, ["status", "output", "error"]) && readDocumentFailure(result["error"]) + ? { kind: "err" } + : undefined; +} + +/** Where core records which document a terminal it wrote before importing was about. */ +const ROOT_BINDING = "root_binding"; + +/** + * Whether a failure carrying a binding is the exact terminal core writes before + * it has imported anything. + * + * The form is `recordedPreRootTerminal()`'s in `packages/core/src/execute.ts`, + * and every part of it is load-bearing: nothing was rendered, so the output is + * empty; no segment failed, so the description repeats the failure's own + * message and says nothing else; and the binding names the document — its path, + * the supplied text for an inline root and nothing for a file one, and the + * selector as written — which is the only thing making a history with no root + * import about one document rather than any. + */ +function readPreRootTerminal(result: Record): boolean { + if (!names(result, ["status", "output", "error", ROOT_BINDING]) || result["output"] !== "") { + return false; + } + const failure = plain(result["error"]); + if ( + failure === undefined || + !names(failure, ["name", "message", "segment", "cause"]) || + typeof failure["name"] !== "string" || + typeof failure["message"] !== "string" || + ("cause" in failure && typeof failure["cause"] !== "string") + ) { + return false; + } + const segment = plain(failure["segment"]); + if ( + segment === undefined || + !names(segment, ["message"]) || + segment["message"] !== failure["message"] + ) { + return false; + } + const binding = plain(result[ROOT_BINDING]); + if (binding === undefined || !names(binding, ["path", "source", "target"])) { + return false; + } + const path = binding["path"]; + const source = binding["source"]; + const target = binding["target"]; + return ( + typeof path === "string" && + (source === null || typeof source === "string") && + (target === null || typeof target === "string") && + "source" in binding && + "target" in binding + ); +} + +/** Whether a described failure is the closed form core writes. */ +function readDocumentFailure(value: unknown): boolean { + const failure = plain(value); + if ( + failure === undefined || + typeof failure["name"] !== "string" || + typeof failure["message"] !== "string" || + !names(failure, ["name", "message", "segment", "cause", "errors"]) + ) { + return false; + } + const segment = plain(failure["segment"]); + if ( + segment === undefined || + typeof segment["message"] !== "string" || + !names(segment, ["message", "source"]) || + ("source" in segment && typeof segment["source"] !== "string") + ) { + return false; + } + if ("cause" in failure && typeof failure["cause"] !== "string") { + return false; + } + const errors = failure["errors"]; + if (errors === undefined) { + return true; + } + return ( + Array.isArray(errors) && + errors.every((entry) => { + const described = plain(entry); + return ( + described !== undefined && + typeof described["name"] === "string" && + typeof described["message"] === "string" && + names(described, ["name", "message"]) + ); + }) + ); +} + +/** A retained value that is an ordinary object, read once through its own names. */ +function plain(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const held: Record = {}; + for (const name of Object.keys(value)) { + held[name] = Reflect.get(value, name); + } + return held; +} + +/** Whether a record carries only members this form declares. */ +function names(record: Record, declared: readonly string[]): boolean { + return Object.keys(record).every((name) => declared.includes(name)); +} + +/** + * Whether the two accounts of why this run stopped are the same account. + * + * A reason that names an event names the exact retained row, a categorical one + * carries the same word, and one that names nothing leaves the run with + * nothing. An unrelated row, the wrong row, an invented code and a missing + * reason are each a different explanation of the same failure. + */ +function sameReason( + retained: DocumentExecutionCompletion["reason"], + canonical: DocumentExecutionCompletion["reason"], +): boolean { + if (canonical === undefined || retained === undefined) { + return canonical === retained; + } + if (canonical.kind === "journal") { + return retained.kind === "journal" && retained.eventId === canonical.eventId; + } + return retained.kind === "host" && retained.code === canonical.code; +} + +/** + * Whether a run in this state is the run this retained history produced. + * + * One execution has one winning outcome, and `rootOutcome()` above is it. + * Settlement and stale recovery are two ways of publishing that same semantic + * result, not two authorities allowed to disagree — so a lifecycle row saying + * anything else is a second account of one run, and a replay that reused either + * would be choosing between them. + */ +export function agreesWithRetainedResult( + record: { readonly status: WorkflowRunStatus; readonly stopReason?: WorkflowStopReason }, + canonical: RetainedTerminal, +): boolean { + return ( + canonical.kind === "outcome" && + record.status === canonical.status && + sameReason(record.stopReason, canonical.reason) + ); +} + +/** What closing a dead executor's execution makes of it, and of the run. */ +export interface Closing { + readonly status: WorkflowRunStatus; + readonly reason: DocumentExecutionCompletion["reason"]; + /** Whether the run's own status follows the execution's, or stays as it is. */ + readonly publishes: boolean; + /** + * Whether this run's own terminal is one this build cannot read. + * + * Distinct from "nothing to publish", and the distinction is the whole point: + * a run with nothing to publish is one to go on with, and this is a run whose + * document already ended in a way nothing here can account for. Losing it + * between recovery and the caller is how an unreadable terminal came to + * authorize a second execution. + */ + readonly damaged: boolean; +} + +/** + * What an unfinished execution becomes, on the evidence the run itself holds. + * + * A replay whose terminal state was preserved closes only its own execution: + * the authoritative outcome stays exactly as it was. Otherwise a retained root + * Close proves the canonical outcome won, and without one the executor was + * interrupted. A root Close this build cannot read is none of those: it proves + * the document finished, so nothing about the run or the execution it left is + * decided here, and the caller is refused instead. + */ +export function closingOutcome( + storedStatus: WorkflowRunStatus, + canonical: RetainedTerminal | undefined, +): Closing { + // Damage first, and before the stored status is consulted at all. A run whose + // row already says `completed` is not a run whose journal is therefore safe: + // the two accounts have to agree before either is reused, and a row cannot + // vouch for history nothing can read. + if (canonical?.kind === "damaged") { + // The document finished and this build cannot read what it finished as. + // Calling that an interruption would say the executor went without saying + // anything, which is the one thing this history rules out; closing its + // execution would say the same about the execution. So nothing here is + // decided at all, and the caller is told why. + return { status: storedStatus, reason: undefined, publishes: false, damaged: true }; + } + if (terminal(storedStatus)) { + return { status: "interrupted", reason: INTERRUPTED, publishes: false, damaged: false }; + } + if (canonical === undefined) { + return { status: "interrupted", reason: INTERRUPTED, publishes: true, damaged: false }; + } + return { status: canonical.status, reason: canonical.reason, publishes: true, damaged: false }; +} + +/** + * What a caller is told when the run it named holds a terminal nothing can read. + * + * One sentence, shared by every provider and every action, and carrying nothing + * the history held: what is unreadable is retained data, and a diagnostic that + * quoted it would publish exactly what it exists to refuse. + */ +export function damagedTerminalRefusal(): WorkflowRequestError { + return new WorkflowRequestError( + "workflow run: its root recorded a document result this version cannot read, so the run " + + "is neither advanced nor changed. The run is left exactly as it is.", + ); +} + +/** + * Why this caller may not continue, or nothing when it may. + * + * A run nobody has begun admits either action. A failed or cancelled run is not + * resumed, and a cancelled run is not advanced at all — both report what is + * retained rather than moving it. + */ +export function admissionRefusal( + action: "start" | "resume", + status: WorkflowRunStatus | undefined, +): Error | undefined { + if (status === undefined) { + return undefined; + } + if (action === "resume" && (status === "failed" || status === "cancelled")) { + return new WorkflowRequestError( + `workflow run ${status}: a run that ${ + status === "failed" ? "failed" : "was cancelled" + } is not resumed. The run is left exactly as it is.`, + ); + } + if (status === "cancelled") { + return new WorkflowRequestError( + "workflow run cancelled: a cancelled run reports its retained state and is not advanced.", + ); + } + return undefined; +} + +/** What beginning does, given what recovery left behind. */ +export type BeginDecision = + /** Nothing was there: this begin creates the run and its first execution. */ + | { readonly kind: "create" } + /** An outcome already won: record the execution and leave the status alone. */ + | { readonly kind: "replay" } + /** An ordinary continuation: record the execution and publish `running`. */ + | { readonly kind: "running" }; + +export function beginDecision(recoveredStatus: WorkflowRunStatus | undefined): BeginDecision { + if (recoveredStatus === undefined) { + return { kind: "create" }; + } + return terminal(recoveredStatus) ? { kind: "replay" } : { kind: "running" }; +} diff --git a/packages/workflow/src/remote/answer-link.ts b/packages/workflow/src/remote/answer-link.ts new file mode 100644 index 000000000..6ed2d76fd --- /dev/null +++ b/packages/workflow/src/remote/answer-link.ts @@ -0,0 +1,81 @@ +/** + * What crosses a connection when a durable wait is answered. + * + * Types only, and deliberately a leaf. An adapter implements these, and an + * adapter is the one place that must not reach a document runtime: judging a + * value against a response schema needs a schema compiler and a secret scanner, + * and neither belongs in a run's owner. So the shapes live here, where nothing + * an adapter cannot load lives, and the judging lives beside the contract that + * needs it. + * + * Nothing here is a parsed request. What a wait retained is journal data until + * something walks it, and walking it is what `remote/delivery.ts` does before a + * value is judged against it. + */ + +import type { Operation, Result } from "effection"; +import type { Json } from "@executablemd/durable-streams"; + +/** One delivered answer, as a run's owner retains it. */ +export interface RemoteRetainedAnswer { + readonly suspensionId: string; + readonly requestEventId: string; + readonly requestFingerprint: string; + readonly answer: Json; + readonly state: "pending" | "consumed"; +} + +/** + * The wait a run is standing at, as its owner retains it. + * + * `request` and `responseSchema` are exactly what the retained description + * held. The fingerprint is the owner's own, over that description, and is what + * a retention is later held to — so a runner that derives a different one from + * the same bytes has found a disagreement rather than a wait. + */ +export interface RemoteRetainedWaitRecord { + readonly runId: string; + readonly suspensionId: string; + readonly requestEventId: string; + readonly request: Json; + readonly responseSchema: Json; + readonly requestFingerprint: string; +} + +/** + * One value offered to a wait, for its owner to judge and retain. + * + * It carries the value and the gate decision and nothing else. There is + * deliberately no request identity and no claim that anything was checked: the + * owner resolves the wait it is answering, judges the value against the schema + * that wait retained, and applies the selected gate, all where the write + * happens. A member saying "already validated" would be a caller deciding what + * it is allowed to store. + */ +export interface RemoteAnswerRetention { + readonly runId: string; + readonly suspensionId: string; + readonly answer: Json; + /** Whether the owner applies the credential gate before retaining. */ + readonly secretDetection: boolean; +} + +/** What one accepted delivery left behind. */ +export interface RemoteAnswerRetained { + readonly runId: string; + readonly suspensionId: string; +} + +/** + * Reaching a run's owner to answer it, and nothing else. + * + * Two operations, both about one wait. There is no acquisition to take, no + * lifecycle to move and no journal to append: an implementation offering any of + * those would be an executor plane wearing this one's name. + */ +export interface RemoteDeliveryLink { + /** What this run is waiting at, or why it is not waiting at that. */ + wait(runId: string, suspensionId: string): Operation>; + /** Retain one judged value, or say why this run will not. */ + retain(retention: RemoteAnswerRetention): Operation>; +} diff --git a/packages/workflow/src/remote/answers.ts b/packages/workflow/src/remote/answers.ts new file mode 100644 index 000000000..51b3c4439 --- /dev/null +++ b/packages/workflow/src/remote/answers.ts @@ -0,0 +1,171 @@ +/** + * Ending a durable wait on a run whose owner is somewhere else. + * + * The delivered value is retained on the owner, and the event that answers the + * wait is published on the owner, and the two have to happen together. Locally + * that is one SQLite transaction. Here it is one commit: the execution reads + * what the owner retains, publishes the answer into the transaction it is + * already inside, and enlists the consumption beside it — so the owner receives + * one proposal that appends the event and spends the row, and applies both or + * neither. + * + * Nothing about that is a claim the runner gets to make. The owner holds the + * value, checks the event against what it holds, and refuses a consumption + * whose row is gone, spent, or delivered against a different request. What the + * runner decides is only where its execution is standing. + */ + +import { type Operation, scoped } from "effection"; +import type { Json, JournalProvenance } from "@executablemd/durable-streams"; +import { atOwnRequest } from "../suspension/position.ts"; +import { suspensionRequestFingerprint } from "../suspension/api.ts"; +import { SUSPENSION_REQUEST } from "../suspension/effects.ts"; +import { + type SuspensionAnswerAuthority, + type SuspensionAnswerProvider, + useSuspensionAnswerProvider, +} from "../suspension/answer.ts"; +import type { WorkflowRunDatabase } from "../storage/api.ts"; +import { WorkflowRequestError, WorkflowTransactionError } from "../storage/errors.ts"; +import type { RemoteRetainedAnswer } from "./answer-link.ts"; +import { activeWorkspaceRoute, type RemoteRunLink } from "./database.ts"; +import { withRemoteJournalRoute } from "./journal-route.ts"; + +/** The one acquired run an answer may be claimed against. */ +export interface RemoteAnsweredRun { + /** The acquisition this run is reached through. */ + readonly link: RemoteRunLink; + /** The handle the execution reads and transacts through. */ + readonly database: WorkflowRunDatabase; + /** The witness taken over the exact journal this run publishes into. */ + readonly provenance: JournalProvenance; +} + +/** + * Install this run's answer provider for the current scope. + * + * Installed beside one acquired execution's run and closed with it. There is no + * name to reach it by and no value to copy: registration is keyed by an opaque + * selection this module hands the coordinator, so a fabricated one resolves to + * nothing and a closed one stops resolving. + */ +export function* installRemoteSuspensionAnswers(run: RemoteAnsweredRun): Operation { + yield* useSuspensionAnswerProvider(remoteAnswerProvider(run)); +} + +function remoteAnswerProvider(run: RemoteAnsweredRun): SuspensionAnswerProvider { + const { link, database } = run; + return { + *claim(authority: SuspensionAnswerAuthority): Operation { + // Where the execution stands, before what the owner retains. An execution + // that is not at this wait has nothing to claim, and asking the owner + // about a wait this execution is not standing at would be asking it to + // decide something only position can decide. + const refused = yield* atOwnRequest(database, authority.suspensionId, authority.request); + if (refused !== undefined) { + return undefined; + } + + // The event this run published the request as, read from the run's own + // journal rather than derived. The owner compares it with what the run is + // standing at, so a claim naming the wrong one is asking about a wait + // rather than claiming this one. + const requestEventId = yield* publishedRequest(database, authority.suspensionId); + if (requestEventId === undefined) { + return undefined; + } + const pending = yield* link.pendingAnswer(authority.suspensionId, requestEventId); + if (!pending.ok) { + throw pending.error; + } + if (pending.value === undefined || pending.value.state !== "pending") { + // Nothing retained, or an answer this run already published. Either way + // this wait is not ended by a delivery, and the wait itself follows. + return undefined; + } + const retained = pending.value; + if (retained.requestFingerprint !== suspensionRequestFingerprint(authority.request)) { + // Retained against a different request. It is not an answer to the wait + // this execution reached, whatever it is an answer to. + return undefined; + } + + const claimed = yield* database.transact(function* (transaction) { + const route = yield* activeWorkspaceRoute(database, transaction); + if (route === undefined) { + throw new WorkflowTransactionError( + "the answer claim is not bound to this active workflow run transaction.", + ); + } + // The provenance this coordinator was given has to be the journal this + // transaction commits against. A publication routed anywhere else would + // append its answer outside the transaction that spends the row. + if ( + authority.journalProvenance === undefined || + authority.journalProvenance !== run.provenance + ) { + throw new WorkflowRequestError( + "the live journal this answer would be published into does not have the " + + "provenance of the selected remote run.", + ); + } + + yield* withRemoteJournalRoute(database, transaction, authority.publish(retained.answer)); + // Named, not asserted: the owner reads what it retained, holds the + // event this transaction appends to it, and spends the row in the same + // transaction — or spends nothing and appends nothing. + route.consume({ + suspensionId: retained.suspensionId, + requestEventId: retained.requestEventId, + requestFingerprint: retained.requestFingerprint, + }); + return retained.answer; + }); + if (!claimed.ok) { + throw claimed.error; + } + // Only now. What proves the wait ended is the owner having committed the + // transaction that appended the answer and spent the row — not that a + // publication was offered inside it. + return claimed.value; + }, + }; +} + +/** + * The journal event this run published one wait's request as. + * + * Read from the run's own history, so what the claim names is what the run + * retains rather than something derived from the identifier it was given. + */ +function* publishedRequest( + database: WorkflowRunDatabase, + suspensionId: string, +): Operation { + const entries = yield* database.readJournalEntries(); + if (!entries.ok) { + return undefined; + } + const found = entries.value.find( + (entry) => + entry.event.type === "yield" && + entry.event.description.type === SUSPENSION_REQUEST && + entry.event.description.name === suspensionId, + ); + return found?.eventId; +} + +/** Read one run's retained answer in a scope of its own. */ +export function readRemoteAnswer( + link: RemoteRunLink, + suspensionId: string, + requestEventId: string, +): Operation { + return scoped(function* () { + const pending = yield* link.pendingAnswer(suspensionId, requestEventId); + if (!pending.ok) { + throw pending.error; + } + return pending.value; + }); +} diff --git a/packages/workflow/src/remote/client.ts b/packages/workflow/src/remote/client.ts new file mode 100644 index 000000000..d597962e6 --- /dev/null +++ b/packages/workflow/src/remote/client.ts @@ -0,0 +1,397 @@ +/** + * The runner's side of the connection to its owner. + * + * One connection, one acquisition, and one request in flight at a time per + * command id. Requests and answers are correlated explicitly rather than by + * arrival order, because a socket delivers what the owner sent whenever it + * sent it, and a client that assumed order would attribute one command's + * refusal to another. + * + * Nothing here decides anything about the run. It carries a question to the + * owner and hands back what the owner said — including a refusal, which is an + * answer rather than a transport failure. What the owner does with a command is + * the owner's, and a client that interpreted a refusal would be a second place + * deciding what a run may do. + * + * What it will not do is carry on after the two sides disagree about which + * command completed. An answer it cannot read, an answer naming a request + * nobody made, and a second answer to a request already settled are each + * evidence that correlation has broken — and a commit may have landed on the + * owner while the caller waits for a reply that will never be attributed. So + * the channel fails closed: it stops, and every waiter learns, rather than + * dropping the answer and leaving somebody blocked forever. + */ + +import { ensure, type Operation, resource, withResolvers } from "effection"; + +/** + * The most bytes one message on this connection may carry. + * + * The bound is the whole message as it crosses, in both directions. Measuring + * one member of a request instead would let a request that clears the check + * still be too large once its correlation and framing are added. + */ +export const MAX_MESSAGE_BYTES = 8 * 1024 * 1024; + +/** Why the connection itself could not carry a request. */ +export type LinkRefusal = + | "closed" + | "malformed-answer" + | "unknown-answer" + | "duplicate-answer" + | "too-large" + | "malformed-request" + | "send-failed" + | "socket-error"; + +/** + * A parser's own failure, as something that can be settled and reported. + * + * A parser may throw anything. What travels back to the caller has to be an + * `Error`, and it has to stay the parser's failure rather than becoming the + * channel's, so the boundary that knows what the value meant can classify it. + */ +function unreadable(error: unknown): Error { + return error instanceof Error ? error : new OwnerLinkError("malformed-answer"); +} + +export class OwnerLinkError extends Error { + override name = "OwnerLinkError"; + + constructor(readonly refusal: LinkRefusal) { + super(`the connection to this run's owner cannot carry the request (${refusal})`); + } +} + +/** + * What the owner answered, once the caller's own parser has read the value. + * + * `T` is what the request asked for. A performed answer carries a parsed value + * and never an `unknown`: the JSON boundary is inside this module, and letting + * it out would make every consumer responsible for remembering to parse — which + * is the kind of thing that is remembered until it is not. + */ +export type OwnerAnswer = + | { readonly outcome: "performed"; readonly value: T } + | { readonly outcome: "refused"; readonly refusal: string }; + +/** + * How a request reads its own success value. + * + * Supplied with the request, because what a performed answer means is the + * command's business rather than the connection's. Raising is how it says the + * owner sent something this build cannot read. + */ +export type AnswerParser = (value: unknown) => T; + +/** One listener, kept so teardown can remove the exact callback it installed. */ +export type SocketListener = (event: { data?: unknown }) => void; + +/** The socket shape this client needs, so a test can supply one. */ +export interface OwnerSocket { + send(data: string): void; + close(): void; + addEventListener(type: "message" | "close" | "error", listener: SocketListener): void; + removeEventListener(type: "message" | "close" | "error", listener: SocketListener): void; +} + +/** One live connection to a run's owner. */ +export interface OwnerConnection { + /** + * Send one command and wait for the answer that names it. + * + * `parse` reads the success value. If it raises, the channel fails closed + * like any other disagreement about what completed — a value neither side + * agrees on is not something to hand a caller and carry on from. + */ + ask( + id: string, + command: Record, + parse: AnswerParser, + parseRefusal?: (refusal: string) => string, + ): Operation>; + /** + * End this connection now, ahead of the scope that owns it. + * + * The same teardown scope exit reaches, and it still runs once: whoever is + * waiting is told the connection closed, the listeners come off, and the + * socket goes. A caller that has given up on an acquisition uses this to + * stop being the run's executor without waiting for its scope to end. + */ + close(): void; +} + +/** The most bytes one answer may carry. */ +const MAX_ANSWER = 8 * 1024 * 1024; + +/** The longest correlation id, in either direction. */ +const MAX_ID = 128; + +/** + * The longest refusal this reads. + * + * Small and its own bound: a refusal is a category, and the eight-megabyte + * envelope bound is for a command's payload rather than for a word. + */ +const MAX_REFUSAL = 200; + +/** Whether a correlation id is one this client will send or accept. */ +function usableId(value: unknown): value is string { + return typeof value === "string" && value !== "" && value.length <= MAX_ID; +} + +/** + * The shape a refusal category has. + * + * The owner answers with a category and an optional detail. This proves + * *spelling* and nothing more — a syntactically valid category this build has + * never heard of still passes here. Narrowing a refusal to the exact declared + * union is the Cloudflare adapter's job, where the union is known; what this + * bound is for is stopping an arbitrary remote sentence from travelling as + * though it were a category at all. + */ +const REFUSAL = /^[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)?$/; + +/** The envelope, before the caller's parser reads the value inside it. */ +type RawAnswer = + | { readonly outcome: "performed"; readonly value: unknown } + | { readonly outcome: "refused"; readonly refusal: string }; + +function readAnswer(raw: unknown): { id: string; answer: RawAnswer } { + if (typeof raw !== "string") { + throw new OwnerLinkError("malformed-answer"); + } + if (raw.length > MAX_ANSWER) { + throw new OwnerLinkError("too-large"); + } + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + throw new OwnerLinkError("malformed-answer"); + } + if (decoded === null || typeof decoded !== "object" || Array.isArray(decoded)) { + throw new OwnerLinkError("malformed-answer"); + } + const members: Map = new Map(Object.entries(decoded)); + const id = members.get("id"); + const outcome = members.get("outcome"); + if (!usableId(id)) { + throw new OwnerLinkError("malformed-answer"); + } + + // Each branch declares its whole key set. A performed answer carrying a + // `refusal`, or a refused one carrying a `value`, is an answer the two sides + // disagree about the shape of — which is the thing this channel refuses to + // carry on past. + const declared = + outcome === "performed" ? ["id", "outcome", "value"] : ["id", "outcome", "refusal"]; + if (members.size !== declared.length) { + throw new OwnerLinkError("malformed-answer"); + } + for (const key of members.keys()) { + if (!declared.includes(key)) { + throw new OwnerLinkError("malformed-answer"); + } + } + + if (outcome === "performed") { + return { id, answer: { outcome, value: members.get("value") } }; + } + if (outcome === "refused") { + const refusal = members.get("refusal"); + if (typeof refusal !== "string" || refusal.length > MAX_REFUSAL || !REFUSAL.test(refusal)) { + throw new OwnerLinkError("malformed-answer"); + } + return { id, answer: { outcome, refusal } }; + } + throw new OwnerLinkError("malformed-answer"); +} + +/** + * Hold one connection open for the calling scope. + * + * The connection *is* the executor acquisition, so the scope that owns it owns + * ending it: there is no lease to expire and no heartbeat to miss, and an owner + * that still sees a healthy socket still considers this runner the executor. A + * scope that walked away without closing would leave the run unadvanceable by + * anybody, forever. + * + * So teardown is one operation with one owner. Scope exit, cancellation, a + * remote close, a socket error, a protocol failure and a failed send all reach + * it, it runs once, and it removes the exact listeners it installed and closes + * the socket. The failure that caused it is what the waiters are told — a close + * arriving afterwards must not rewrite `malformed-answer` into `closed`. + */ +export function useOwnerConnection(socket: OwnerSocket): Operation { + return resource(function* (provide) { + /** + * One waiting request, as the reader sees it. + * + * The command's own type stays inside the closure `ask()` built, so the + * reader settles an answer without naming it and nothing here has to assert + * what a value is. `deliver` settles the request either way and returns the + * failure that made an answer unreadable, so the caller that asked learns + * what was wrong with its own answer rather than only that the channel + * ended. + */ + interface Waiter { + deliver(answer: RawAnswer): Error | undefined; + fail(error: OwnerLinkError): void; + } + const waiting = new Map(); + /** Requests already answered, so a second answer is recognized as one. */ + const settled = new Set(); + let closed = false; + let torn = false; + + /** + * Read one incoming answer and settle the request it names. + * + * Synchronous, and deliberately so. If this queued the message and read it + * later, a close arriving in the same turn would reach teardown first and + * the caller would be told `closed` for an answer that was actually + * unreadable. What went wrong is decided where it is observed. + */ + const onMessage: SocketListener = (event) => { + if (torn) { + return; + } + let read: { id: string; answer: RawAnswer }; + try { + read = readAnswer(event.data); + } catch (error) { + // The owner said something this build cannot read. Whether it was meant + // for a waiter is exactly what cannot be established. + teardown(error instanceof OwnerLinkError ? error.refusal : "malformed-answer"); + return; + } + const pending = waiting.get(read.id); + if (pending === undefined) { + // Either a request nobody made, or a second answer to one already + // settled. Both mean the two sides disagree about what completed. + teardown(settled.has(read.id) ? "duplicate-answer" : "unknown-answer"); + return; + } + waiting.delete(read.id); + settled.add(read.id); + if (pending.deliver(read.answer) !== undefined) { + // The owner performed the command and described the result in a way + // this build cannot read. Handing the caller an unparsed value is the + // one outcome that must not happen — but the caller that asked has + // already been told why, so teardown here is about everyone else. + teardown("malformed-answer"); + } + }; + const onClose: SocketListener = () => teardown("closed"); + const onError: SocketListener = () => teardown("socket-error"); + + /** + * End the connection, once. + * + * `refusal` is what the waiters are told. The first caller decides it: a + * remote close after a malformed answer is the same teardown, and the + * caller waiting on that answer should learn what actually went wrong. + */ + function teardown(refusal: LinkRefusal): void { + if (torn) { + return; + } + torn = true; + closed = true; + for (const pending of waiting.values()) { + pending.fail(new OwnerLinkError(refusal)); + } + waiting.clear(); + socket.removeEventListener("message", onMessage); + socket.removeEventListener("close", onClose); + socket.removeEventListener("error", onError); + try { + socket.close(); + } catch { + // Already closed, or closing threw on the way out. Either way this + // connection is over and there is nothing left to tell anybody. + } + } + + socket.addEventListener("message", onMessage); + socket.addEventListener("close", onClose); + socket.addEventListener("error", onError); + // Registered before anything can suspend, so a cancellation between here + // and `provide()` still closes the socket it just started listening to. + yield* ensure(() => { + teardown("closed"); + }); + + yield* provide({ + close(): void { + teardown("closed"); + }, + *ask( + id: string, + command: Record, + parse: AnswerParser, + parseRefusal: (refusal: string) => string = (refusal) => refusal, + ): Operation> { + if (closed) { + throw new OwnerLinkError("closed"); + } + // The same contract an incoming answer is held to. An id this client + // would refuse to read must never be one it sends. + if (!usableId(id)) { + throw new OwnerLinkError("malformed-request"); + } + if (waiting.has(id) || settled.has(id)) { + throw new OwnerLinkError("duplicate-answer"); + } + // Exactly what would be written, correlation and framing included, and + // measured before this request is registered as outstanding. A request + // too large to carry never becomes one the caller is waiting on. + const raw = JSON.stringify({ ...command, id }); + if (new TextEncoder().encode(raw).length > MAX_MESSAGE_BYTES) { + throw new OwnerLinkError("too-large"); + } + const settle = withResolvers>(); + waiting.set(id, { + deliver(answer: RawAnswer): Error | undefined { + if (answer.outcome === "refused") { + let refusal: string; + try { + refusal = parseRefusal(answer.refusal); + } catch (error) { + settle.reject(unreadable(error)); + return unreadable(error); + } + settle.resolve({ outcome: "refused", refusal }); + return undefined; + } + let value: T; + try { + value = parse(answer.value); + } catch (error) { + // The request that asked learns why its own answer could not be + // read. Whoever else is waiting learns the channel ended, which + // is all that is true for them. + settle.reject(unreadable(error)); + return unreadable(error); + } + settle.resolve({ outcome: "performed", value }); + return undefined; + }, + fail(error: OwnerLinkError): void { + settle.reject(error); + }, + }); + try { + socket.send(raw); + } catch { + // The socket refused the write. This request never left, and the + // connection cannot be trusted to carry the next one either. + teardown("send-failed"); + throw new OwnerLinkError("send-failed"); + } + return yield* settle.operation; + }, + }); + }); +} diff --git a/packages/workflow/src/remote/collector.ts b/packages/workflow/src/remote/collector.ts new file mode 100644 index 000000000..c4bea4db2 --- /dev/null +++ b/packages/workflow/src/remote/collector.ts @@ -0,0 +1,445 @@ +/** + * `transact()` for a run whose storage is somewhere else. + * + * A Durable Object commits synchronously and cannot hold a transaction open + * across a network wait, so the obvious reading — open a remote transaction, + * run the caller's body, commit — is not available. What is available is that + * the body does not need the transaction to be open while it runs. It needs to + * read the starting history, it needs its writes to go somewhere, and it needs + * all of them to land together or not at all. + * + * So the callback runs here, in a runner-owned scope, against a collector. The + * starting frontier is read once through an ordinary bounded request that opens + * and closes its own read on the owner. Journal appends go into a local buffer + * that `readAll()` reads back after the starting prefix, so the body sees its + * own writes. Nothing is sent while the body is running. When the body and + * everything it started have torn down successfully, one closed intent goes to + * the owner, which revalidates and applies it inside its one transaction. + * + * The callback is never serialized, interpreted, or run inside the owner. It is + * ordinary code doing ordinary work; only what it *enlisted* crosses the + * connection. That is what makes arbitrary control flow safe here — nothing + * tries to infer what the body did. + */ + +import { call, ensure, Ok, type Operation, type Result, scoped } from "effection"; +import type { CommitDecision, RetainedMapping, WorkspacePublication } from "./publication.ts"; +import { SEAL, type SealableAttempt } from "./seal.ts"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import type { DurableStream } from "@executablemd/durable-streams"; +import type { WorkflowRunTransaction } from "../storage/api.ts"; + +/** Why a transaction could not be run or committed. */ +export type CollectorRefusal = + | "nested-transaction" + | "publication-already-enlisted" + | "answer-already-enlisted" + | "too-many-mappings" + | "transaction-closed" + | "operation-inside-body" + | "too-many-events" + | "events-too-large" + | "malformed-event"; + +export class RemoteTransactionError extends Error { + override name = "RemoteTransactionError"; + + constructor(readonly refusal: CollectorRefusal) { + super(`this remote transaction cannot proceed (${refusal})`); + } +} + +/** The starting state a transaction is proposed against. */ +export interface StartingFrontier { + readonly workspaceRootId: string; + readonly journalEventId: string | null; + readonly events: readonly DurableEvent[]; +} + +/** + * One closed intent, as the owner will receive it. + * + * Everything the transaction decided, and nothing it did not. `publication` is + * absent for a transaction that only appended to the journal — a real case, and + * inventing a Workspace change to make the shape uniform would publish a root + * nobody asked for. + */ +export interface CommitIntent { + readonly expectedWorkspaceRootId: string; + readonly expectedJournalEventId: string | null; + readonly events: readonly DurableEvent[]; + readonly publication: WorkspacePublication | null; + readonly mappings: readonly RetainedMapping[]; + /** The sealed bytes for the pieces this proposal may have to supply. */ + readonly bytes: ReadonlyMap; + /** + * The retained answer this transaction is spending, when it is spending one. + * + * `null` for every ordinary transaction, which is nearly all of them. It is + * here rather than beside the commit because consuming a delivered answer and + * publishing the event that answers the wait are one act: an answer consumed + * without its event is lost, and an event published without the consumption + * could be delivered twice. + * + * It names the wait and the request it answers and carries no value. The + * owner holds the value already; a value travelling here would be the runner + * saying what it is owed rather than spending what it was given. + */ + readonly answer: AnswerConsumption | null; +} + +/** Which retained answer one transaction spends. */ +export interface AnswerConsumption { + readonly suspensionId: string; + readonly requestEventId: string; + readonly requestFingerprint: string; +} + +/** What the collector needs from the connection. */ +export interface OwnerLink { + /** One bounded read that opens and closes its own owner-side read. */ + frontier(): Operation; + /** One closed intent, applied atomically or not at all. */ + commit(intent: CommitIntent): Operation>; +} + +/** The most events one intent may carry. */ +const MAX_EVENTS = 4096; + +/** The most serialized bytes one intent may carry. */ +const MAX_EVENT_BYTES = 4 * 1024 * 1024; + +/** The most retained mapping changes one intent may carry. */ +const MAX_MAPPINGS = 256; + +/** + * Admit one event and detach it from whoever handed it over. + * + * Cloning on the way in is not enough on its own: a caller that reads an event + * back and mutates what it received would otherwise change what this + * transaction commits. So every crossing — in, out, and into the intent — is a + * fresh copy, and the collector's own array is never handed to anybody. + */ +function admitEvent(event: DurableEvent): DurableEvent { + if (event === null || typeof event !== "object") { + throw new RemoteTransactionError("malformed-event"); + } + if (!("type" in event) || typeof event.type !== "string") { + throw new RemoteTransactionError("malformed-event"); + } + try { + return structuredClone(event); + } catch { + // A value that cannot be cloned cannot be sent either. + throw new RemoteTransactionError("malformed-event"); + } +} + +/** The serialized size of what has been collected so far. */ +function serializedBytes(events: readonly DurableEvent[]): number { + return new TextEncoder().encode(JSON.stringify(events)).length; +} + +/** + * Whether a transaction is open on this handle. + * + * Scope-local rather than global: two runs may transact at once, and what must + * not happen is a second transaction — or an ordinary operation — on the *same* + * handle from inside a body. That is the same refusal the local provider makes, + * and for the same reason: work that never received the transaction handle + * would otherwise commit on its own, outside the unit of work it appears to be + * part of. + */ +export interface TransactionGate { + open: boolean; +} + +export function createTransactionGate(): TransactionGate { + return { open: false }; +} + +/** Refuse an ordinary same-handle operation while a body is running. */ +export function requireNoOpenTransaction(gate: TransactionGate): void { + if (gate.open) { + throw new RemoteTransactionError("operation-inside-body"); + } +} + +/** + * Run `body` against a collector, then submit what it enlisted. + * + * The body may compute, suspend and perform runner-owned effects. None of that + * is reduced to an intent and none of it executes on the owner; only mutations + * made through the transaction handle enter the collector. + */ +export function transactRemotely( + link: OwnerLink, + gate: TransactionGate, + body: ( + transaction: WorkflowRunTransaction, + enlist: EnlistWorkspace, + anchor: TransactionAnchor, + consume: EnlistAnswer, + enlistMappings: EnlistMappings, + ) => Operation, +): Operation> { + return call(function* (): Operation> { + // Taken synchronously, before the first suspension. Checking and then + // suspending in `frontier()` would let two calls on one handle both pass + // the check and act from the same starting frontier. + if (gate.open) { + throw new RemoteTransactionError("nested-transaction"); + } + gate.open = true; + // Released once, and only after nothing from this transaction can still + // affect the handle — which is after the commit answer, not after the body. + // Between those two the outcome is undecided, and later work must not run + // as though it had been decided. + try { + return yield* run(); + } finally { + gate.open = false; + } + + function* run(): Operation> { + const starting = yield* link.frontier(); + const appended: DurableEvent[] = []; + let live = true; + + const journal: DurableStream = { + *readAll(): Operation { + if (!live) { + throw new RemoteTransactionError("transaction-closed"); + } + // Read-your-writes, as fresh copies. The starting prefix then this + // transaction's own appends, in order. + return [...starting.events, ...appended].map((event) => structuredClone(event)); + }, + *append(event: DurableEvent): Operation { + if (!live) { + throw new RemoteTransactionError("transaction-closed"); + } + if (appended.length >= MAX_EVENTS) { + throw new RemoteTransactionError("too-many-events"); + } + const admitted = admitEvent(event); + if (serializedBytes([...appended, admitted]) > MAX_EVENT_BYTES) { + throw new RemoteTransactionError("events-too-large"); + } + appended.push(admitted); + }, + }; + + let enlisted: { attempt: SealableAttempt; mappings: readonly RetainedMapping[] } | undefined; + /** + * How a Workspace operation puts its result into this transaction. + * + * Private: it is handed to the body rather than reachable from the + * database, so work that never received it cannot publish a Workspace by + * accident. Detached on the way in, because the caller still holds the + * arrays and records it passed and a proposal that changed after it was + * admitted would not be the proposal the identity was computed over. + */ + const enlist: EnlistWorkspace = ( + attempt: SealableAttempt, + mappings: readonly RetainedMapping[] = [], + ): void => { + if (!live) { + throw new RemoteTransactionError("transaction-closed"); + } + if (enlisted !== undefined || stagedMappings !== undefined) { + throw new RemoteTransactionError("publication-already-enlisted"); + } + if (mappings.length > MAX_MAPPINGS) { + throw new RemoteTransactionError("too-many-mappings"); + } + // The mappings are detached now, because they are the caller's values. + // The Workspace itself is not read until sealing. + enlisted = { attempt, mappings: Object.freeze(mappings.map(detachMapping)) }; + }; + + let stagedMappings: readonly RetainedMapping[] | undefined; + /** + * How a transaction retains mappings without proposing a Workspace. + * + * An Agent-session mapping is a fact about a conversation rather than + * about bytes: the run's Workspace does not move, and nothing is + * published. It is here rather than as an option on `enlist` because the + * two are different proposals — one carries a root and one does not — + * and one transaction makes at most one of them. + */ + const enlistMappings: EnlistMappings = (mappings: readonly RetainedMapping[]): void => { + if (!live) { + throw new RemoteTransactionError("transaction-closed"); + } + if (enlisted !== undefined || stagedMappings !== undefined) { + throw new RemoteTransactionError("publication-already-enlisted"); + } + if (mappings.length > MAX_MAPPINGS) { + throw new RemoteTransactionError("too-many-mappings"); + } + stagedMappings = Object.freeze(mappings.map(detachMapping)); + }; + + let consumption: AnswerConsumption | undefined; + /** + * How an answer claim spends the retained value inside this transaction. + * + * Private for the same reason `enlist` is: it is handed to the body, so + * work that never received it cannot spend an answer, and one transaction + * spends at most one — a second would be a second wait ending inside a + * unit of work that describes one. + */ + const consume: EnlistAnswer = (offered: AnswerConsumption): void => { + if (!live) { + throw new RemoteTransactionError("transaction-closed"); + } + if (consumption !== undefined) { + throw new RemoteTransactionError("answer-already-enlisted"); + } + consumption = Object.freeze({ + suspensionId: offered.suspensionId, + requestEventId: offered.requestEventId, + requestFingerprint: offered.requestFingerprint, + }); + }; + + let outcome: T; + try { + // A scope of its own, closed here. Everything the body started — + // spawned children, resources — has finished tearing down before the + // intent is built, so "no commit was sent" and "the body did not + // finish" are one statement. `call()` alone would let a resource whose + // teardown fails surface its failure after the commit had already gone + // out, which is the one ordering that cannot be taken back. + outcome = yield* scoped(() => + body( + { journal }, + enlist, + { + workspaceRootId: starting.workspaceRootId, + journalEventId: starting.journalEventId, + }, + consume, + enlistMappings, + ), + ); + } finally { + // The handle is closed before the commit goes out, so a retained + // transaction object refuses while the handle-level gate is still held. + live = false; + } + + // Sealed after teardown: the proposal is the tree as it finally is. + const sealed = + enlisted === undefined ? undefined : yield* enlisted.attempt[SEAL](enlisted.mappings); + + const committed = yield* link.commit({ + expectedWorkspaceRootId: starting.workspaceRootId, + expectedJournalEventId: starting.journalEventId, + // A private snapshot. The collector's own array never leaves. + events: appended.map((event) => structuredClone(event)), + publication: sealed?.publication ?? null, + mappings: sealed?.mappings ?? stagedMappings ?? [], + bytes: sealed?.bytes ?? new Map(), + answer: consumption ?? null, + }); + if (!committed.ok) { + return committed; + } + // The owner performed this exact proposal, so the attempt that produced + // it becomes the accepted Workspace — here, inside the operation that + // received and validated the answer, rather than by handing the answer + // to a caller and trusting the sequence. + if (sealed !== undefined) { + yield* sealed.transfer(committed.value); + } + // Only now. `T` is the body's own value and never crossed the connection. + return Ok(outcome); + } + }); +} + +/** + * Exactly where this transaction began, and nothing else about it. + * + * A coordinator has to prove that the state it admitted its invocation from is + * the state this transaction will commit against. It needs the two anchors for + * that and no more — the journal prefix is the body's to read through the + * transaction, not something a route hands out. + */ +export interface TransactionAnchor { + readonly workspaceRootId: string; + readonly journalEventId: string | null; +} + +/** + * How a Workspace operation designates its attempt for publication. + * + * It names an attempt rather than handing over a proposal. What the attempt + * holds is captured when the transaction seals it — after the body and + * everything it started have finished — so the proposal always describes the + * tree as it finally is, and the tree the owner decides is the tree that gets + * transferred. There is no way to enlist a Workspace that no live attempt owns, + * which is what stops a durable commit from leaving the invocation behind. + */ +export type EnlistWorkspace = ( + attempt: SealableAttempt, + mappings?: readonly RetainedMapping[], +) => void; + +/** How a transaction retains mappings with no Workspace proposal at all. */ +export type EnlistMappings = (mappings: readonly RetainedMapping[]) => void; + +/** + * How an answer claim designates the retained value this transaction spends. + * + * It names the retained row rather than handing over a value, because what is + * being asked for is a consumption the owner performs: the owner reads what it + * retained, checks the event this transaction is appending against it, and + * marks the row spent in the same transaction that appends the event. + */ +export type EnlistAnswer = (consumption: AnswerConsumption) => void; + +/** + * A copy nobody else holds a reference into. + * + * The caller keeps whatever it passed, and may go on using it. What the intent + * carries has to be what was admitted at the moment it was admitted — a + * publication whose inventory or manifest changed afterwards would not be the + * one its identity was computed over. + */ +/** + * One mapping, copied all the way down. + * + * A shallow copy is not enough: an Agent-session record holds its provider + * assertion as a nested object, and that assertion is part of the retained + * identity. Leaving it shared would let a caller change what the run recorded + * about a session after the transaction had sealed. + */ +function detachMapping(mapping: RetainedMapping): RetainedMapping { + if (mapping.kind === "repository") { + return Object.freeze({ + kind: mapping.kind, + locator: mapping.locator, + record: Object.freeze({ ...mapping.record }), + }); + } + if (mapping.kind === "worktree") { + return Object.freeze({ kind: mapping.kind, record: Object.freeze({ ...mapping.record }) }); + } + return Object.freeze({ + kind: mapping.kind, + record: Object.freeze({ + ...mapping.record, + assertion: Object.freeze({ ...mapping.record.assertion }), + }), + }); +} + +/** Discard a collector's work without sending it. */ +export function abandon(gate: TransactionGate): Operation { + return ensure(() => { + gate.open = false; + }); +} diff --git a/packages/workflow/src/remote/database.ts b/packages/workflow/src/remote/database.ts new file mode 100644 index 000000000..c933cedaa --- /dev/null +++ b/packages/workflow/src/remote/database.ts @@ -0,0 +1,545 @@ +/** + * One run's storage, when the run is owned somewhere else. + * + * The same handle the local host hands out, backed by a connection instead of a + * file. Everything the interface promises has to be true here for the same + * reasons it is true there — a snapshot is a snapshot, a transaction commits or + * it does not, and a closed handle is closed — and the differences are all + * beneath it: there is no connection to hold open across a callback, so the + * body runs on the runner and only what it enlisted crosses. + * + * Two mechanisms keep operations in order and they solve different problems. + * A *turn* serializes work so two operations do not interleave on one handle; + * unrelated work waits and then proceeds. A *marker* records that this scope is + * inside a transaction on this handle, so a nested transaction — or an ordinary + * operation called from inside the body — is refused immediately rather than + * waiting for a turn its own caller is holding and will not release. A queue + * alone would deadlock that case; a flag alone would mistake unrelated work for + * nested work. + * + * The handle is a lease. Closing it ends this handle and nothing else: the + * connection may be owned by an outer scope and shared with other handles, and + * a lease that closed it would end a run somebody else was still reading. + */ + +import { + createContext, + createSignal, + ensure, + Err, + Ok, + type Context, + type Operation, + type Result, + resource, +} from "effection"; +import { + establishJournalProvenance, + type DurableEvent, + type DurableStream, + type JournalProvenance, + type Json, +} from "@executablemd/durable-streams"; +import type { JournalEntry, WorkflowRunDatabase, WorkflowRunTransaction } from "../storage/api.ts"; +import { + WorkflowDatabaseClosedError, + WorkflowRecordMalformedError, + WorkflowRequestError, + WorkflowStorageError, + WorkflowTransactionError, +} from "../storage/errors.ts"; +import { parseJsonValue } from "../storage/members.ts"; +import type { + DefinitionRetrieval, + DocumentExecutionRecord, + WorkflowRunRecord, +} from "../storage/record.ts"; +import { createTransactionGate, type OwnerLink, transactRemotely } from "./collector.ts"; +import type { + EnlistAnswer, + EnlistMappings, + EnlistWorkspace, + TransactionAnchor, +} from "./collector.ts"; +import type { RemoteContent, RemoteContentRequest, RemoteFrontierSnapshot } from "./read.ts"; +import type { RemoteRetainedAnswer } from "./answer-link.ts"; +import type { RemoteInvocationSnapshot } from "./records.ts"; +import type { CreateWorkflowRunRequest } from "../storage/api.ts"; +import type { WorkspaceRootManifest } from "../workspace/root-manifest.ts"; +import { routeRemoteRunJournal } from "./journal-route.ts"; + +/** What a remote handle needs to answer everything the interface asks. */ +export interface RemoteRunLink extends OwnerLink { + /** A fresh coherent frontier, for a read that must not use a snapshot. */ + frontierSnapshot(): Operation; + /** + * What this run retains for one wait, if it retains anything. + * + * On the acquisition's own authority, because it is read to be spent: an + * execution asks what it may publish, only the executor may publish, and the + * owner requires that acquisition to hold an open execution before it says + * anything. The request event is named because a wait's identifier is + * derivable and the event it was published as is not. + */ + pendingAnswer( + suspensionId: string, + requestEventId: string, + ): Operation>; + /** Replace or clear the retrieval metadata, and answer with the result. */ + replaceRetrieval( + expectedWorkspaceRootId: string, + metadata: string | null, + ): Operation>; + /** Every document execution, as one anchored snapshot. */ + readExecutions(): Operation>; +} + +/** + * Everything one remote run is reached through, as one value. + * + * The Workspace reads and the commits are the same authority, so they are the + * same object. Carried as two — a link and a read link a caller supplies + * separately — they can be taken from two owners: an invocation would then + * execute against one run's retained mappings and content and commit the + * result to another, and if the two began at the same root and anchor nothing + * downstream could notice. There is no such pair to make. + */ +export interface RemoteWorkspaceLink extends RemoteRunLink { + /** + * Find this run on its owner, or create it exactly once. + * + * On the link rather than beside it. An opener supplied separately could + * have been admitted for another owner, and a create authorized by one owner + * would then return a handle that reads and commits through the other. + * Opening and operating are the same authority, so they are the same object. + */ + open( + runId: string, + creation: CreateWorkflowRunRequest | null, + ): Operation>; + /** The one coherent admitted state a Workspace invocation begins from. */ + invocationSnapshot(): Operation; + root(workspaceRootId: string): Operation; + content(workspaceRootId: string, request: RemoteContentRequest): Operation; +} + +/** + * Which handles this scope is inside a transaction on. + * + * Structural and inert, exactly like the local provider's: it can only ever + * cause an operation to be refused, never authorize one. A chain rather than a + * single handle, because transactions on *different* runs may nest and + * recording only the innermost would hide the outer one. + */ +interface OpenTransaction { + readonly handle: object; + readonly enclosing: OpenTransaction | undefined; +} + +const ActiveTransaction: Context = createContext< + OpenTransaction | undefined +>("executablemd.workflow.remote.transaction", undefined); + +function* holdsTransactionOn(handle: object): Operation { + let active = yield* ActiveTransaction.get(); + while (active !== undefined) { + if (active.handle === handle) { + return true; + } + active = active.enclosing; + } + return false; +} + +/** + * The route a Workspace coordinator reaches the active transaction through. + * + * Bound to one exact handle and one exact transaction object, and live only + * inside that transaction body's descendant scope. D3c installs a coordinator + * over it; nothing about a document execution or its provenance is decided + * here, and no placeholder for either is invented. + */ +export interface WorkspaceRoute { + readonly database: WorkflowRunDatabase; + readonly transaction: WorkflowRunTransaction; + readonly enlist: EnlistWorkspace; + /** How this transaction retains mappings with no Workspace proposal. */ + readonly enlistMappings: EnlistMappings; + /** Where this transaction began, so a coordinator can prove it has not drifted. */ + readonly anchor: TransactionAnchor; + /** + * How this transaction spends a retained answer. + * + * On the same route, and reachable on the same terms: an answer claim that + * cannot prove it holds this exact database and this exact live transaction + * cannot spend anything, which is what keeps a wait from being ended outside + * the transaction that publishes its answer. + */ + readonly consume: EnlistAnswer; +} + +const ActiveRoute: Context = createContext( + "executablemd.workflow.remote.workspace-route", + undefined, +); + +/** + * The enlistment route for this exact database and transaction, if it is live. + * + * Answers nothing for a foreign database, a substituted or stale transaction + * object, or a scope outside the body — which is the whole point: a coordinator + * that has drifted from the transaction it belongs to must not be able to + * publish into it. + */ +export function* activeWorkspaceRoute( + database: WorkflowRunDatabase, + transaction: WorkflowRunTransaction, +): Operation { + const route = yield* ActiveRoute.get(); + if (route === undefined || route.database !== database || route.transaction !== transaction) { + return undefined; + } + return route; +} + +/** + * A failure this interface can return, whatever it arrived as. + * + * The adapter beneath has already translated what it knows about; anything else + * reaching here is the body's own error, which is carried as it is. A value + * that is not an error at all becomes one rather than travelling as a thrown + * string nobody can act on. + */ +function failure(error: unknown): Error { + return error instanceof Error ? error : new WorkflowTransactionError(String(error)); +} + +/** + * What a `DurableStream` member does with a result. + * + * The interface splits these deliberately: a member returning `Result` answers + * with the failure, and a stream member raises it. Both describe the same + * condition. + */ +function* raising(result: Result): Operation { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +/** One handle's cooperative turn, so two operations never interleave on it. */ +interface Turns { + take(body: () => Operation): Operation; +} + +function createTurns(): Turns { + const waiting = createSignal(); + const holder = { held: false }; + return { + *take(body: () => Operation): Operation { + while (holder.held) { + // Someone else has the handle. Wait to be told it is free rather than + // polling, and check again, because several may be waiting and only one + // of them can take the turn that was just released. + const released = yield* waiting; + yield* released.next(); + } + holder.held = true; + try { + return yield* body(); + } finally { + holder.held = false; + waiting.send(); + } + }, + }; +} + +/** What one handle was opened from, for a host that has to prove it was. */ +export interface RemoteRunOrigin { + /** The exact link this handle reads, writes and commits through. */ + readonly link: RemoteRunLink; + /** The provenance taken over this handle's routed journal. */ + readonly provenance: JournalProvenance; +} + +/** + * What each handle was opened from. + * + * Held beside the handle rather than on it: `WorkflowRunDatabase` is the same + * interface both hosts implement, and a link or a witness on it would be a + * capability every caller of either could reach. A `WeakMap` keyed by the exact + * handle answers only for a handle this module built, and a second loaded copy + * cannot answer for one of these at all. + * + * What a host does with the answer is compare it — by object identity, to the + * connection it holds — so a handle from another client, or a look-alike with + * the same run id and root, is refused before an effect exists. + */ +const origins = (() => { + const held = new WeakMap(); + return { + remember(database: WorkflowRunDatabase, origin: RemoteRunOrigin): void { + held.set(database, origin); + }, + of(database: WorkflowRunDatabase): RemoteRunOrigin | undefined { + return held.get(database); + }, + }; +})(); + +/** What this handle was opened from, if this module opened it. */ +export function remoteRunOrigin(database: WorkflowRunDatabase): RemoteRunOrigin | undefined { + return origins.of(database); +} + +/** Open one scope-owned lease on a run whose storage is somewhere else. */ +export function useRemoteRunDatabase( + link: RemoteRunLink, + frontier: RemoteFrontierSnapshot, +): Operation { + return resource(function* (provide) { + let closed = false; + let record: WorkflowRunRecord = frontier.record; + let retrieval: DefinitionRetrieval | undefined = frontier.retrieval; + const turns = createTurns(); + const gate = createTransactionGate(); + + /** Whether this scope may reach the handle at all, and why not. */ + function* admit(): Operation> { + if (closed) { + return Err(new WorkflowDatabaseClosedError(record.runId)); + } + if (yield* holdsTransactionOn(handle)) { + return Err( + new WorkflowTransactionError( + "this scope is inside a transaction on the same workflow run database, and an " + + "operation outside that transaction cannot run until it commits. Use the " + + "transaction handed to the body, or move the operation outside it.", + ), + ); + } + return Ok(); + } + + /** + * One turn at the handle, for an ordinary operation. + * + * A member that returns `Result` answers with the failure rather than + * raising it, so a link that raised is caught here. Cancellation is not a + * failure and is left to unwind as control flow. + */ + function* turn(body: () => Operation>): Operation> { + const admitted = yield* admit(); + if (!admitted.ok) { + return admitted; + } + return yield* turns.take(function* (): Operation> { + try { + return yield* body(); + } catch (error) { + return Err(failure(error)); + } + }); + } + + const ordinary: DurableStream = { + *readAll(): Operation { + return yield* raising( + yield* turn(function* () { + const snapshot = yield* link.frontierSnapshot(); + return Ok(snapshot.entries.map((entry) => structuredClone(entry.event))); + }), + ); + }, + + *append(event: DurableEvent): Operation { + // One journal-only transaction through the same commit path a caller's + // transaction uses. A second insertion route would be a second thing to + // keep in agreement with the first. + yield* raising( + yield* transact(function* (transaction) { + yield* transaction.journal.append(event); + }), + ); + }, + }; + + function* transact( + body: (transaction: WorkflowRunTransaction) => Operation, + ): Operation> { + if (closed) { + return Err(new WorkflowDatabaseClosedError(record.runId)); + } + if (yield* holdsTransactionOn(handle)) { + return Err( + new WorkflowTransactionError( + "a transaction on this workflow run database is already open in this scope. " + + "Nesting one inside another would commit or roll back work the outer " + + "transaction has not finished deciding about.", + ), + ); + } + return yield* turns.take(function* (): Operation> { + try { + return yield* transactRemotely( + link, + gate, + function* (transaction, enlist, anchor, consume, enlistMappings) { + // The marker and the route are installed for the body's scope + // alone. Outside it neither exists, so a retained transaction + // object reaches nothing and an unrelated scope is not mistaken for + // a nested one. + yield* ActiveTransaction.set({ + handle, + enclosing: yield* ActiveTransaction.get(), + }); + yield* ActiveRoute.set({ + database: handle, + transaction, + enlist, + enlistMappings, + anchor, + consume, + }); + return yield* body(transaction); + }, + ); + } catch (error) { + // A body that raised, or a resource of its that failed to tear down, + // is a failed transaction rather than a raised one: the interface + // answers with a `Result`, and nothing was committed. + return Err(failure(error)); + } + }); + } + + const handle: WorkflowRunDatabase = { + get record(): WorkflowRunRecord { + return record; + }, + + get retrieval(): DefinitionRetrieval | undefined { + return retrieval; + }, + + get journal(): DurableStream { + return routed; + }, + + transact, + + *readJournalEntries(): Operation> { + return yield* turn(function* () { + const snapshot = yield* link.frontierSnapshot(); + return Ok(snapshot.entries.map((entry) => Object.freeze({ ...entry }))); + }); + }, + + *replaceRetrievalMetadata(metadata: Json | undefined): Operation> { + let encoded: string | null; + try { + // Parsed by the same rules a stored value is held to, then encoded + // canonically. A value that is not JSON at all never becomes a + // request: refusing it here is what "no request" means. + encoded = + metadata === undefined + ? null + : canonical(parseJsonValue(metadata, "$", retrievalFailure)); + } catch (error) { + return Err(failure(error)); + } + const replaced = yield* turn(function* () { + const snapshot = yield* link.frontierSnapshot(); + return yield* link.replaceRetrieval(snapshot.workspaceRootId, encoded); + }); + if (!replaced.ok) { + return replaced; + } + // The answer has to describe the replacement that was asked for. An + // owner that returned different metadata would otherwise install the + // location a later fetch of the definition would use. + const answered = replaced.value; + if (encoded === null) { + if (answered !== undefined) { + return Err(contradiction()); + } + } else if (answered === undefined || canonical(answered.metadata) !== encoded) { + return Err(contradiction()); + } + // Only this handle, and only after its own successful replacement. The + // owner's revision and time are what is recorded; nothing is invented + // here. + retrieval = answered; + return Ok(); + }, + + *readDocumentExecutions(): Operation> { + return yield* turn(() => link.readExecutions()); + }, + }; + + // The same shape the local handle has: what a caller runs its document on + // is the routed journal, so a Workspace effect's publication lands inside + // the transaction that made the change rather than beside it, and the + // provenance a coordinator compares is taken over that exact stream. Built + // here because here is where the handle exists — a caller that assembled + // the pair itself could pair one run's journal with another's storage. + const routed: DurableStream = routeRemoteRunJournal(handle, ordinary); + origins.remember( + handle, + Object.freeze({ link, provenance: establishJournalProvenance(routed) }), + ); + + yield* ensure(() => { + closed = true; + }); + yield* provide(handle); + }); +} + +/** How a malformed retrieval value is reported, before anything is sent. */ +function retrievalFailure(reason: string, path: string): Error { + return new WorkflowRequestError( + `this retrieval metadata is not a JSON value storage can keep at ${path}: ${reason}.`, + ); +} + +/** An answer that does not describe the replacement it answered. */ +function contradiction(): WorkflowStorageError { + return new WorkflowRecordMalformedError( + "retrieval this run's owner returned", + "it does not describe the replacement that was asked for", + ); +} + +/** + * The canonical encoding of one retrieval metadata value. + * + * Sorted keys and no incidental whitespace, so two callers writing the same + * metadata write the same bytes and a comparison of what is stored means what + * it appears to mean. + */ +function canonical(value: Json): string { + return JSON.stringify(sorted(value)); +} + +function sorted(value: Json): Json { + if (Array.isArray(value)) { + return value.map(sorted); + } + if (value === null || typeof value !== "object") { + return value; + } + const members: Record = {}; + const names = Object.keys(value); + names.sort(); + for (const key of names) { + const held = (value as Record)[key]; + if (held !== undefined) { + members[key] = sorted(held); + } + } + return members; +} diff --git a/packages/workflow/src/remote/delivery.ts b/packages/workflow/src/remote/delivery.ts new file mode 100644 index 000000000..664ece350 --- /dev/null +++ b/packages/workflow/src/remote/delivery.ts @@ -0,0 +1,304 @@ +/** + * Answering a durable wait on a run whose owner is somewhere else. + * + * Delivery is not execution, and being remote does not change that: nothing + * here takes an acquisition, opens a socket, begins an execution, appends a + * journal event or moves a run's status. What it does is retain one typed value + * against the exact wait the run is standing at, so the next execution that + * reaches that wait finds it. + * + * ## What is judged here, and what the owner judges anyway + * + * The owner is the authority: it resolves the wait, judges the value against + * the schema that wait retained, and applies the selected credential gate, + * inside the transaction that writes. Nothing this module reports is taken on + * trust there, and no member of the retention says a value was checked. + * + * What happens here is the document runtime's half, and it happens first + * because it is better at it. The schema compiler `` uses gives a + * document-shaped diagnostic naming where a value went wrong; the full secret + * scanner catches far more than the owner's floor. A value refused here never + * reaches the owner, and a value the owner refuses was refused for a reason + * this had no way to see. + * + * Both judgments must agree before anything is sent. The shared judgment is the + * one the owner will run, so running it here as well turns a disagreement + * between the two into a refusal on this side rather than a surprise on the + * other. + */ + +import { Err, Ok, type Operation, type Result } from "effection"; +import { + createSecretScanner, + type Json, + prepareElicitation, + SecretDetectedError, + type SecretFinding, +} from "@executablemd/core"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { SUSPENSION_ANSWER } from "../suspension/effects.ts"; +import { + parseSuspensionRequest, + suspensionRequestFingerprint, + type WorkflowSuspensionRequest, +} from "../suspension/api.ts"; +import type { RemoteDeliveryLink, RemoteRetainedWaitRecord } from "./answer-link.ts"; +import { + parseAnswerDelivery, + type WorkflowAnswerDelivery, + WorkflowAnswerDeliveryError, + type WorkflowAnswerRetention, + WorkflowInputDelivery, +} from "../suspension/delivery.ts"; +import { canonicalJson } from "../storage/record.ts"; + +/** + * The wait one run is standing at, once its retained description is walked. + * + * Three identities travel together because a value answers all three: the wait, + * the exact journal event its request was published as, and a fingerprint of + * that request with its response schema. The fingerprint is what the retention + * is held to — a run whose retained request changed is a run this value was + * judged for and is no longer an answer to. + */ +export interface RemoteRetainedWait { + readonly runId: string; + readonly suspensionId: string; + readonly requestEventId: string; + readonly request: WorkflowSuspensionRequest; + readonly requestFingerprint: string; +} + +/** + * Install typed answer delivery over one remote link, for the current scope. + * + * `{ at: "min" }` for the reason every provider here uses it: middleware at the + * default position runs outermost, so an enclosing scope's installation would + * be selected ahead of the one installed nearer the run. + */ +export function* installRemoteInputDelivery(link: RemoteDeliveryLink): Operation { + yield* WorkflowInputDelivery.around( + { + *deliver([request]) { + return yield* deliverRemotely(link, request); + }, + }, + { at: "min" }, + ); +} + +function* deliverRemotely( + link: RemoteDeliveryLink, + request: WorkflowAnswerDelivery, +): Operation> { + const checked = parseAnswerDelivery(request); + if (!checked.ok) { + return checked; + } + const { runId, suspensionId, value, secretDetection } = checked.value; + + const answered = yield* link.wait(runId, suspensionId); + if (!answered.ok) { + return answered; + } + const waiting = walkRetainedWait(answered.value, runId, suspensionId); + if (!waiting.ok) { + return waiting; + } + + const judged = yield* judgeAnswer(waiting.value, suspensionId, value); + if (!judged.ok) { + return judged; + } + + if (secretDetection) { + const scanned = yield* scanDelivery(waiting.value, suspensionId, value); + if (!scanned.ok) { + return scanned; + } + } + + return yield* link.retain({ + runId, + suspensionId, + answer: value, + // The choice travels; the judgment does not. The owner applies the gate + // this names, and a value that reached here without the choice being made + // could not have been offered at all. + secretDetection, + }); +} + +/** + * The wait this owner described, as a request rather than as a claim about one. + * + * The retained description is journal data reached through a public durable + * operation, so a schema nothing could validate against must not become the + * schema a value is judged by. The fingerprint is recomputed rather than + * believed: an owner that names one and retains another would have this value + * judged against a request it will not hold the retention to. + */ +function walkRetainedWait( + record: RemoteRetainedWaitRecord, + runId: string, + suspensionId: string, +): Result { + // The owner answered about a wait, and this is the one that was asked about. + // An answer describing something else is an owner disagreeing with the + // question, not a wait to judge a value against. + if (record.runId !== runId || record.suspensionId !== suspensionId) { + return Err( + new WorkflowAnswerDeliveryError( + "this run's owner answered about a different wait, so the value was not judged.", + ), + ); + } + let request: WorkflowSuspensionRequest; + try { + request = parseSuspensionRequest({ + request: record.request, + responseSchema: record.responseSchema, + }); + } catch (error) { + return Err( + new WorkflowAnswerDeliveryError( + `the request retained for ${suspensionId} is not one a durable wait can be answered ` + + `for: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + } + if (suspensionRequestFingerprint(request) !== record.requestFingerprint) { + return Err( + new WorkflowAnswerDeliveryError( + `this run's owner names a different request for ${suspensionId} than the one it ` + + "returned, so the value was not judged.", + ), + ); + } + return Ok({ + runId: record.runId, + suspensionId: record.suspensionId, + requestEventId: record.requestEventId, + request, + requestFingerprint: record.requestFingerprint, + }); +} + +/** + * The value, judged by the schema the wait retained. + * + * The same compilation `` uses, so what a document may receive here is + * exactly what it may receive there. The refusal names where the value went + * wrong and never what it held: a diagnostic that quoted a rejected value would + * publish it in a place nothing filters. + */ +function* judgeAnswer( + waiting: RemoteRetainedWait, + suspensionId: string, + value: Json, +): Operation> { + let issues; + try { + const prepared = yield* prepareElicitation(waiting.request.responseSchema, "workflow answer"); + issues = prepared.validator.judge(value); + } catch (error) { + return Err( + new WorkflowAnswerDeliveryError( + `the response schema retained for ${suspensionId} cannot judge an answer: ` + + (error instanceof Error ? error.message : String(error)), + ), + ); + } + if (issues.length === 0) { + return Ok(); + } + const described = issues + .map( + (issue) => `${issue.instancePath === "" ? "the value" : issue.instancePath} ${issue.message}`, + ) + .join("; "); + return Err( + new WorkflowAnswerDeliveryError( + `the value offered to ${suspensionId} does not satisfy the response schema that wait ` + + `retained: ${described}.`, + ), + ); +} + +/** + * Cross the same gate a durable event crosses, before anything is retained. + * + * Both framings, exactly as the local host scans them: the retained row this + * value becomes, and the durable event a later execution would publish from it. + * A credential that reached retained state has already leaked, and it has + * leaked into somebody else's storage — which is a reason to scan here rather + * than a reason not to. + */ +function* scanDelivery( + waiting: RemoteRetainedWait, + suspensionId: string, + value: Json, +): Operation> { + const scanner = createSecretScanner(); + const findings: SecretFinding[] = []; + for (const content of [ + canonicalJson({ + suspensionId, + requestEventId: waiting.requestEventId, + requestFingerprint: waiting.requestFingerprint, + answer: value, + }), + serializeDurableEvent(answerEvent(suspensionId, value)), + ]) { + try { + findings.push(...(yield* scanner.scan(content))); + } catch (error) { + return Err( + new WorkflowAnswerDeliveryError( + "secret detection could not scan this answer, so it was not retained: " + + (error instanceof Error ? error.message : String(error)), + ), + ); + } + } + if (findings.length === 0) { + return Ok(); + } + return Err(new WorkflowAnswerDeliveryError(describeDetection(findings))); +} + +/** + * The event a resume would publish, for the scanner to read. + * + * Which coroutine reaches the wait is not known until an execution does, and + * nothing delivered travels in that field — so it is left empty here. + */ +function answerEvent(suspensionId: string, value: Json): DurableEvent { + return { + type: "yield", + coroutineId: "", + description: { type: SUSPENSION_ANSWER, name: suspensionId, suspensionId }, + result: { status: "ok", value }, + }; +} + +/** + * What was found, without what was matched. + * + * The rule and the position say enough to fix the data flow. The fingerprints + * are keyed to a scanner that existed for this call alone, so reporting them + * would say nothing, and the matched text is exactly what must not travel. + */ +function describeDetection(findings: readonly SecretFinding[]): string { + const detected = new SecretDetectedError(findings); + const where = findings + .map((finding) => `${finding.ruleId} (${finding.messageId})`) + .filter((description, index, all) => all.indexOf(description) === index) + .join(", "); + return ( + `${detected.name}: this answer was not retained because secret detection matched it: ` + + `${where}. Neither the value nor the match is recorded. Disable detection for this ` + + "delivery with --no-secret-detection only when the value is known not to be a credential." + ); +} diff --git a/packages/workflow/src/remote/inspection.ts b/packages/workflow/src/remote/inspection.ts new file mode 100644 index 000000000..45c5361d8 --- /dev/null +++ b/packages/workflow/src/remote/inspection.ts @@ -0,0 +1,109 @@ +/** + * What a run says about itself, when its storage is somewhere else. + * + * Reading is not advancing, so none of this takes the run's executor + * acquisition: an inspection can be answered while an executor is live, and + * asking one never makes a run unrunnable. Nothing here recovers a stale + * execution, attaches a Workspace, materializes a root, imports a document, + * contacts a provider or appends anything. + * + * The projection is done here rather than by the adapter. The owner returns + * retained rows; what a history *means* — its authored source, its cumulative + * forkability, its inherited provenance — is provider-neutral, and computing it + * on the runner is what keeps one interpretation of a journal rather than one + * per adapter. + */ + +import { Err, Ok, type Operation, type Result } from "effection"; +import { checkRunId } from "../storage/create-request.ts"; +import { WorkflowRunIdMismatchError, WorkflowRunNotFoundError } from "../storage/errors.ts"; +import { WorkflowLifecycle } from "../lifecycle/api.ts"; +import type { WorkflowLifecycleSnapshot } from "../lifecycle/api.ts"; +import { projectHistory, type WorkflowHistoryEntry } from "../lifecycle/history.ts"; +import type { RemoteReadPlane, RetainedInspection } from "./read.ts"; + +/** + * Install the read-only lifecycle operations over one owner's read plane. + * + * The plane is bound to one admitted owner, so the domain of `list()` is that + * owner: zero snapshots when it holds no run, one when it does. That is a + * complete answer to "every run this is bound to", and it claims nothing about + * a namespace — there is no registry here and nothing to enumerate. + */ +export function useRemoteLifecycleReads(plane: RemoteReadPlane): Operation { + return WorkflowLifecycle.around( + { + *inspect([runId]): Operation> { + const addressed = addressing(plane, runId); + if (addressed !== undefined) { + return addressed; + } + const read = yield* plane.inspect(); + return read.ok ? Ok(snapshotOf(read.value)) : read; + }, + + *list(): Operation> { + const read = yield* plane.inspect(); + if (read.ok) { + return Ok(Object.freeze([snapshotOf(read.value)])); + } + // An owner holding no run at all lists nothing. Everything else — a + // foreign, incompatible, damaged or unreadable owner — fails the whole + // request, exactly as the local provider does: a shorter list is an + // answer to a question nobody asked. + return read.error instanceof WorkflowRunNotFoundError ? Ok(Object.freeze([])) : read; + }, + + *history([runId]): Operation> { + const addressed = addressing(plane, runId); + if (addressed !== undefined) { + return addressed; + } + const read = yield* plane.history(); + if (!read.ok) { + return read; + } + return Ok( + projectHistory(read.value.entries, { + retainedRoots: read.value.retainedRoots, + inherited: read.value.inherited, + }), + ); + }, + }, + { at: "min" }, + ); +} + +/** + * Why this request is not for this plane's run, or nothing when it is. + * + * Refused here, before the transport is reached, so a request naming another + * run neither travels nor comes back with the bound run's answer. The bound id + * is compared rather than trusted: it can only ever cause a refusal. + */ +function addressing(plane: RemoteReadPlane, runId: string): Result | undefined { + const checked = checkRunId(runId); + if (!checked.ok) { + return checked; + } + if (checked.value !== plane.runId) { + return Err(new WorkflowRunIdMismatchError(checked.value, REMOTE_RUN)); + } + return undefined; +} + +/** How a remote run's storage is named in a public error. */ +const REMOTE_RUN = "this run's remote storage"; + +/** The retained reading, as the public snapshot it projects to. */ +function snapshotOf(read: RetainedInspection): WorkflowLifecycleSnapshot { + return Object.freeze({ + record: read.record, + executions: read.executions, + ...(read.retrieval === undefined ? {} : { retrieval: read.retrieval }), + ...(read.journalFrontier === undefined ? {} : { journalFrontier: read.journalFrontier }), + currentWorkspaceRootId: read.currentWorkspaceRootId, + ...(read.lineage === undefined ? {} : { lineage: read.lineage }), + }); +} diff --git a/packages/workflow/src/remote/invocation.ts b/packages/workflow/src/remote/invocation.ts new file mode 100644 index 000000000..656706d27 --- /dev/null +++ b/packages/workflow/src/remote/invocation.ts @@ -0,0 +1,298 @@ +/** + * What a remote invocation owns on the runner, and when it lets go. + * + * Two trees, and the difference between them is the whole point. The + * *materialization* is the accepted root: what the owner last confirmed this + * run is at, restored so native tools can work in it. The *attempt* is where a + * mutation actually happens, and it is disposable by construction — until the + * owner performs the commit, nothing that happened in it has happened. + * + * Keeping them apart is what makes a documented failure ordinary. A Workspace + * effect that fails is a fact the run records against the root it started from, + * so the attempt is thrown away and the accepted tree is still exactly what the + * owner confirmed. Working directly in the accepted tree would mean a failed + * effect had already changed the only local copy of the run's Workspace, and + * the next attempt would start somewhere nobody chose. + * + * Both are Effection resources, so their lifetimes are their scopes'. Normal + * return, a raised failure, cancellation, a refusal from the owner and a lost + * response all leave nothing behind, because none of them skips teardown. Only + * a performed owner answer promotes an attempt, and promotion is a decision + * this module is told about rather than one it infers. + */ + +import { ensure, type Operation, resource } from "effection"; +import type { WorkspaceRejection } from "../workspace/root-manifest.ts"; +import { + captureWorkspace, + type CapturedWorkspace, + type HostPath, + materializeWorkspaceRoot, + type RunnerFiles, +} from "./materialize.ts"; +import type { RemoteReadLink } from "./read.ts"; +import type { CommitDecision, ProposedContent, RetainedMapping } from "./publication.ts"; +import { SEAL, type SealableAttempt, type SealedProposal } from "./seal.ts"; + +/** A directory this invocation owns for as long as it needs one. */ +export interface TemporaryTrees { + /** A fresh empty directory, removed when the calling scope ends. */ + create(purpose: string): Operation; + /** Remove one, before its scope would. */ + remove(path: string): Operation; +} + +/** + * The capability to move the accepted tree, which is not part of reading it. + * + * A symbol because a symbol cannot be written down by anyone who does not + * already have it. Everything that merely reads the Workspace receives a + * `Materialization` and can see no way to change which Workspace it is + * reading; only an attempt, created by this module, is handed the key. + */ +const ACCEPT: unique symbol = Symbol("executablemd.workflow.remote.accept"); + +/** The accepted local copy of the root the owner last confirmed. */ +export interface Materialization { + /** The root this tree is, as the owner confirmed it. */ + readonly workspaceRootId: string; + /** + * Where a logical Workspace path sits in the accepted tree. + * + * Resolved on each call rather than closed over one directory, because + * promotion replaces the tree: after it, this has to answer with the promoted + * bytes. A path captured once would keep pointing at the Workspace the run + * used to be at while the identity said otherwise. + */ + at(logical: string): string; +} + +/** The accepted materialization as this module alone sees it. */ +interface AcceptedMaterialization extends Materialization { + readonly [ACCEPT]: (next: { root: string; workspaceRootId: string }) => Operation; +} + +/** + * One disposable place to make a mutation, and the way to offer it. + * + * There is no `promote()`. Promotion is not something a caller does at the + * right moment; it is what happens inside the transaction when the owner + * performs the exact commit that proposed this attempt. An attempt offers a + * proposal, the transaction sends it, and only the transaction — holding the + * answer it just validated — transfers the tree. + */ +export interface Attempt extends SealableAttempt { + readonly at: HostPath; + /** What the attempt describes right now, captured and checked locally. */ + capture(): Operation; + /** + * Put this attempt back to the accepted root it started from. + * + * What a savepoint means on a tree. One mutation performs one change, so a + * part of it that cannot be finished leaves nothing to keep — and the + * accepted materialization is still exactly what the owner confirmed, so the + * attempt is rebuilt from it rather than repaired. + */ + restore(): Operation; +} + +/** + * Restore the admitted root into a tree this invocation owns. + * + * The tree is created, filled and proved before anything else runs against it: + * `materializeWorkspaceRoot` refuses a host that cannot reproduce the retained + * modes, times or topology, so an invocation either has the Workspace the owner + * described or does not start. + */ +export function useMaterialization( + files: RunnerFiles, + trees: TemporaryTrees, + reads: RemoteReadLink, + workspaceRootId: string, + reject: WorkspaceRejection, +): Operation { + return resource(function* (provide) { + const root = yield* trees.create("accepted"); + yield* materializeWorkspaceRoot(files, reads, at(root), workspaceRootId, reject); + let accepted = { root, workspaceRootId }; + const materialization: AcceptedMaterialization = { + get workspaceRootId(): string { + return accepted.workspaceRootId; + }, + at(logical: string): string { + return at(accepted.root)(logical); + }, + *[ACCEPT](next: { root: string; workspaceRootId: string }): Operation { + const previous = accepted.root; + accepted = next; + // The tree the run used to be at is removed once nothing points at it. + // Leaving it would keep a second copy of the Workspace on disk that + // nothing can reach and nothing will clean up until the invocation ends. + yield* trees.remove(previous); + }, + }; + yield* provide(materialization); + }); +} + +/** + * The accepted materialization, with the capability an attempt needs. + * + * The declared type hides it, so this is where the two views meet. A value that + * did not come from `useMaterialization()` carries no such key and cannot be + * mistaken for one. + */ +function accepting(materialization: Materialization, reject: WorkspaceRejection) { + const accept = (materialization as Partial)[ACCEPT]; + if (accept === undefined) { + reject("this is not an accepted materialization this invocation owns"); + } + return accept; +} + +/** + * A disposable copy of the accepted tree, for one mutation. + * + * Materialized from the owner rather than copied from the accepted tree: the + * owner's copy is the one that is authoritative, and reading it again is how an + * attempt starts from what the run actually is rather than from whatever the + * last attempt happened to leave behind. + */ +export function useAttempt( + files: RunnerFiles, + trees: TemporaryTrees, + reads: RemoteReadLink, + materialization: Materialization, + reject: WorkspaceRejection, +): Operation { + return resource(function* (provide) { + const accept = accepting(materialization, reject); + const root = yield* trees.create("attempt"); + yield* materializeWorkspaceRoot( + files, + reads, + at(root), + materialization.workspaceRootId, + reject, + ); + + let transferred = false; + // Registered before the attempt is handed over, so every exit removes it — + // including the ones that never reach the end of the calling scope. + yield* ensure(function* () { + if (!transferred) { + yield* trees.remove(root); + } + }); + + yield* provide({ + at: at(root), + *capture(): Operation { + return yield* captureWorkspace(files, at(root), reject); + }, + + *restore(): Operation { + yield* files.removeTree(root); + yield* files.makeDirectory(root, 0o700); + yield* materializeWorkspaceRoot( + files, + reads, + at(root), + materialization.workspaceRootId, + reject, + ); + }, + + /** + * Seal this attempt into the proposal the owner will decide. + * + * Captured here, not earlier. The transaction calls this once the body + * and everything it started have torn down, so the proposal describes the + * tree as it finally is — a capture taken when the body enlisted could + * name one Workspace while the directory went on to hold another, and the + * owner would commit one root while the runner transferred different + * bytes under it. + */ + *[SEAL](mappings: readonly RetainedMapping[]): Operation { + if (transferred) { + reject("this attempt has already been sealed and transferred"); + } + const captured = yield* captureWorkspace(files, at(root), reject); + return { + publication: { + proposedWorkspaceRootId: captured.root.rootId, + proposedManifest: captured.root.manifest, + content: inventoryOf(captured), + }, + mappings, + bytes: bytesOf(captured), + *transfer(decision: CommitDecision): Operation { + if (transferred) { + reject("this attempt has already been transferred"); + } + if (decision.workspaceRootId !== captured.root.rootId) { + reject("the owner's decision names a root this attempt did not seal"); + } + transferred = true; + yield* accept({ root, workspaceRootId: captured.root.rootId }); + }, + }; + }, + }); + }); +} + +/** + * The exact closure a captured root names, in canonical order. + * + * Ordered by `kind:digest`, which is the order the owner reads it in: it holds + * the inventory to a strictly increasing sequence, because a proposal that + * named its pieces in another order is not the proposal whose identity the + * runner computed. Concatenating one kind after the other would produce a list + * this owner refuses, and only a real owner would say so. + */ +function inventoryOf(captured: CapturedWorkspace): ProposedContent[] { + const inventory: ProposedContent[] = [ + ...captured.root.manifests.map((digest) => ({ + kind: "manifest" as const, + digest, + size: captured.contents.get(digest)?.manifestBytes.length ?? 0, + })), + ...captured.root.blobs.map((digest) => ({ + kind: "blob" as const, + digest, + size: captured.blobs.get(digest)?.length ?? 0, + })), + ]; + return inventory.toSorted((left, right) => + orderingOf(left) < orderingOf(right) ? -1 : orderingOf(left) > orderingOf(right) ? 1 : 0, + ); +} + +function orderingOf(piece: ProposedContent): string { + return `${piece.kind}:${piece.digest}`; +} + +/** Every piece the capture can supply, by identity. */ +function bytesOf(captured: CapturedWorkspace): Map { + const bytes = new Map(); + for (const [digest, content] of captured.contents) { + bytes.set(digest, content.manifestBytes); + } + for (const [digest, blob] of captured.blobs) { + bytes.set(digest, blob); + } + return bytes; +} + +/** + * One logical Workspace path under a host directory. + * + * Kept here rather than imported from a path module because it is the only + * place the two vocabularies meet, and because a shared module may not name a + * host's path conventions. The logical root is `/` and everything under it is + * relative to the tree this invocation was given. + */ +function at(root: string): HostPath { + return (logical) => (logical === "/" ? root : `${root}/${logical.slice(1)}`); +} diff --git a/packages/workflow/src/remote/journal-route.ts b/packages/workflow/src/remote/journal-route.ts new file mode 100644 index 000000000..52755e988 --- /dev/null +++ b/packages/workflow/src/remote/journal-route.ts @@ -0,0 +1,94 @@ +/** + * Where a Workspace effect's publication goes. + * + * A durable operation publishes its result into the run's journal. When a + * Workspace effect is the thing publishing, that append has to land in the + * exact transaction the effect ran inside, so the Files change and the row + * describing it commit together or not at all. The ordinary journal would + * append outside the transaction, which is the one ordering that cannot be + * taken back. + * + * So the route is installed for one transaction's descendant scope, keyed to + * one exact database, transaction and token. Outside that scope the wrapper + * falls through to the ordinary journal, and a retained token reaches nothing. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { DurableEvent, DurableStream } from "@executablemd/durable-streams"; +import { ensure, type Operation, scoped } from "effection"; +import type { WorkflowRunDatabase, WorkflowRunTransaction } from "../storage/api.ts"; + +interface JournalDestinationApi { + append(database: WorkflowRunDatabase, event: DurableEvent): Operation; +} + +const RemoteJournalDestination: Api = createApi( + "executablemd.workflow.remote.journal.destination", + { + // deno-lint-ignore require-yield + *append(): Operation { + return false; + }, + }, +); + +/** + * Run `publication` with this exact transaction as the journal's destination. + * + * The transaction object is the capability. Only code inside the live + * transaction body holds one, and the caller has already proved through + * `activeWorkspaceRoute()` that this is that transaction — so there is nothing + * further to look up, and no registry to outlive the run. + * + * Scoped, so the redirection ends with the operation that needed it rather than + * outliving the transaction it names. `live` closes with that scope: an append + * arriving afterwards falls through to the ordinary journal instead of reaching + * a transaction that has closed. + */ +export function withRemoteJournalRoute( + database: WorkflowRunDatabase, + transaction: WorkflowRunTransaction, + publication: Operation, +): Operation { + return scoped(function* () { + let live = true; + yield* ensure(() => { + live = false; + }); + yield* RemoteJournalDestination.around( + { + *append([candidate, event], next): Operation { + if (candidate !== database || !live) { + return yield* next(candidate, event); + } + yield* transaction.journal.append(event); + return true; + }, + }, + { at: "min" }, + ); + return yield* publication; + }); +} + +/** + * The run's journal, willing to be redirected into an open transaction. + * + * Reads always come from the ordinary journal: what a transaction has appended + * is read back through the transaction itself, and a reader outside it is + * asking about committed history. + */ +export function routeRemoteRunJournal( + database: WorkflowRunDatabase, + ordinary: DurableStream, +): DurableStream { + return { + readAll: () => ordinary.readAll(), + + *append(event: DurableEvent): Operation { + if (!(yield* RemoteJournalDestination.operations.append(database, event))) { + yield* ordinary.append(event); + } + }, + }; +} diff --git a/packages/workflow/src/remote/lifecycle-link.ts b/packages/workflow/src/remote/lifecycle-link.ts new file mode 100644 index 000000000..fef595ef2 --- /dev/null +++ b/packages/workflow/src/remote/lifecycle-link.ts @@ -0,0 +1,193 @@ +/** + * The lifecycle half of one run's connection. + * + * Beginning, settling, cancelling and forking are the moments a run's own state + * changes, and they travel on the same connection as everything else that + * carries authority. This is that half of the link, stated where nothing knows + * which host answers: a provider composes these with the Workspace half it + * already has, and the adapter underneath decides how a command is spelled. + * + * Every answer here is already this build's own vocabulary. A refusal spelling, + * a command name, a socket or a row never reaches a caller through one of + * these; what reaches a caller is a `Result` of values the shared record + * parsers produced. + */ + +import type { Operation, Result } from "effection"; +import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import type { + DocumentExecutionCompletion, + DocumentExecutionRecord, + WorkflowRunRecord, +} from "../storage/record.ts"; +import type { CreateWorkflowRunRequest } from "../storage/api.ts"; +import type { RemoteFrontierSnapshot } from "./read.ts"; +import type { RemoteWorkspaceLink } from "./database.ts"; + +/** What one begin committed, as the runner is allowed to know it. */ +export interface RemoteBegun { + readonly frontier: RemoteFrontierSnapshot; + readonly execution: DocumentExecutionRecord; + readonly replay: boolean; + /** What stale recovery closed on the way in, when it closed anything. */ + readonly recovered: DocumentExecutionRecord | null; +} + +/** + * A lifecycle answer that may decline for a reason about the run. + * + * `refused` carries which condition applied — a cancelled run, a failed one, a + * terminal one — because a caller acts on the difference. What the run holds + * never travels with it. + */ +export type RemoteLifecycleAnswer = + | { readonly kind: "performed"; readonly value: T } + | { readonly kind: "refused"; readonly refusal: RemoteLifecycleRefusal }; + +/** + * A condition of the run itself, as an owner names one. + * + * Each is a fact a caller acts on rather than a failure to translate, and each + * carries nothing the run holds. + */ +export type RemoteLifecycleRefusal = + | "cancelled" + | "resume-failed" + | "terminal" + | "damaged-terminal"; + +/** Which committed checkpoint of which run a fork continues. */ +export interface RemoteForkOrigin { + readonly sourceRunId: string; + readonly checkpointEventId: string; + readonly checkpointWorkspaceRootId: string; + readonly runRecordWorkspaceRootId: string; + readonly rootImportWorkspaceRootId: string; + readonly anchor: string; +} + +/** How many parts of each section a committed fork should find staged. */ +export interface RemoteForkCounts { + readonly inherited: number; + readonly roots: number; + readonly manifests: number; + readonly blobs: number; + readonly checkouts: number; +} + +/** One part of a fork's source, offered before any of it is a run. */ +export interface RemoteForkPart { + readonly section: "inherited" | "roots" | "manifests" | "blobs" | "checkouts"; + readonly position: number; + readonly part: Record; +} + +/** One begin, as the provider addresses it and may address it again. */ +export interface RemoteBeginCommand { + /** The identity this logical invocation keeps, retry after retry. */ + readonly commandId: string; + readonly runId: string; + readonly action: "start" | "resume"; + readonly creation: CreateWorkflowRunRequest | null; + /** Where the definition can be fetched from, when this begin creates. */ + readonly retrieval: Json | undefined; + readonly executionId: string; +} + +/** Which fork a continuation claims, as the destination retains it. */ +export interface RemoteContinuationOrigin { + readonly sourceRunId: string; + readonly checkpointEventId: string; +} + +/** Everything one committed fork is decided from. */ +/** Taking up a destination that already holds this fork, without its source. */ +export interface RemoteForkContinuation { + readonly commandId: string; + readonly runId: string; + readonly creation: CreateWorkflowRunRequest; + readonly origin: RemoteContinuationOrigin; + readonly runRecord: DurableEvent; + readonly rootImport: DurableEvent; + readonly executionId: string; +} + +export interface RemoteForkCommit { + /** The identity this logical invocation keeps, retry after retry. */ + readonly commandId: string; + readonly runId: string; + readonly creation: CreateWorkflowRunRequest; + readonly retrieval: Json | undefined; + readonly origin: RemoteForkOrigin; + readonly counts: RemoteForkCounts; + readonly runRecord: DurableEvent; + readonly rootImport: DurableEvent; + readonly executionId: string; +} + +/** + * One admitted executor connection, as the host arranges it. + * + * The two halves are the same authority and arrive together: a link that reads + * and commits, and the lifecycle commands that move the run. A host that + * returned them from different owners would be handing out an acquisition of + * one run that mutates another, so they are one value. + */ +export interface RemoteExecutorConnection { + readonly link: RemoteWorkspaceLink; + readonly lifecycle: RemoteLifecycleLink; + /** + * End this connection now, before the scope that owns it ends. + * + * An acquisition retired while a command's outcome is unknown must stop + * being the owner's live executor: a lock the runner has given up on while + * its socket still holds the run would leave the run unreachable by anybody, + * including whoever wants to ask the same question again. + */ + close(): Operation; +} + +/** + * The lifecycle commands one admitted connection may perform. + * + * Each is one owner transaction, and each is retried by identity rather than + * repeated: the same command id and the same content is the same request, and + * an owner that already decided it answers with what it decided. + */ +export interface RemoteLifecycleLink { + /** Begin one document execution under this acquisition. */ + begin(request: RemoteBeginCommand): Operation>>; + /** Finish the execution this acquisition began. */ + settle( + commandId: string, + completion: DocumentExecutionCompletion, + expectedWorkspaceRootId: string, + ): Operation>; + /** Make this run terminal, following what it retains. */ + cancel( + commandId: string, + runId: string, + ): Operation>>; + /** Offer one part of a fork's source to this acquisition's scratch. */ + stageForkPart(commandId: string, part: RemoteForkPart): Operation>; + /** Commit the offered parts as one destination run and its first execution. */ + /** + * Commit the offered parts as one destination run and its first execution. + * + * `needs-transfer` is the one failure a caller answers by copying the source + * again: the destination holds nothing and the parts this command names were + * never offered on this connection. + */ + commitFork( + commit: RemoteForkCommit, + ): Operation | "needs-transfer">>; + /** + * Continue a destination that already holds this fork. + * + * `absent` rather than a failure when the destination holds no run: that is + * the answer that sends a caller to the source it has not needed yet. + */ + continueFork( + continuation: RemoteForkContinuation, + ): Operation | "absent">>; +} diff --git a/packages/workflow/src/remote/lifecycle.ts b/packages/workflow/src/remote/lifecycle.ts new file mode 100644 index 000000000..9a4cc5754 --- /dev/null +++ b/packages/workflow/src/remote/lifecycle.ts @@ -0,0 +1,1008 @@ +/** + * The remote provider's half of the executor lifecycle. + * + * Taking the lock is opening a connection: an admitted socket *is* the + * acquisition, so the lock this hands back is an object issued beside one exact + * connection, held in this provider's own closure, and recognized by identity. + * A run id, a copy of the object, another provider's lock or a lock whose + * connection has closed authorizes nothing, and nothing about it is checked by + * comparing fields. + * + * ## The connection is the lifetime + * + * The connection belongs to the scope that asked for it. When that scope ends — + * normally, by cancellation, or because the socket failed — the hold is retired + * and the connection closes once. Nothing expires: there is no lease, no + * heartbeat and no elapsed time anywhere in this file. An acquisition ends when + * its connection does. + * + * ## One acquisition begins one execution + * + * The hold remembers which execution this acquisition began, and so does the + * owner. Both are needed: the runner's copy refuses a second begin before a + * message is sent, and the owner's copy is what a settlement is actually + * checked against, because a runner that lost track of its own hold must not be + * able to finish an execution it never began. + */ + +import { Err, ensure, Ok, type Operation, type Result, scoped } from "effection"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { + type ExecutorAcquisition, + type ExecutorLock, + WorkflowLifecycle, +} from "../lifecycle/api.ts"; +import type { + WorkflowBeginRequest, + WorkflowExecutionBegun, + WorkflowExecutionTransitions, + WorkflowForkRequest, + WorkflowRunCreation, +} from "../lifecycle/execution.ts"; +import { damagedTerminalRefusal } from "../lifecycle/policy.ts"; +import type { WorkflowRunDatabase } from "../storage/api.ts"; +import type { CreateWorkflowRunRequest } from "../storage/api.ts"; +import type { DocumentExecutionCompletion, WorkflowRunRecord } from "../storage/record.ts"; +import { + WorkflowRequestError, + WorkflowRunNotFoundError, + type WorkflowStorageError, + WorkflowTransactionError, +} from "../storage/errors.ts"; +import { useRemoteRunDatabase } from "./database.ts"; +import { canonicalJson } from "../storage/record.ts"; +import { definitionToJson } from "../storage/definition.ts"; +import type { RemoteForkSource, RemoteReadPlane } from "./read.ts"; +import { forkRunRecordEvent } from "../fork.ts"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import type { + RemoteBegun, + RemoteExecutorConnection, + RemoteForkCommit, + RemoteForkPart, + RemoteLifecycleLink, + RemoteLifecycleRefusal, +} from "./lifecycle-link.ts"; + +export type { RemoteExecutorConnection }; + +/** + * What this provider needs from its host, and nothing more. + * + * Narrow on purpose: reaching an owner, reading a source without acquiring it, + * and assembling a candidate somewhere local are host arrangements. None of + * them is a registry, none enumerates anything, and none is public API. + */ +export interface RemoteLifecycleHost { + /** + * Admit one executor connection for this run, owned by the calling scope. + * + * Answers `already-running` when the owner refuses because another live + * executor holds the run — a fact about the run, not a failure of this call. + */ + admit(runId: string): Operation>; + /** The no-acquisition read plane for one run, for copy and inspection only. */ + source(runId: string): Operation>; + /** + * Assemble one fork candidate in runner-local disposable storage. + * + * Takes no acquisition and creates nothing a host would discover. What it + * returns belongs to the calling scope and goes when that scope ends. + */ + stage( + request: WorkflowForkRequest, + source: RemoteForkSource, + head: { readonly runRecord: DurableEvent; readonly rootImport: DurableEvent }, + ): Operation>; + /** Fresh identities for this provider's own commands and executions. */ + readonly ids: { readonly execution: () => string; readonly command: () => string }; +} + +/** + * One logical lifecycle call, as this provider owns it. + * + * A call is not the same call as another because their arguments compare equal: + * two identical `begin()` calls are two calls, and the second must be refused + * rather than handed the first one's execution. What may reuse an identity is + * one narrow thing — the continuation of a call whose answer was lost after the + * owner may already have committed — and that is what this records. + * + * `in flight` is held by the acquisition, so a second transition under one live + * lock is refused before anything is sent. `ambiguous` outlives the connection + * it was sent on, because the whole point is that a replacement acquisition + * asks the same question. A definitive answer — a decision, a conflict, an + * owner refusal — retires the record: it has been answered, and a later + * corrected request is a new call with new identities. + */ +interface Ambiguous { + readonly commandId: string; + readonly executionId: string; + /** The complete canonical request this identity belongs to. */ + readonly question: string; + /** + * Which phase of its call this identity belongs to. + * + * A fork can be told, definitively, that the destination holds nothing and + * was offered nothing — `needs-transfer`. That answer finishes the identity + * it was given: the owner has answered it, and the same name may never carry + * a question again. What follows is a second transfer of the same logical + * fork under an identity of its own, and this says which of the two an + * invocation is, so a retry resumes the phase it is in rather than reaching + * back for a name that has already been answered. + */ + readonly phase: "initial" | "retransfer"; + /** The exact command that was sent, resent verbatim on continuation. */ + readonly command: RemoteForkCommit | undefined; +} + +/** What one issued lock is allowed to do, and what it has already done. */ +interface Hold { + readonly runId: string; + readonly connection: RemoteExecutorConnection; + /** Which execution this acquisition began, once it has begun one. */ + execution: string | undefined; + /** + * Whether a transition is in flight on this acquisition. + * + * One acquisition begins one execution, and it has to be refused while the + * first call is still waiting as well as after it returns — otherwise two + * identical calls both pass the check and both send. + */ + busy: boolean; + /** Whether the connection this lock was issued beside is still open. */ + live: boolean; +} + +/** + * Install the remote executor lifecycle, and hand back its transitions. + * + * The transitions are returned rather than installed: they hand out an open + * database, which is a transport, and a capability like that belongs to the + * executor that already holds the lock rather than to a contextual surface + * anything in the process can reach. + */ +export function* useRemoteLifecycle( + host: RemoteLifecycleHost, +): Operation { + // Keyed by the object itself: two locks are the same lock when they are the + // same object, and nothing about their fields is consulted. + const held = new Map(); + // Keyed by the run and the question asked, so the same question after a lost + // answer is the same invocation. Not authority: what it carries is an + // identity, and the owner decides what that identity already means. + const ambiguous = new Map(); + + /** + * The identity this call carries. + * + * A fresh identity, unless this is the continuation of a call whose answer + * was lost and whose question is exactly this one. + */ + function identify(runId: string, question: string): Ambiguous { + const found = ambiguous.get(runId); + if (found !== undefined && found.question === question) { + return found; + } + return { + commandId: host.ids.command(), + executionId: host.ids.execution(), + question, + phase: "initial", + command: undefined, + }; + } + + /** Remember a call whose answer never arrived, with what it sent. */ + function unanswered(runId: string, held: Ambiguous, command?: RemoteForkCommit): void { + ambiguous.set(runId, { ...held, command: command ?? held.command }); + } + + /** Retire a call that was answered, whatever the answer was. */ + function answeredNow(runId: string, held: Ambiguous): void { + const found = ambiguous.get(runId); + if (found?.commandId === held.commandId) { + ambiguous.delete(runId); + } + } + + function hold(lock: ExecutorLock): Hold | undefined { + // Fabricated, copied, foreign, released and closed locks all answer + // `undefined` here — before a read plane, an owner command or a database is + // touched. + const found = held.get(lock); + return found === undefined || !found.live ? undefined : found; + } + + function* acquireExecutor(runId: string): Operation> { + if (runId === "") { + return Err(new WorkflowRequestError("a workflow run id cannot be empty.")); + } + const admitted = yield* host.admit(runId); + if (!admitted.ok) { + return admitted; + } + if (admitted.value === "already-running") { + return Ok({ kind: "already-running" }); + } + const connection = admitted.value; + const lock: ExecutorLock = Object.freeze({ runId }); + const record: Hold = { + runId, + connection, + execution: undefined, + busy: false, + live: true, + }; + held.set(lock, record); + // Registered before it is returned, and retired when the scope that asked + // for it ends — the same scope that owns the connection underneath. + yield* ensure(function* () { + record.live = false; + held.delete(lock); + }); + return Ok({ kind: "acquired", lock }); + } + + function* cancel(runId: string): Operation> { + if (runId === "") { + return Err(new WorkflowRequestError("a workflow run id cannot be empty.")); + } + // Cancellation takes an acquisition of its own for exactly this operation + // and gives it back. It never reads the no-acquisition plane to decide + // whether it may proceed: that plane holds no authority. + return yield* scoped(function* () { + const admitted = yield* host.admit(runId); + if (!admitted.ok) { + return admitted; + } + if (admitted.value === "already-running") { + return Err( + new WorkflowRequestError( + "a live workflow executor holds this run, so it cannot be cancelled from here.", + ), + ); + } + const question = questionOf(["cancel"]); + const addressed = identify(runId, question); + // The same line the transitions draw, on a call that has no acquisition + // of its own to retire: if this is interrupted after the command went + // out, the question is retained so a later attempt asks that one rather + // than a new one. The connection ends with this scope either way. + let outstanding = false; + // deno-lint-ignore require-yield + yield* ensure(function* () { + if (outstanding) { + unanswered(runId, addressed); + } + }); + outstanding = true; + const answered = yield* admitted.value.lifecycle.cancel( + commandOf(addressed, "cancel"), + runId, + ); + outstanding = false; + if (!answered.ok) { + // The same distinction the other mutations make. A cancellation may + // have committed before its answer was lost, and the next attempt has + // to ask that question rather than a new one. + if (lost(answered.error)) { + unanswered(runId, addressed); + } else { + answeredNow(runId, addressed); + } + return answered; + } + answeredNow(runId, addressed); + if (answered.value.kind === "refused") { + if (answered.value.refusal === "damaged-terminal") { + return Err(damagedTerminalRefusal()); + } + return Err( + new WorkflowRequestError( + answered.value.refusal === "terminal" + ? "this run already reached a terminal outcome, so it cannot be cancelled." + : "this run cannot be cancelled from the state it is in.", + ), + ); + } + return Ok(answered.value.value); + }); + } + + yield* WorkflowLifecycle.around( + { + *acquireExecutor([runId]): Operation> { + return yield* acquireExecutor(runId); + }, + *cancel([runId]): Operation> { + return yield* cancel(runId); + }, + }, + // Nearest wins, the same way storage and the read provider install: a + // scope that installed this one answers with it, not with whatever an + // enclosing scope happened to install first. + { at: "min" }, + ); + + return transitions(host, hold, identify, unanswered, answeredNow); +} + +/** + * One transition's grip on its acquisition, ended however the transition ends. + * + * An outstanding command is the line between two very different cancellations. + * Before one has gone out, nothing can have happened, so the guard simply + * lifts. After one has gone out and no answer came back, the owner's decision + * is unknown — so the exact question is retained first, and then the + * acquisition is retired rather than freed, because a fresh mutation racing an + * unknown decision is the one thing that must not happen. Whoever wants to + * continue takes a new acquisition and asks the retained question again. + * + * A transition sends more than one command, and only the last of them is in + * flight at a time. So `sending` and `answered` bracket one command each, + * while `done` is what says the whole public transition has returned. A + * preliminary answer in the middle of a fork is not the end of the fork, and + * treating it as one is how a cancellation during the source read used to slip + * past cleanup entirely. + */ +interface Grip { + /** + * One command is going out now. + * + * `retain` is what to remember if this call is interrupted before the answer + * arrives: the exact identity, and the exact bytes when there are any. It + * runs only on that path, because a command that was answered is not + * ambiguous however the answer read. + */ + sending(retain: () => void): void; + /** Say that the command in flight was answered, whatever the answer was. */ + answered(): void; + /** Say that this public transition has returned. */ + done(): void; +} + +/** + * Take this acquisition for one transition, or say why it cannot be taken. + * + * One acquisition begins one execution. Two identical calls are two calls, so + * the second is refused while the first is still in flight as well as after it + * has returned. + */ +function engage(held: Hold): WorkflowRequestError | undefined { + if (held.execution !== undefined || held.busy) { + return new WorkflowRequestError( + "this executor lock has already begun a document execution. One acquisition begins one.", + ); + } + held.busy = true; + return undefined; +} + +/** + * Register how this transition lets go, before it can be interrupted. + * + * `ensure` runs on every exit — a return, a raise, or cancellation while an + * owner answer is still outstanding — so no path can leave the guard held or + * leave a sent command unaccounted for. + */ +function* gripped(held: Hold, guard: boolean): Operation { + let outstanding: (() => void) | undefined; + let finished = false; + const release = () => { + if (guard) { + held.busy = false; + } + }; + yield* ensure(function* () { + if (finished) { + return; + } + const retain = outstanding; + if (retain !== undefined) { + // A command went out and nothing came back. What it was asking is + // retained first — a cancellation unwinds past the branches that would + // have retained it, and an identity nobody kept is one no replacement + // can ask under. Then this acquisition is retired rather than released: + // its authority ends with it, and nothing new can be sent on it while + // the first outcome is unknown. The connection goes with it, because a + // lock nobody may use whose socket still holds the run would leave the + // run unreachable by anyone at all. + retain(); + held.live = false; + yield* held.connection.close(); + return; + } + release(); + }); + return { + sending(retain: () => void) { + outstanding = retain; + }, + answered() { + outstanding = undefined; + }, + done() { + finished = true; + release(); + }, + }; +} + +/** + * Run one public transition holding this acquisition. + * + * The grip is taken before the body starts and given back when the body + * returns, however many commands the body sent on the way. A body that is + * interrupted never reaches the release, which is exactly the point: what + * happens then is the cleanup `gripped` registered, decided by whether a + * command was outstanding at the moment of the interruption. + */ +function* holding(held: Hold, guard: boolean, body: (grip: Grip) => Operation): Operation { + const grip = yield* gripped(held, guard); + const outcome = yield* body(grip); + grip.done(); + return outcome; +} + +/** + * Whether a failure left the owner's decision unknown. + * + * A refusal or a conflict is an answer: the owner decided, and the question is + * finished. A transport that ended without answering is not, and the same + * question has to be asked again rather than replaced by a new one. + */ +function lost(error: WorkflowStorageError): boolean { + return error instanceof WorkflowTransactionError; +} + +/** What an unrecognized lock answers, wherever one is offered. */ +function unauthorized(): WorkflowRequestError { + return new WorkflowRequestError( + "this executor lock was not issued by this provider, or its acquisition has ended.", + ); +} + +function transitions( + host: RemoteLifecycleHost, + hold: (lock: ExecutorLock) => Hold | undefined, + identify: (runId: string, question: string) => Ambiguous, + unanswered: (runId: string, held: Ambiguous, command?: RemoteForkCommit) => void, + answeredNow: (runId: string, held: Ambiguous) => void, +): WorkflowExecutionTransitions { + return { + *begin( + lock: ExecutorLock, + request: WorkflowBeginRequest, + ): Operation> { + const held = hold(lock); + if (held === undefined) { + return Err(unauthorized()); + } + if (request.runId !== held.runId) { + return Err( + new WorkflowRequestError("this executor lock was issued for a different workflow run."), + ); + } + const busy = engage(held); + if (busy !== undefined) { + return Err(busy); + } + return yield* holding(held, true, function* (grip) { + if (request.action === "resume" && request.creation !== undefined) { + return Err(new WorkflowRequestError("a resume does not carry a creation.")); + } + // Minted once for this question, outside anything that could retry: the + // owner recognizes a repeat by this identity, and a fresh one would be a + // second execution. A later acquisition asking the same question finds + // the same identity and re-observes the decision. + const creation = creationOf(request.runId, request.creation); + const question = questionOf([ + "begin", + request.action, + creation === null ? null : creationShape(creation), + request.creation?.retrieval === undefined + ? null + : canonicalJson(request.creation.retrieval), + ]); + const addressed = identify(request.runId, question); + grip.sending(() => unanswered(request.runId, addressed)); + const answered = yield* held.connection.lifecycle.begin({ + commandId: commandOf(addressed, "begin"), + runId: request.runId, + action: request.action, + creation, + retrieval: request.creation?.retrieval, + executionId: addressed.executionId, + }); + grip.answered(); + if (!answered.ok) { + // Which kind of failure decides whether the question survives it. An + // answer that was lost leaves the identity standing, so a replacement + // acquisition asks the same one; anything the owner actually decided + // retires it. + if (lost(answered.error)) { + unanswered(request.runId, addressed); + } else { + answeredNow(request.runId, addressed); + } + return answered; + } + answeredNow(request.runId, addressed); + if (answered.value.kind === "refused") { + return Err(refusalError(answered.value.refusal, request.runId)); + } + held.execution = answered.value.value.execution.executionId; + return Ok(yield* begun(held, answered.value.value)); + }); + }, + + *settle( + lock: ExecutorLock, + completion: DocumentExecutionCompletion, + ): Operation> { + const held = hold(lock); + if (held === undefined) { + return Err(unauthorized()); + } + if (held.execution !== completion.executionId) { + // Nothing is sent. An execution this acquisition did not begin is not + // this acquisition's to finish, and asking would be asking about + // somebody else's work. + return Err( + new WorkflowRequestError( + "this executor lock did not begin the document execution it is settling.", + ), + ); + } + // A settlement runs on an acquisition that already holds its execution, + // so it takes no one-execution guard — but it sends a mutation, and an + // interrupted mutation is retained and retires its acquisition exactly + // the way begin's is. + return yield* holding(held, false, function* (grip) { + // The root the owner is held to comes from the same connection-owned + // frontier the execution ran against, after the host has torn down. + const frontier = yield* held.connection.link.frontierSnapshot(); + const question = questionOf([ + "settle", + completion.executionId, + completion.status, + // The whole completion, stop reason included: a settlement that named + // a different reason is a different settlement. + canonicalJson(completion.reason ?? null), + frontier.workspaceRootId, + ]); + const addressed = identify(held.runId, question); + grip.sending(() => unanswered(held.runId, addressed)); + const answered = yield* held.connection.lifecycle.settle( + commandOf(addressed, "settle"), + completion, + frontier.workspaceRootId, + ); + grip.answered(); + if (!answered.ok) { + if (lost(answered.error)) { + unanswered(held.runId, addressed); + } else { + answeredNow(held.runId, addressed); + } + return answered; + } + answeredNow(held.runId, addressed); + held.execution = undefined; + return Ok(answered.value.record); + }); + }, + + *fork( + lock: ExecutorLock, + request: WorkflowForkRequest, + ): Operation> { + const held = hold(lock); + if (held === undefined) { + return Err(unauthorized()); + } + if (request.runId !== held.runId) { + return Err( + new WorkflowRequestError("this executor lock was issued for a different workflow run."), + ); + } + const busy = engage(held); + if (busy !== undefined) { + return Err(busy); + } + return yield* holding(held, true, function* (grip) { + const head = headOf(request); + const creation = creationRequest(request.runId, request.creation); + const question = questionOf([ + "fork", + request.runId, + creationShape(creation), + request.creation.retrieval === undefined + ? null + : canonicalJson(request.creation.retrieval), + request.selection.sourceRunId, + request.selection.checkpointEventId, + serializeDurableEvent(head.runRecord), + serializeDurableEvent(request.rootImport), + ]); + let addressed = identify(request.runId, question); + + // Before the source: a destination that already holds this fork can be + // continued from what it retains, and a decision this owner already made + // can be re-observed. Either way the source is not needed, and it may not + // be there any more. + const outstanding = addressed.command; + if (outstanding !== undefined) { + // An exact command was sent once and never answered. It is resent + // verbatim, and every outcome is classified here: nothing else is + // tried until this one's is known. + const resent = addressed; + grip.sending(() => unanswered(request.runId, resent, outstanding)); + const retained = yield* held.connection.lifecycle.commitFork(outstanding); + grip.answered(); + if (!retained.ok) { + if (lost(retained.error)) { + // Ambiguous again. The claim stands, and no other command is sent. + unanswered(request.runId, resent, outstanding); + } else { + answeredNow(request.runId, resent); + } + return retained; + } + if (retained.value !== "needs-transfer") { + answeredNow(request.runId, resent); + if (retained.value.kind === "refused") { + return Err(refusalError(retained.value.refusal, request.runId)); + } + held.execution = retained.value.value.execution.executionId; + return Ok(yield* begun(held, retained.value.value)); + } + // The one outcome that sends this same logical fork back to its + // source: the destination holds nothing, and the parts this command + // names went with the connection that offered them. + answeredNow(request.runId, resent); + if (resent.phase !== "initial") { + // This was already the second transfer. Offering the same snapshot a + // third time would meet the same answer. + return Err( + new WorkflowRequestError("this fork's transfer did not reach its destination."), + ); + } + // That answer finished the identity it was asked under, and a name the + // owner has answered may never carry another question. The rest of + // this call is a second transfer, under an identity of its own. + addressed = retransfer(host, question); + } + if (addressed.phase === "initial") { + // Whether the destination already holds this fork. A second transfer + // never asks: the destination answered that question by saying it + // holds nothing and was offered nothing. + const asking = addressed; + grip.sending(() => unanswered(request.runId, asking)); + const continued = yield* held.connection.lifecycle.continueFork({ + commandId: commandOf(asking, "continue"), + runId: request.runId, + creation, + // What this request names, which the destination proves against what + // it retained. Nothing here was read from the source. + origin: { + sourceRunId: request.selection.sourceRunId, + checkpointEventId: request.selection.checkpointEventId, + }, + runRecord: head.runRecord, + rootImport: request.rootImport, + executionId: asking.executionId, + }); + grip.answered(); + if (!continued.ok) { + if (lost(continued.error)) { + unanswered(request.runId, asking); + } else { + answeredNow(request.runId, asking); + } + return continued; + } + if (continued.value !== "absent") { + answeredNow(request.runId, asking); + if (continued.value.kind === "refused") { + return Err(refusalError(continued.value.refusal, request.runId)); + } + held.execution = continued.value.value.execution.executionId; + return Ok(yield* begun(held, continued.value.value)); + } + } + + // Nothing there. Making a fork needs the whole source, staged under this + // acquisition and committed in one transaction. + // Nothing has been mutated yet, so the guard is simply held across the + // source read and the staging: a cancellation here retains no question + // and leaves the acquisition usable. + const source = yield* readSource(host, request); + if (!source.ok) { + answeredNow(request.runId, addressed); + return source; + } + const staged = yield* offer(addressed, held.connection.lifecycle, source.value); + if (!staged.ok) { + return staged; + } + const command: RemoteForkCommit = { + commandId: commandOf(addressed, "commit"), + runId: request.runId, + retrieval: request.creation.retrieval, + creation, + origin: { + sourceRunId: source.value.sourceRunId, + checkpointEventId: source.value.checkpointEventId, + checkpointWorkspaceRootId: source.value.checkpointWorkspaceRootId, + runRecordWorkspaceRootId: source.value.runRecordWorkspaceRootId, + rootImportWorkspaceRootId: source.value.rootImportWorkspaceRootId, + anchor: source.value.anchor, + }, + counts: { + inherited: source.value.inherited.length, + roots: source.value.roots.length, + manifests: source.value.manifests.length, + blobs: source.value.blobs.length, + checkouts: source.value.checkouts.length, + }, + runRecord: head.runRecord, + rootImport: request.rootImport, + executionId: addressed.executionId, + }; + const committing = addressed; + // Kept with the exact bytes it was sent with, so a replacement + // acquisition resends this command rather than reading the source again. + grip.sending(() => unanswered(request.runId, committing, command)); + const answered = yield* held.connection.lifecycle.commitFork(command); + grip.answered(); + if (!answered.ok) { + if (lost(answered.error)) { + unanswered(request.runId, committing, command); + } else { + answeredNow(request.runId, committing); + } + return answered; + } + answeredNow(request.runId, committing); + if (answered.value === "needs-transfer") { + // Offered and still not there. Repeating the same transfer would meet + // the same answer. + return Err( + new WorkflowRequestError("this fork's transfer did not reach its destination."), + ); + } + if (answered.value.kind === "refused") { + return Err(refusalError(answered.value.refusal, request.runId)); + } + held.execution = answered.value.value.execution.executionId; + return Ok(yield* begun(held, answered.value.value)); + }); + }, + + *stageFork(request: WorkflowForkRequest): Operation> { + const source = yield* readSource(host, request); + if (!source.ok) { + return source; + } + // No destination acquisition, no destination owner, nothing a host would + // discover: the candidate is assembled locally and belongs to the scope + // that asked for it. + return yield* host.stage(request, source.value, headOf(request)); + }, + }; +} + +function* begun(held: Hold, answer: RemoteBegun): Operation { + const database = yield* useRemoteRunDatabase(held.connection.link, answer.frontier); + return { + database, + record: answer.frontier.record, + execution: answer.execution, + replay: answer.replay, + ...(answer.recovered === null ? {} : { recovered: answer.recovered }), + }; +} + +/** + * The two records a fork writes for itself. + * + * Its own run record, because the fork is its own run and the source's record + * describes the source; and the root import its own definition produced, + * because a fork that inherited the source's would run the source's document. + */ +function headOf(request: WorkflowForkRequest): { + readonly runRecord: DurableEvent; + readonly rootImport: DurableEvent; +} { + return { + runRecord: forkRunRecordEvent({ + runId: request.runId, + base: request.creation.base, + pinnedCommit: request.creation.definition.objectId, + }), + rootImport: request.rootImport, + }; +} + +/** One creation, as text: the fields a run's identity is compared by. */ +function creationShape(creation: CreateWorkflowRunRequest): string { + return canonicalJson({ + runId: creation.runId, + definition: definitionToJson(creation.definition), + base: creation.base, + props: creation.props, + }); +} + +/** + * One internal command's own identity within a logical call. + * + * A fork asks the destination more than one question — whether it already + * holds this fork, and then to commit the transfer — and the owner keys a + * retained decision by the identity it was asked under. One identity naming + * two different requests would meet its own earlier fingerprint and be refused + * as a repeat of something else, so each kind gets its own, derived from the + * call so that a retry spells it the same way. + */ +function commandOf(invocation: Ambiguous, kind: string): string { + return `${invocation.commandId}:${kind}`; +} + +/** + * The identity the second transfer of one fork is carried under. + * + * `needs-transfer` is an answer: the destination holds no run and was offered + * no parts, and the command that asked has been answered by name. Every + * command of what follows — the offers and the commit — is a question that + * name has never carried, so it takes a whole new identity rather than a + * variation on the answered one. The execution identity is new for the same + * reason: the answered decision began nothing, so there is nothing to inherit. + */ +function retransfer(host: RemoteLifecycleHost, question: string): Ambiguous { + return { + commandId: host.ids.command(), + executionId: host.ids.execution(), + question, + phase: "retransfer", + command: undefined, + }; +} + +/** + * What one invocation is asking, as text two calls can be compared by. + * + * Canonical, so the same question always spells the same way, and a different + * one never spells like it. This decides only whether a retry is the same + * logical invocation; what that identity already means is the owner's to say. + */ +function questionOf(parts: readonly (string | null)[]): string { + return canonicalJson([...parts]); +} + +/** Read one source through the accepted no-acquisition plane. */ +function* readSource( + host: RemoteLifecycleHost, + request: WorkflowForkRequest, +): Operation> { + if (request.selection.sourceRunId === "" || request.selection.checkpointEventId === "") { + return Err(new WorkflowRequestError("a fork names one source run and one checkpoint.")); + } + const plane = yield* host.source(request.selection.sourceRunId); + if (!plane.ok) { + return plane; + } + if (plane.value.runId !== request.selection.sourceRunId) { + return Err(new WorkflowRunNotFoundError(request.selection.sourceRunId)); + } + return yield* plane.value.forkSource(request.selection.checkpointEventId); +} + +/** + * Offer the whole snapshot as bounded parts, in the order it will be read back. + * + * Content crosses through the staging the publication path already uses, and + * the rest — the roots, the inherited rows, the checkouts — crosses as parts + * that name where they belong. Nothing here is a run: the final command decides + * whether these add up to one. + */ +function* offer( + invocation: Ambiguous, + lifecycle: RemoteLifecycleLink, + source: RemoteForkSource, +): Operation> { + const parts: RemoteForkPart[] = []; + source.roots.forEach((root, position) => { + parts.push({ + section: "roots", + position, + part: { + rootId: root.rootId, + formatVersion: root.formatVersion, + manifest: root.manifest, + manifestHashes: [...root.manifestHashes], + blobHashes: [...root.blobHashes], + }, + }); + }); + source.manifests.forEach((manifest, position) => { + // The metadata, not the bytes: the bytes cross through the content staging + // the publication path already uses, and what a digest cannot stand for is + // the watermark copied beside it. + parts.push({ + section: "manifests", + position, + part: { hash: manifest.hash, size: manifest.size, lastSeen: manifest.lastSeen }, + }); + }); + source.blobs.forEach((blob, position) => { + parts.push({ + section: "blobs", + position, + part: { hash: blob.hash, size: blob.size, lastSeen: blob.lastSeen }, + }); + }); + source.inherited.forEach((row, position) => { + parts.push({ + section: "inherited", + position, + part: { eventId: row.eventId, record: row.record, workspaceRootId: row.workspaceRootId }, + }); + }); + source.checkouts.forEach((checkout, position) => { + parts.push({ section: "checkouts", position, part: { ...checkout } }); + }); + for (const part of parts) { + // Named by the call and the place in it, so restaging the same logical + // fork offers the same parts under the same identities. + const staged = yield* lifecycle.stageForkPart( + commandOf(invocation, `part:${part.section}:${part.position}`), + part, + ); + if (!staged.ok) { + return staged; + } + } + return Ok(undefined); +} + +function creationOf( + runId: string, + creation: WorkflowBeginRequest["creation"], +): CreateWorkflowRunRequest | null { + return creation === undefined ? null : creationRequest(runId, creation); +} + +/** + * One creation, as the request storage retains. + * + * The definition and the props travel as the caller built them; what this adds + * is the run they belong to. Normalizing them is the owner's, through the same + * shared parser every host uses, so a remote run's identity is computed exactly + * where a local one's is. + */ +function creationRequest(runId: string, creation: WorkflowRunCreation): CreateWorkflowRunRequest { + return { + runId, + definition: creation.definition, + base: creation.base, + props: creation.props, + }; +} + +function refusalError(refusal: RemoteLifecycleRefusal, runId: string): WorkflowStorageError { + if (refusal === "damaged-terminal") { + return damagedTerminalRefusal(); + } + if (refusal === "cancelled") { + return new WorkflowRequestError(`workflow run ${JSON.stringify(runId)} was cancelled.`); + } + if (refusal === "resume-failed") { + return new WorkflowRequestError( + `workflow run ${JSON.stringify(runId)} failed, so it cannot be resumed.`, + ); + } + return new WorkflowRequestError( + `workflow run ${JSON.stringify(runId)} already reached a terminal outcome.`, + ); +} diff --git a/packages/workflow/src/remote/mappings.ts b/packages/workflow/src/remote/mappings.ts new file mode 100644 index 000000000..495c858e2 --- /dev/null +++ b/packages/workflow/src/remote/mappings.ts @@ -0,0 +1,255 @@ +/** + * Retained mappings, as one invocation on the runner sees them. + * + * The runner has no database. What it has is the coherent snapshot the owner + * admitted this invocation from, and whatever this invocation has staged since. + * That is enough to answer every question the shared composition rules ask, + * because those rules only ever read a mapping back and compare it — and this + * answers with the retained row when there is one, and with what this + * invocation staged when there is not. + * + * Read-your-writes without durability. A document that creates a Repository and + * then asks for it again is asking about its own work, and must see it; nothing + * about that makes it committed. Only the exact list handed through the live + * enlistment capability reaches an intent, and only the owner's transaction + * makes any of it authoritative. + * + * The reconciliation rules are not restated here. A same-name Repository is + * compared by the composition provider that already knows what compatible + * means, and an Agent session by `resolveAgentSession()`; this module decides + * only where a record comes from and what a new one stages. + */ + +import { + type AgentSessionRecord, + type AgentSessions, + WorkflowAgentSessionError, +} from "../storage/agent-session.ts"; +import type { WorktreeRecord } from "../composition/records.ts"; +import { parseCheckoutPath } from "../composition/records.ts"; +import { locatorFingerprintOf } from "../composition/locator.ts"; +import type { StoredRepository, WorkspaceMetadata } from "../workspace/metadata.ts"; +import type { RemoteInvocationSnapshot } from "./records.ts"; +import type { RetainedMapping } from "./publication.ts"; +import { WorkflowRecordMalformedError } from "../storage/errors.ts"; + +/** The most mappings one invocation may stage before it is refused. */ +const MAX_STAGED_MAPPINGS = 256; + +/** The most serialized bytes one invocation may stage before it is refused. */ +const MAX_STAGED_BYTES = 256 * 1024; + +/** + * What an invocation may reach, and what it has decided to retain. + * + * `deltas()` is the whole of what may be enlisted. It is a fresh array each + * time, deterministically ordered, so the caller cannot reach back into what + * the view is still holding. + */ +export interface InvocationMappings { + readonly metadata: WorkspaceMetadata; + readonly agentSessions: AgentSessions; + deltas(): readonly RetainedMapping[]; +} + +function refuse(reason: string): never { + throw new WorkflowRecordMalformedError("this run's retained mappings", reason); +} + +/** + * A copy that shares nothing with what it was given. + * + * These records are handed to a document and staged for a commit, and both hold + * them for longer than the call. A structural clone is what makes "the delta is + * what was staged" true rather than a description of what nobody mutated. + */ +function detach(value: T): T { + return structuredClone(value) as T; +} + +export function createInvocationMappings( + snapshot: RemoteInvocationSnapshot, + live: () => void, +): InvocationMappings { + const repositories = new Map(); + const worktrees = new Map(); + const sessions = new Map(); + for (const stored of snapshot.repositories) { + repositories.set(stored.record.name, stored); + } + for (const record of snapshot.worktrees) { + worktrees.set(worktreeKey(record.repositoryName, record.name), record); + } + for (const record of snapshot.agentSessions) { + sessions.set(record.sessionKey, record); + } + + const staged: RetainedMapping[] = []; + function stage(mapping: RetainedMapping): void { + if (staged.length >= MAX_STAGED_MAPPINGS) { + refuse("this invocation stages more retained mappings than one commit may carry"); + } + const next = [...staged, mapping]; + if (new TextEncoder().encode(JSON.stringify(next)).length > MAX_STAGED_BYTES) { + refuse("this invocation stages more retained mapping bytes than one commit may carry"); + } + staged.push(mapping); + } + + return { + metadata: { + readRepository(name: string): StoredRepository | undefined { + live(); + const found = repositories.get(name); + return found === undefined ? undefined : detach(found); + }, + + readRepositories(): StoredRepository[] { + live(); + return [...repositories.values()] + .toSorted((left, right) => compare(left.record.name, right.record.name)) + .map(detach); + }, + + insertRepository(stored: StoredRepository): void { + live(); + if (locatorFingerprintOf(stored.locator) !== stored.record.locatorFingerprint) { + refuse("a Repository was retained with a fingerprint its locator does not produce"); + } + if (parseCheckoutPath(stored.record.checkoutPath) === undefined) { + refuse("a Repository was retained with a checkout path this build does not admit"); + } + const existing = repositories.get(stored.record.name); + if (existing !== undefined) { + // The same insert twice in one invocation is the same fact stated + // twice. Anything else is a conflict, and a conflict never replaces + // what is already there. + if (!sameRepository(existing, stored)) { + refuse("a Repository name was retained twice under different identities"); + } + // Either the owner already holds it, or this invocation staged it + // earlier. Both mean there is nothing new to retain: a snapshot row + // re-sent as a mutation would ask the owner to insert what it has. + return; + } + const admitted = detach(stored); + repositories.set(admitted.record.name, admitted); + stage({ kind: "repository", record: admitted.record, locator: admitted.locator }); + }, + + readWorktree(repositoryName: string, name: string): WorktreeRecord | undefined { + live(); + const found = worktrees.get(worktreeKey(repositoryName, name)); + return found === undefined ? undefined : detach(found); + }, + + readWorktreesForRepository(repositoryName: string): WorktreeRecord[] { + live(); + return [...worktrees.values()] + .filter((record) => record.repositoryName === repositoryName) + .toSorted((left, right) => compare(left.name, right.name)) + .map(detach); + }, + + insertWorktree(record: WorktreeRecord): void { + live(); + if (parseCheckoutPath(record.checkoutPath) === undefined) { + refuse("a Worktree was retained with a checkout path this build does not admit"); + } + if (!repositories.has(record.repositoryName)) { + refuse("a Worktree was retained for a Repository this run does not hold"); + } + const key = worktreeKey(record.repositoryName, record.name); + const existing = worktrees.get(key); + if (existing !== undefined) { + if (!sameWorktree(existing, record)) { + refuse("a Worktree name was retained twice under different identities"); + } + return; + } + const admitted = detach(record); + worktrees.set(key, admitted); + stage({ kind: "worktree", record: admitted }); + }, + }, + + agentSessions: { + read(sessionKey: string): AgentSessionRecord | undefined { + live(); + const found = sessions.get(sessionKey); + return found === undefined ? undefined : detach(found); + }, + + commit(record: AgentSessionRecord): void { + live(); + const existing = sessions.get(record.sessionKey); + if (existing !== undefined) { + if (!sameSession(existing, record)) { + throw new WorkflowAgentSessionError( + "this run already retains a different Agent session under this identity, and a " + + "session established under one ceiling is not continued under another.", + ); + } + return; + } + const admitted = detach(record); + sessions.set(admitted.sessionKey, admitted); + stage({ kind: "agent-session", record: admitted }); + }, + }, + + deltas(): readonly RetainedMapping[] { + // Parents before children, then by name: the owner applies them in + // dependency order, and a deterministic list is what makes one + // invocation's proposal the same proposal on a retry. + const order = { repository: 0, worktree: 1, "agent-session": 2 } as const; + return staged + .map((mapping, index) => ({ mapping, index })) + .toSorted((left, right) => { + const kinds = order[left.mapping.kind] - order[right.mapping.kind]; + return kinds !== 0 ? kinds : left.index - right.index; + }) + .map((entry) => detach(entry.mapping)); + }, + }; +} + +function worktreeKey(repositoryName: string, name: string): string { + return `${repositoryName}\u0000${name}`; +} + +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function sameRepository(left: StoredRepository, right: StoredRepository): boolean { + return ( + left.locator === right.locator && + left.record.locatorFingerprint === right.record.locatorFingerprint && + left.record.requestedBase === right.record.requestedBase && + left.record.creationCommit === right.record.creationCommit && + left.record.primaryBranch === right.record.primaryBranch && + left.record.objectFormat === right.record.objectFormat && + left.record.checkoutPath === right.record.checkoutPath + ); +} + +function sameWorktree(left: WorktreeRecord, right: WorktreeRecord): boolean { + return ( + left.requestedBranch === right.requestedBranch && + left.requestedBase === right.requestedBase && + left.creationCommit === right.creationCommit && + left.checkoutPath === right.checkoutPath + ); +} + +function sameSession(left: AgentSessionRecord, right: AgentSessionRecord): boolean { + return ( + left.provider === right.provider && + left.agentCommand === right.agentCommand && + left.sessionIdentity === right.sessionIdentity && + left.policy === right.policy && + left.assertion.kind === right.assertion.kind && + left.assertion.value === right.assertion.value + ); +} diff --git a/packages/workflow/src/remote/materialize.ts b/packages/workflow/src/remote/materialize.ts new file mode 100644 index 000000000..d0523f0fc --- /dev/null +++ b/packages/workflow/src/remote/materialize.ts @@ -0,0 +1,367 @@ +/** + * Putting one retained Workspace root on a runner, and reading it back. + * + * The owner holds the run and cannot run anything. Git, an Agent, an evidence + * command — all of it needs real files, and real files are the runner's. So a + * root is materialized into a temporary tree the invocation owns, worked in, + * and captured back into a proposal the owner validates and publishes. + * + * Two things this module refuses to know. It does not know a runtime: every + * native operation arrives as an injected Effection operation, so the same code + * materializes onto whatever filesystem the host adapter wrapped. And it does + * not treat the host path as identity — the logical Workspace root is `/`, the + * temporary directory is an implementation detail of this invocation, and no + * part of the host path reaches a manifest, a journal event, a proposal or an + * error. A run that recorded where it happened to be unpacked would be a run + * that could not be resumed anywhere else. + * + * Everything is verified twice. The owner validated the root before sending it + * and the connection verified each piece on arrival; this verifies again on the + * way to disk, because what must be true is not "the owner was honest" but + * "these bytes are the bytes this root names". The same holds coming back: a + * capture is checked against the rules a stored root is read through before it + * is ever proposed. + */ + +import { type Operation } from "effection"; +import { + captureContent, + type CapturedContent, + type CapturedNode, + type CapturedRoot, + captureWorkspaceRoot, +} from "../workspace/capture.ts"; +import { + compareUtf8, + type WorkspaceRejection, + type WorkspaceRootManifest, +} from "../workspace/root-manifest.ts"; +import { decodeContentManifest } from "../workspace/content-manifest.ts"; +import { sha256Hex } from "../workspace/sha256.ts"; +import type { RemoteReadLink } from "./read.ts"; + +/** One node the runner found, as its host describes one. */ +export interface RunnerNode { + readonly name: string; + readonly kind: "directory" | "file" | "symlink"; + readonly mode: number; + /** Whole seconds, matching what a retained entry carries. */ + readonly mtime: number; + readonly size: number; + /** + * What makes two paths one file. + * + * The host's own answer — an inode, or whatever stands in for one. Absent + * means the host cannot say, and every file is then its own. + */ + readonly identity: string | undefined; + /** Present only for a symbolic link, and never followed. */ + readonly target: string | undefined; +} + +/** + * The native operations materialization needs, and only those. + * + * Deliberately small and deliberately injected. Nothing here opens a process, + * resolves a symbolic link, or reaches outside the directory it was given. + */ +export interface RunnerFiles { + makeDirectory(path: string, mode: number): Operation; + writeFile(path: string, bytes: Uint8Array, mode: number): Operation; + makeSymlink(target: string, path: string): Operation; + makeHardlink(existing: string, path: string): Operation; + /** + * Set permissions exactly, after creation. + * + * Creation modes are narrowed by the process umask, and a retained mode is + * durable identity rather than a preference. Applied to a directory only once + * its children exist, because a mode that forbids writing would otherwise + * forbid filling it. + */ + setMode(path: string, mode: number): Operation; + /** Applied last, because writing into a directory moves its own time. */ + setModifiedAt(path: string, mtime: number): Operation; + /** + * Set a link's own time without following it. + * + * Separate because a link's target may not exist, may be outside the tree, or + * may be something this code must never touch. `undefined` when the host + * cannot do it at all, which materialization reports rather than works around. + */ + readonly setLinkModifiedAt: ((path: string, mtime: number) => Operation) | undefined; + /** The same, for a link's own permissions. `undefined` where unsupported. */ + readonly setLinkMode: ((path: string, mode: number) => Operation) | undefined; + readFile(path: string): Operation; + /** + * Remove one tree this invocation owns, and everything under it. + * + * What an attempt is put back with. Only ever called with a directory this + * invocation materialized, and only by the attempt that owns it. + */ + removeTree(path: string): Operation; + /** One directory's entries, described without following a link. */ + list(path: string): Operation; + /** One path, described without following a link. */ + describe(path: string): Operation; +} + +/** Where one logical Workspace path sits on this host, for this invocation. */ +export type HostPath = (logical: string) => string; + +/** + * Materialize the exact root, one bounded piece at a time. + * + * Entries are created in canonical order, which is also parent-before-child + * order for everything but the depth ordering a restore needs — so directories + * are created as they are met and a file never arrives before the directory + * holding it. A hardlink group's first member is written and the rest are + * linked to it, which is what makes them one file again rather than copies. + * + * Times are set after the tree exists. Writing a file into a directory updates + * that directory's own time, so setting times as we went would leave every + * directory carrying the moment it was filled rather than the moment the root + * records. + */ +export function* materializeWorkspaceRoot( + files: RunnerFiles, + reads: RemoteReadLink, + at: HostPath, + workspaceRootId: string, + reject: WorkspaceRejection, +): Operation { + const manifest = yield* reads.root(workspaceRootId); + /** + * The first path written for each hardlink group. + * + * Keyed by the group the root declares, never by the content digest. Two + * groups may legally hold identical bytes and therefore share one manifest, + * and linking the second to the first would merge two files into one — a + * different Workspace, arriving under the identity of this one. + */ + const groups = new Map(); + const modes: { path: string; mode: number; link: boolean }[] = []; + const times: { path: string; mtime: number; link: boolean }[] = []; + + for (const entry of manifest.entries) { + const path = at(entry.path); + if (entry.kind === "directory") { + if (entry.path !== "/") { + yield* files.makeDirectory(path, entry.mode); + } + modes.push({ path, mode: entry.mode, link: false }); + times.push({ path, mtime: entry.mtime, link: false }); + continue; + } + if (entry.kind === "symlink") { + // Created, never followed. A retained link may point anywhere, including + // outside the tree, and resolving one here would be this code deciding to + // read something the Workspace merely mentions. + yield* files.makeSymlink(entry.target, path); + modes.push({ path, mode: entry.mode, link: true }); + times.push({ path, mtime: entry.mtime, link: true }); + continue; + } + + const first = entry.hardlink === null ? undefined : groups.get(entry.hardlink); + if (first !== undefined) { + // One inode reached by a second name. Its mode and time belong to the + // file, which the first member already carries. + yield* files.makeHardlink(first, path); + continue; + } + const bytes = yield* fetchFile(reads, workspaceRootId, entry.manifest, entry.size, reject); + yield* files.writeFile(path, bytes, entry.mode); + if (entry.hardlink !== null) { + groups.set(entry.hardlink, path); + } + modes.push({ path, mode: entry.mode, link: false }); + times.push({ path, mtime: entry.mtime, link: false }); + } + + // Modes before times and both deepest-first: a mode that forbids writing must + // not be applied while children are still arriving, and filling a directory + // moves a time that was already restored. + for (const entry of modes.toReversed()) { + if (!entry.link) { + yield* files.setMode(entry.path, entry.mode); + continue; + } + if (files.setLinkMode !== undefined) { + yield* files.setLinkMode(entry.path, entry.mode); + } + } + for (const entry of times.toReversed()) { + if (!entry.link) { + yield* files.setModifiedAt(entry.path, entry.mtime); + continue; + } + if (files.setLinkModifiedAt !== undefined) { + yield* files.setLinkModifiedAt(entry.path, entry.mtime); + } + } + + // Proved rather than assumed. A host that cannot represent a legal retained + // mode or time must say so here, before anything executes against this tree — + // silently normalizing one would hand the run a Workspace with a different + // durable identity than the history it accepted. + yield* requireExactMaterialization(files, at, manifest, reject); + return manifest; +} + +/** + * Whether what is on disk is what the root said. + * + * Every entry's kind, mode and time, read back without following a link. This + * is not defensive duplication: umask, platform link semantics and filesystem + * timestamp granularity are all real, and each of them turns one retained root + * into a different one quietly. The refusal names what disagreed, not where the + * tree happens to live. + */ +function* requireExactMaterialization( + files: RunnerFiles, + at: HostPath, + manifest: WorkspaceRootManifest, + reject: WorkspaceRejection, +): Operation { + for (const entry of manifest.entries) { + const found = yield* files.describe(at(entry.path)); + if (found.kind !== entry.kind) { + reject(`this host materialized a ${entry.kind} as a ${found.kind}`); + } + if (found.mode !== entry.mode) { + reject(`this host cannot preserve the retained mode of a ${entry.kind}`); + } + if (found.mtime !== entry.mtime) { + reject(`this host cannot preserve the retained modification time of a ${entry.kind}`); + } + } +} + +/** One file's bytes, assembled from the chunks its manifest names. */ +function* fetchFile( + reads: RemoteReadLink, + workspaceRootId: string, + manifestDigest: string, + size: number, + reject: WorkspaceRejection, +): Operation { + const encoded = yield* reads.content(workspaceRootId, { + kind: "manifest", + digest: manifestDigest, + }); + const manifest = decodeContentManifest(encoded.bytes, reject); + if (manifest.size !== size) { + reject("a retained Workspace file size disagrees with the manifest it names"); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of manifest.chunks) { + const piece = yield* reads.content(workspaceRootId, { + kind: "blob", + digest: chunk.hash, + manifestDigest, + }); + if (piece.bytes.length !== chunk.size) { + reject("a retained content piece is not the size its manifest declares"); + } + bytes.set(piece.bytes, offset); + offset += piece.bytes.length; + } + if (offset !== size) { + reject("a retained Workspace file is not the size its entry declares"); + } + return bytes; +} + +/** What a capture produced, and the content it must be able to supply. */ +export interface CapturedWorkspace { + readonly root: CapturedRoot; + readonly contents: ReadonlyMap; + /** Every blob identity, with the bytes to send if the owner lacks it. */ + readonly blobs: ReadonlyMap; +} + +/** + * Read the tree back as the root it now describes. + * + * A walk, then the shared rules. Nothing here decides ordering, numbering or + * encoding — those belong to the capture rules both hosts share, so that this + * walk and the local provider's walk of its own tables cannot drift apart. + */ +export function* captureWorkspace( + files: RunnerFiles, + at: HostPath, + reject: WorkspaceRejection, +): Operation { + const nodes: CapturedNode[] = []; + const contents = new Map(); + const blobs = new Map(); + + function* visit(logical: string): Operation { + const found = yield* files.list(at(logical)); + for (const node of found.toSorted((left, right) => compareUtf8(left.name, right.name))) { + const path = logical === "/" ? `/${node.name}` : `${logical}/${node.name}`; + if (node.kind === "directory") { + nodes.push({ path, kind: "directory", mode: node.mode, mtime: node.mtime }); + yield* visit(path); + continue; + } + if (node.kind === "symlink") { + if (node.target === undefined) { + reject("a Workspace symbolic link has no target"); + } + nodes.push({ + path, + kind: "symlink", + mode: node.mode, + mtime: node.mtime, + target: node.target, + }); + continue; + } + const bytes = yield* files.readFile(at(path)); + if (bytes.length !== node.size) { + reject("a Workspace file changed size while it was being captured"); + } + const content = captureContent(bytes); + if (!contents.has(content.manifest)) { + contents.set(content.manifest, content); + let offset = 0; + for (const chunk of content.chunks) { + blobs.set(chunk.hash, bytes.slice(offset, offset + chunk.size)); + offset += chunk.size; + } + } + nodes.push({ + path, + kind: "file", + mode: node.mode, + mtime: node.mtime, + size: node.size, + manifest: content.manifest, + identity: node.identity, + }); + } + } + + // The root directory is part of the root's identity like any other entry, so + // its own mode and time are read rather than assumed. + const top = yield* files.describe(at("/")); + if (top.kind !== "directory") { + reject("a Workspace root is not a directory"); + } + nodes.push({ path: "/", kind: "directory", mode: top.mode, mtime: top.mtime }); + yield* visit("/"); + + return { root: captureWorkspaceRoot(nodes, contents, reject), contents, blobs }; +} + +/** Whether a captured root is the one it was materialized from. */ +export function unchangedFrom(captured: CapturedRoot, workspaceRootId: string): boolean { + return captured.rootId === workspaceRootId; +} + +/** The digest of a piece the runner is about to offer. */ +export function pieceDigest(bytes: Uint8Array): string { + return sha256Hex(bytes); +} diff --git a/packages/workflow/src/remote/publication.ts b/packages/workflow/src/remote/publication.ts new file mode 100644 index 000000000..2c7371b5b --- /dev/null +++ b/packages/workflow/src/remote/publication.ts @@ -0,0 +1,93 @@ +/** + * What a runner proposes when a transaction changed the Workspace. + * + * A transaction that only appended to the journal proposes nothing here: the + * run's Workspace is where it was, and saying otherwise would invent a mutation + * to make the shape uniform. When the Workspace did change, exactly one of + * these describes the whole change — the root that was started from, the + * canonical root now proposed, the content that root closes over, and the + * retained mappings the same operation produced. + * + * Everything here is semantic. There is no command, no correlation id, no + * base64, no staged row, no SQL, no socket and no path on the runner. The + * adapter beneath translates this into whatever its owner speaks; a neutral + * value that carried transport vocabulary would make every other host implement + * this one's transport. + * + * The inventory is exact rather than advisory. It names every manifest and blob + * the proposed root closes over, once each, in canonical order — not the pieces + * that happen to be new. The owner resolves each identity from content it + * already holds or from what this acquisition staged, and an inventory that + * named more or fewer would be a root whose content nobody agreed on. + */ + +import type { RepositoryRecord, WorktreeRecord } from "../composition/records.ts"; +import type { AgentSessionRecord } from "../storage/agent-session.ts"; + +/** One content identity a proposed root closes over. */ +export interface ProposedContent { + readonly kind: "manifest" | "blob"; + readonly digest: string; + readonly size: number; +} + +/** + * One complete Workspace change, as the owner will receive it. + * + * `proposedWorkspaceRootId` is not taken on trust: it is what the runner + * computed, and the owner recomputes it from the manifest before anything is + * adopted. Carrying it makes the disagreement detectable rather than making the + * owner guess what the runner thought it was proposing. + */ +export interface WorkspacePublication { + readonly proposedWorkspaceRootId: string; + /** The canonical root manifest, exactly as it was encoded and hashed. */ + readonly proposedManifest: string; + readonly content: readonly ProposedContent[]; +} + +/** + * One retained mapping the same operation produced. + * + * A Repository or Worktree row and the Workspace bytes that make its checkout + * true are one proposal: a mapping naming a checkout that does not exist, or a + * checkout no mapping accounts for, is a Workspace that only half happened. + * + * An Agent-session mapping carries the provider's canonical assertion and the + * derived key, and nothing of the conversation itself. The owner retains what + * the run established; it never contacts or impersonates an Agent provider. + */ +export type RetainedMapping = + | { + readonly kind: "repository"; + readonly record: RepositoryRecord; + /** + * The admitted locator, which the record deliberately does not carry. + * + * A record is journal-safe and names only the fingerprint, because a + * locator can carry a credential and a journal is history. Storage needs + * the real thing to reattach, so it travels beside the record and its + * fingerprint must follow from it. + */ + readonly locator: string; + } + | { readonly kind: "worktree"; readonly record: WorktreeRecord } + | { readonly kind: "agent-session"; readonly record: AgentSessionRecord }; + +/** + * What the owner did, as the runner is allowed to know it. + * + * Returned by a performed commit and by nothing else. It names the root the + * commit selected and the identities the owner minted for the events it + * retained, which is what makes it checkable: a runner can compare the answer + * with the proposal it sent and refuse an owner that agreed to something else. + * + * It is also the only thing that authorizes a local promotion. Passing it is + * how an attempt proves the owner published *that* Workspace — a promotion + * that took no evidence would be the runner deciding on the owner's behalf, + * and the two would disagree the first time a commit was refused. + */ +export interface CommitDecision { + readonly workspaceRootId: string; + readonly journalEventIds: readonly string[]; +} diff --git a/packages/workflow/src/remote/read.ts b/packages/workflow/src/remote/read.ts new file mode 100644 index 000000000..6d2fee703 --- /dev/null +++ b/packages/workflow/src/remote/read.ts @@ -0,0 +1,216 @@ +/** + * What a runner may read from the owner of its run. + * + * Semantic values, not messages. The seam speaks in workflow records, Workspace + * roots and content identities; how those are asked for, what a page is, and + * which refusals exist are the adapter's, below this line. That division is + * what lets a second host implement the same reads without this module learning + * anything about it — and what stops paging mechanics leaking into the code + * that only wanted the frontier. + * + * The frontier snapshot is deliberately richer than `StartingFrontier`. A + * transaction needs the root, the anchor and the events; a database handle will + * also need the run record and its retrieval snapshot. Modelling both as one + * value would make the collector carry members it has no business reading, so + * the richer value is separate and maps down to the smaller one. + * + * Nothing here is exported from the package. A read seam a document or a runner + * could name would be a second place deciding what a run may see. + */ + +import type { Operation } from "effection"; +import type { RemoteInvocationSnapshot } from "./records.ts"; +import type { Result } from "effection"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import type { JournalEntry } from "../storage/api.ts"; +import type { + DefinitionRetrieval, + DocumentExecutionRecord, + WorkflowRunRecord, +} from "../storage/record.ts"; +import type { WorkspaceRootManifest } from "../workspace/root-manifest.ts"; +import type { StartingFrontier } from "./collector.ts"; + +export interface RemoteFrontierSnapshot { + readonly record: WorkflowRunRecord; + readonly retrieval: DefinitionRetrieval | undefined; + readonly workspaceRootId: string; + readonly journalEventId: string | null; + readonly entries: readonly JournalEntry[]; +} + +export interface RemoteContent { + readonly kind: "manifest" | "blob"; + readonly digest: string; + readonly bytes: Uint8Array; +} + +export type RemoteContentRequest = + | { readonly kind: "manifest"; readonly digest: string } + | { readonly kind: "blob"; readonly digest: string; readonly manifestDigest: string }; + +export interface RemoteReadLink { + frontier(): Operation; + /** The one coherent admitted state a Workspace invocation begins from. */ + invocationSnapshot(): Operation; + root(workspaceRootId: string): Operation; + content(workspaceRootId: string, request: RemoteContentRequest): Operation; +} + +export function startingFrontier(snapshot: RemoteFrontierSnapshot): StartingFrontier { + return { + workspaceRootId: snapshot.workspaceRootId, + journalEventId: snapshot.journalEventId, + events: snapshot.entries.map((entry) => structuredClone(entry.event)), + }; +} + +/** Where one inherited row came from. Rows a run wrote itself have none. */ +export interface RetainedProvenance { + readonly sourceRunId: string; + readonly sourceEventId: string; +} + +/** One retained journal row, parsed but not yet projected. */ +export interface RetainedRow { + readonly eventId: string; + readonly event: DurableEvent; + readonly workspaceRootId: string; +} + +/** Everything one anchored history sequence produced, once it terminated. */ +export interface RetainedHistory { + readonly entries: readonly RetainedRow[]; + readonly retainedRoots: ReadonlySet; + readonly inherited: ReadonlyMap; +} + +/** One run's committed state, as the owner reported it. */ +export interface RetainedInspection { + readonly record: WorkflowRunRecord; + readonly executions: readonly DocumentExecutionRecord[]; + readonly retrieval?: DefinitionRetrieval; + readonly journalFrontier?: { readonly eventId: string; readonly workspaceRootId: string }; + readonly currentWorkspaceRootId: string; + readonly lineage?: { + readonly sourceRunId: string; + readonly checkpointEventId: string; + readonly checkpointWorkspaceRootId: string; + }; +} + +/** + * What the runner may ask this owner for, without taking it. + * + * Both answer with parsed retained values rather than public projections: the + * projection is provider-neutral and belongs on the runner, so there is one + * meaning of a history rather than one per adapter. + */ +export interface RemoteReadPlane { + /** + * The one run this plane was opened for. + * + * Descriptive, and compared rather than trusted: it lets a provider refuse a + * request for another run before it reaches the owner. It authorizes + * nothing — the plane can only ever answer about the run it was built with. + */ + readonly runId: string; + inspect(): Operation>; + history(): Operation>; + /** + * Everything a fork must copy out of this run at one checkpoint. + * + * Private: a destination transition calls it through a narrow internal + * accessor. It is copy data and not authority — the destination still needs + * its own live acquisition and its own atomic commit. + */ + forkSource(checkpointEventId: string): Operation>; +} + +/** + * One inherited row, as a fork must copy it. + * + * The exact retained record string, not only the event it parses to. A record + * is validated before it is accepted, but two different spellings can parse to + * one event, and a destination inserting a reconstructed spelling would retain + * history that is not the history it inherited. A destination writes this + * string into its journal as it stands, with nothing re-encoded. Public + * history projects the parsed event and never carries it. + */ +export interface RemoteForkRow { + readonly eventId: string; + /** The retained record, byte for byte. */ + readonly record: string; + readonly workspaceRootId: string; +} + +/** One DOFS manifest, as content a fork must hold for itself. */ +export interface RemoteManifest { + readonly hash: string; + readonly size: number; + readonly lastSeen: number; + readonly encoded: Uint8Array; +} + +/** One DOFS blob and its bytes. */ +export interface RemoteBlob { + readonly hash: string; + readonly size: number; + readonly lastSeen: number; + readonly content: Uint8Array; +} + +/** One immutable Workspace root the selected prefix requires. */ +export interface RemoteStoredRoot { + readonly rootId: string; + readonly formatVersion: number; + readonly manifest: string; + readonly manifestHashes: readonly string[]; + readonly blobHashes: readonly string[]; +} + +/** One checkout the checkpoint's Workspace holds, as a fork inherits it. */ +export type RemoteCheckout = + | { + readonly kind: "repository"; + readonly name: string; + readonly locator: string; + readonly locatorFingerprint: string; + readonly requestedBase: string | null; + readonly creationCommit: string; + readonly primaryBranch: string; + readonly objectFormat: string; + readonly checkoutPath: string; + } + | { + readonly kind: "worktree"; + readonly repositoryName: string; + readonly name: string; + readonly requestedBranch: string; + readonly requestedBase: string | null; + readonly creationCommit: string; + readonly checkoutPath: string; + }; + +/** Everything one checkpoint hands a fork, read in one committed selection. */ +export interface RemoteForkSource { + readonly sourceRunId: string; + /** + * The selection this snapshot was read under. + * + * Every page of it agreed on this, so it identifies the committed state the + * whole snapshot describes. A destination keeps it with what it copied, which + * is what lets a retry say whether it is the same transfer. + */ + readonly anchor: string; + readonly checkpointEventId: string; + readonly checkpointWorkspaceRootId: string; + readonly runRecordWorkspaceRootId: string; + readonly rootImportWorkspaceRootId: string; + /** The prefix without the two rows the fork writes for itself. */ + readonly inherited: readonly RemoteForkRow[]; + readonly roots: readonly RemoteStoredRoot[]; + readonly manifests: readonly RemoteManifest[]; + readonly blobs: readonly RemoteBlob[]; + readonly checkouts: readonly RemoteCheckout[]; +} diff --git a/packages/workflow/src/remote/records.ts b/packages/workflow/src/remote/records.ts new file mode 100644 index 000000000..3bb3383c8 --- /dev/null +++ b/packages/workflow/src/remote/records.ts @@ -0,0 +1,334 @@ +/** + * Reading a workflow record that arrived over a connection. + * + * The owner is the same build and is trusted to be honest; it is not trusted to + * be correct, and neither is the wire between them. A performed answer is an + * answer the owner labelled performed — that is all it is — so nothing here + * turns an `unknown` into a run record, a retrieval or a journal entry without + * checking every member first. + * + * The parsers the local host holds its own rows to are the parsers used here. + * Two readings of one record is how the two hosts would stop agreeing about + * what a run is, and the second reading is always the more permissive one. + * + * A failure names the member and never the value. What crossed the connection + * is retained history, and a record that does not parse is not a reason to + * repeat what it held. + */ + +import { parseDurableEvent } from "@executablemd/durable-streams"; +import type { JournalEntry } from "../storage/api.ts"; +import { parseWorkflowDefinition } from "../storage/definition.ts"; +import { + parseJsonObject, + parseJsonValue, + parseMembers, + parseStringMember, + requireMemberNames, +} from "../storage/members.ts"; +import { + type DefinitionRetrieval, + type DocumentExecutionRecord, + parseRunId, + parseWorkflowRunStatus, + parseWorkflowStopReason, + type WorkflowRunRecord, +} from "../storage/record.ts"; +import { SHA256 } from "../workspace/root-manifest.ts"; +import { admitLocator, locatorFingerprintOf } from "../composition/locator.ts"; +import { + parseRepositoryRecord, + parseWorktreeRecord, + type WorktreeRecord, +} from "../composition/records.ts"; +import { type AgentSessionRecord, parseAgentSessionRecord } from "../storage/agent-session.ts"; +import type { StoredRepository } from "../workspace/metadata.ts"; + +export class RemoteRecordError extends Error { + override name = "RemoteRecordError"; +} + +function fail(reason: string, path: string): Error { + return new RemoteRecordError( + `the owner returned a malformed workflow record at ${path}: ${reason}`, + ); +} + +function instant(value: unknown, path: string): string { + if (typeof value !== "string") { + throw fail("expected an instant", path); + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) { + throw fail("expected an instant", path); + } + return value; +} + +function positiveInteger(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) { + throw fail("expected a positive whole number", path); + } + return value; +} + +function rootId(value: unknown, path: string): string { + if (typeof value !== "string" || !SHA256.test(value)) { + throw fail("expected a Workspace root identity", path); + } + return value; +} + +export function parseRemoteRunRecord(value: unknown): WorkflowRunRecord { + const members = parseMembers(value, "$", fail); + requireMemberNames( + members, + ["runId", "definition", "base", "props", "status", "stopReason", "createdAt", "updatedAt"], + "$", + fail, + ); + const definition = parseWorkflowDefinition(members.get("definition")); + if (!definition.ok) { + throw fail("expected a workflow definition", "$.definition"); + } + const base = parseStringMember(members, "base", "$", fail); + if (base === "") { + throw fail("expected a non-empty string", "$.base"); + } + const record: WorkflowRunRecord = { + runId: parseRunId(members.get("runId"), "$.runId", fail), + definition: definition.value, + base, + props: parseJsonObject(members.get("props"), "$.props", fail), + status: parseWorkflowRunStatus(members.get("status"), "$.status", fail), + createdAt: instant(members.get("createdAt"), "$.createdAt"), + updatedAt: instant(members.get("updatedAt"), "$.updatedAt"), + }; + if (!members.has("stopReason")) { + return Object.freeze(record); + } + return Object.freeze({ + ...record, + stopReason: parseWorkflowStopReason(members.get("stopReason"), "$.stopReason", fail), + }); +} + +export function parseRemoteRetrieval(value: unknown): DefinitionRetrieval | undefined { + if (value === null) { + return undefined; + } + const members = parseMembers(value, "$", fail); + requireMemberNames(members, ["metadata", "revision", "updatedAt"], "$", fail); + if (members.size !== 3) { + throw fail("expected every retrieval member", "$ "); + } + return Object.freeze({ + metadata: parseJsonValue(members.get("metadata"), "$.metadata", fail), + revision: positiveInteger(members.get("revision"), "$.revision"), + updatedAt: instant(members.get("updatedAt"), "$.updatedAt"), + }); +} + +export function parseRemoteJournalEntry(value: unknown): JournalEntry { + const members = parseMembers(value, "$", fail); + requireMemberNames(members, ["eventId", "record", "workspaceRootId"], "$", fail); + if (members.size !== 3) { + throw fail("expected every journal member", "$ "); + } + const eventId = parseStringMember(members, "eventId", "$", fail); + if (eventId === "") { + throw fail("expected a non-empty identity", "$.eventId"); + } + const record = parseStringMember(members, "record", "$", fail); + const event = parseDurableEvent(record); + if (!event.ok) { + throw fail("expected a durable event", "$.record"); + } + return Object.freeze({ + eventId, + event: event.value, + workspaceRootId: rootId(members.get("workspaceRootId"), "$.workspaceRootId"), + }); +} + +/** + * One document execution, read out of a value nothing has checked. + * + * The shared rules the local host holds its own rows to, applied to what + * arrived. A stopped execution has to carry its status, and a stop reason has + * to agree with the way the record spells one — an execution that stopped for a + * reason the shape does not admit is not a record this build can act on. + */ +export function parseRemoteExecution(value: unknown): DocumentExecutionRecord { + const found = parseMembers(value, "$", fail); + // One of exactly two legal shapes. A record carrying a stop status without + // having stopped, or an undeclared member, is a shape this build does not + // understand — and reading it leniently would make a history that means one + // thing here and another where it was written. + const active = ["executionId", "startedAt"]; + const stopped = [...active, "stoppedAt", "stopStatus"]; + const declared = found.has("stoppedAt") + ? found.has("stopReason") + ? [...stopped, "stopReason"] + : stopped + : active; + requireMemberNames(found, declared, "$", fail); + if (found.size !== declared.length) { + throw fail("expected exactly the members this shape declares", "$"); + } + + const executionId = parseStringMember(found, "executionId", "$", fail); + if (executionId === "") { + throw fail("expected a non-empty identity", "$.executionId"); + } + const record: DocumentExecutionRecord = { + executionId, + startedAt: instant(found.get("startedAt"), "$.startedAt"), + }; + if (!found.has("stoppedAt")) { + return Object.freeze(record); + } + const halted: DocumentExecutionRecord = { + ...record, + stoppedAt: instant(found.get("stoppedAt"), "$.stoppedAt"), + stopStatus: parseWorkflowRunStatus(found.get("stopStatus"), "$.stopStatus", fail), + }; + if (!found.has("stopReason")) { + return Object.freeze(halted); + } + return Object.freeze({ + ...halted, + stopReason: parseWorkflowStopReason(found.get("stopReason"), "$.stopReason", fail), + }); +} + +/** + * One admitted invocation snapshot, as the runner is allowed to read it. + * + * The root and journal anchor travel with the mappings because they are one + * fact, and the runner holds the whole answer to that: before a document runs, + * the transaction it runs inside has to start from exactly this root and this + * anchor. + */ +export interface RemoteInvocationSnapshot { + readonly workspaceRootId: string; + readonly journalEventId: string | null; + readonly repositories: readonly StoredRepository[]; + readonly worktrees: readonly WorktreeRecord[]; + readonly agentSessions: readonly AgentSessionRecord[]; +} + +/** The most mapping entries one admitted snapshot may carry. */ +const MAX_SNAPSHOT_ENTRIES = 256; + +export function parseRemoteInvocationSnapshot(value: unknown): RemoteInvocationSnapshot { + const found = parseMembers(value, "$", fail); + requireMemberNames( + found, + ["workspaceRootId", "journalEventId", "repositories", "worktrees", "agentSessions"], + "$", + fail, + ); + const workspaceRootId = parseStringMember(found, "workspaceRootId", "$", fail); + if (!SHA256.test(workspaceRootId)) { + throw fail("expected a Workspace root identity", "$.workspaceRootId"); + } + const anchor = found.get("journalEventId"); + if (anchor !== null && (typeof anchor !== "string" || anchor === "")) { + throw fail("expected a journal event identity or an explicit empty anchor", "$.journalEventId"); + } + + const repositories = list(found.get("repositories"), "$.repositories").map((entry, index) => + parseStoredRepository(entry, `$.repositories[${index}]`), + ); + const worktrees = list(found.get("worktrees"), "$.worktrees").map((entry, index) => + admitted(parseWorktreeRecord(entry), `$.worktrees[${index}]`, "a Worktree"), + ); + const agentSessions = list(found.get("agentSessions"), "$.agentSessions").map((entry, index) => + admitted(parseAgentSessionRecord(entry), `$.agentSessions[${index}]`, "an Agent session"), + ); + + if (repositories.length + worktrees.length + agentSessions.length > MAX_SNAPSHOT_ENTRIES) { + throw fail("expected fewer retained mappings than one snapshot may carry", "$"); + } + requireOrdered( + repositories.map((stored) => stored.record.name), + "$.repositories", + ); + requireOrdered( + worktrees.map((record) => `${record.repositoryName} ${record.name}`), + "$.worktrees", + ); + requireOrdered( + agentSessions.map((record) => record.sessionKey), + "$.agentSessions", + ); + // Every Worktree names a Repository this snapshot also carries. A checkout + // whose Repository is missing is not a state this run was ever in. + const names = new Set(repositories.map((stored) => stored.record.name)); + for (const [index, record] of worktrees.entries()) { + if (!names.has(record.repositoryName)) { + throw fail( + "expected a Worktree whose Repository this snapshot holds", + `$.worktrees[${index}]`, + ); + } + } + return Object.freeze({ + workspaceRootId, + journalEventId: anchor === null ? null : anchor, + repositories: Object.freeze(repositories), + worktrees: Object.freeze(worktrees), + agentSessions: Object.freeze(agentSessions), + }); +} + +function list(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) { + throw fail("expected an array", path); + } + return value; +} + +function admitted(parsed: T | undefined, path: string, expectation: string): T { + if (parsed === undefined) { + throw fail(`expected ${expectation}`, path); + } + return parsed; +} + +/** + * Deterministic and without repeats, checked rather than assumed. + * + * The owner reads these in one order; a snapshot that arrived in another, or + * twice under one name, is not the state it claims to describe — and a mapping + * view built from it would answer differently depending on which copy it read. + */ +function requireOrdered(keys: readonly string[], path: string): void { + for (const [index, key] of keys.entries()) { + const previous = keys[index - 1]; + if (previous !== undefined && previous >= key) { + throw fail("expected retained mappings in one deterministic order, without repeats", path); + } + } +} + +function parseStoredRepository(value: unknown, path: string): StoredRepository { + const found = parseMembers(value, path, fail); + requireMemberNames(found, ["record", "locator"], path, fail); + const locator = parseStringMember(found, "locator", path, fail); + // Admitted by the same rule the local host admits one by, so a locator this + // build would refuse to use never becomes one it reconciles against. + if (admitLocator(locator) === undefined) { + throw fail("expected a Repository locator this build admits", `${path}.locator`); + } + const record = admitted( + parseRepositoryRecord(found.get("record")), + `${path}.record`, + "a Repository", + ); + if (locatorFingerprintOf(locator) !== record.locatorFingerprint) { + throw fail("expected a locator the record's fingerprint follows from", `${path}.locator`); + } + return Object.freeze({ record, locator }); +} diff --git a/packages/workflow/src/remote/seal.ts b/packages/workflow/src/remote/seal.ts new file mode 100644 index 000000000..07f86f46b --- /dev/null +++ b/packages/workflow/src/remote/seal.ts @@ -0,0 +1,41 @@ +/** + * How a transaction reaches into the attempt it was given, and nothing else can. + * + * A transaction needs two things from a disposable attempt: to seal it into a + * proposal once the body has finished, and to make it the accepted Workspace + * once the owner has performed that exact proposal. Neither may be offered to + * whoever is running the body — a capability handed out is a capability that + * can be used at the wrong moment, and the wrong moment here is any moment + * before the owner has decided. + * + * So they hang off a symbol. A symbol cannot be written down by code that does + * not already have it, this module is reachable from no package entrypoint, and + * the declared `Attempt` says nothing about it. What a caller receives is a + * place to work and a way to read what it did. + */ + +import type { Operation } from "effection"; +import type { CommitDecision, RetainedMapping, WorkspacePublication } from "./publication.ts"; + +/** The key a transaction reaches an attempt's own machinery through. */ +export const SEAL: unique symbol = Symbol("executablemd.workflow.remote.seal"); + +/** One attempt, sealed into the proposal the owner will decide. */ +export interface SealedProposal { + readonly publication: WorkspacePublication; + readonly mappings: readonly RetainedMapping[]; + readonly bytes: ReadonlyMap; + /** + * Make the sealed attempt the accepted Workspace. + * + * Called once, by the transaction, after the owner performed this exact + * proposal and the answer was checked against it. The decision is compared + * with what was sealed, so an answer about another Workspace moves nothing. + */ + transfer(decision: CommitDecision): Operation; +} + +/** What an attempt privately offers the transaction that was given it. */ +export interface SealableAttempt { + readonly [SEAL]: (mappings: readonly RetainedMapping[]) => Operation; +} diff --git a/packages/workflow/src/remote/storage.ts b/packages/workflow/src/remote/storage.ts new file mode 100644 index 000000000..6c9a68f02 --- /dev/null +++ b/packages/workflow/src/remote/storage.ts @@ -0,0 +1,75 @@ +/** + * Finding and creating a run whose storage is somewhere else. + * + * The local provider answers "where is this run" with a path and then opens a + * file. Here the question is already answered before a command is sent: the + * connection was admitted for one run, so the owner on the other end of it *is* + * that run's storage. What is left is the same pair of questions the local + * provider asks — is there a run here, and is it this run — asked of the owner + * in one command so the answer cannot be assembled from two moments. + * + * `lookup()` creates nothing. `create()` is lookup-or-create, and repeating a + * byte-compatible creation returns the run that is already there rather than + * making a second one. Neither hands back the link, the connection or the + * acquisition: what a caller receives is the same scope-owned + * `WorkflowRunDatabase` the local provider returns. + * + * One argument, deliberately. Opening a run and operating on it are the same + * authority; an opener admitted for one owner paired with another owner's link + * would authorize a create against the first and return a database backed by + * the second, and matching run ids would make that look correct. + */ + +import { Ok, type Operation, type Result } from "effection"; +import type { WorkflowRunDatabase } from "../storage/api.ts"; +import { WorkflowRunStorage } from "../storage/api.ts"; +import { checkRunId, parseCreateRequest } from "../storage/create-request.ts"; +import { useRemoteRunDatabase, type RemoteWorkspaceLink } from "./database.ts"; + +/** + * Install `WorkflowRunStorage` over one admitted owner. + * + * The link is the one the connection created, so every run this provider can + * open is the run that connection was admitted for. There is no registry and + * nothing to route: a request naming another run is refused by the owner, + * which is the only thing that knows what it holds. + */ +export function useRemoteRunStorage(link: RemoteWorkspaceLink): Operation { + return WorkflowRunStorage.around( + { + *create([request]): Operation> { + // Checked here as well as on the owner. A request this build would not + // send is not one it asks an owner to refuse. + const checked = parseCreateRequest(request); + if (!checked.ok) { + return checked; + } + // The parsed value, never the object it came from. A getter could + // answer one identity while this validates and another while the + // request is serialized, and the owner reparsing the second one would + // retain a run nothing here admitted. + const opened = yield* link.open(checked.value.runId, checked.value); + if (!opened.ok) { + return opened; + } + // Built from that exact answer. A second frontier read would assemble + // the handle from two owner observations, and could fail outside the + // `Result` this interface promises. + return Ok(yield* useRemoteRunDatabase(link, opened.value)); + }, + + *lookup([runId]): Operation> { + const checked = checkRunId(runId); + if (!checked.ok) { + return checked; + } + const opened = yield* link.open(checked.value, null); + if (!opened.ok) { + return opened; + } + return Ok(yield* useRemoteRunDatabase(link, opened.value)); + }, + }, + { at: "min" }, + ); +} diff --git a/packages/workflow/src/remote/workspace.ts b/packages/workflow/src/remote/workspace.ts new file mode 100644 index 000000000..f82a4ef9a --- /dev/null +++ b/packages/workflow/src/remote/workspace.ts @@ -0,0 +1,620 @@ +/** + * Running Workspace work on the runner, against a run the owner holds. + * + * The Deno coordinator opens a transaction, hands a mutation the authoritative + * filesystem and the retained metadata, and commits both together. This is the + * same shape with the storage somewhere else: the Workspace is a real directory + * this invocation materialized from the exact admitted root, the metadata is a + * detached snapshot of that same admitted state, and "commit" is one intent the + * owner performs atomically or not at all. + * + * The ordering is the whole of the correctness argument, so it is written out + * rather than implied: + * + * 1. The execution identity is claimed by one database before any effect is + * created, so a foreign or reused one cannot coordinate. + * 2. One coherent snapshot is admitted: root, journal anchor and mappings + * together, because they are one state. + * 3. The accepted tree and the disposable attempt are created *outside* the + * transaction, because the collector seals the attempt after the transaction + * body has torn down — an attempt scoped to the body would be gone by then. + * 4. Inside the exact transaction callback, the route proves it starts from the + * admitted state. Drift refuses here, before the document runs and before + * anything is sent. + * 5. The document runs once. A documented Workspace failure is the effect's own + * result and journals against the unchanged root; everything else is the run + * failing and publishes nothing. + * 6. Only a successful result enlists the attempt and its staged deltas, and + * the publication is routed into this exact transaction's journal. + * 7. The collector seals, sends, and transfers the tree only on the exact + * performed answer. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import { + createOwnedDurableWorkspaceOperation, + type WorkspaceCoordinationAuthority, + type WorkspaceCoordinationProvider, + withWorkspaceCoordinationProvider, +} from "../workspace/effect.ts"; +import { + type DurableEffect, + type EffectDescription, + type Json, + type JournalProvenance, + type Result as DurableResult, + serializeError, +} from "@executablemd/durable-streams"; +import { ensure, Err, Ok, type Operation, type Result, scoped } from "effection"; +import type { WorkflowRunDatabase, WorkflowRunTransaction } from "../storage/api.ts"; +import { WorkflowTransactionError } from "../storage/errors.ts"; +import type { WorkspaceFilesystem } from "../workspace/filesystem.ts"; +import { isJournaledEffectFailure } from "../workspace/failure.ts"; +import type { WorkspaceMetadata } from "../workspace/metadata.ts"; +import type { AgentSessions } from "../storage/agent-session.ts"; +import type { WorkspaceAttachmentView } from "../workspace/effects.ts"; +import { activeWorkspaceRoute, type WorkspaceRoute } from "./database.ts"; +import { createInvocationMappings } from "./mappings.ts"; +import type { RetainedMapping } from "./publication.ts"; +import { Transaction } from "../workspace/undoable.ts"; +import { + type Attempt, + type Materialization, + useAttempt, + useMaterialization, +} from "./invocation.ts"; +import type { HostPath, RunnerFiles } from "./materialize.ts"; +import type { RemoteReadLink } from "./read.ts"; +import type { RemoteInvocationSnapshot } from "./records.ts"; +import { withRemoteJournalRoute } from "./journal-route.ts"; +import { resource } from "effection"; +import type { DurableStream } from "@executablemd/durable-streams"; +import { remoteRunOrigin, useRemoteRunDatabase, type RemoteWorkspaceLink } from "./database.ts"; +import { installRemoteSuspensionAnswers } from "./answers.ts"; + +import type { TemporaryTrees } from "./invocation.ts"; + +/** + * What a Workspace mutation is given. + * + * The same two things the Deno coordinator hands one, plus the Agent-session + * mappings, so one contract describes both hosts' work. + */ +export type RemoteWorkspaceMutation = ( + filesystem: WorkspaceFilesystem, + metadata: WorkspaceMetadata, + agentSessions: AgentSessions, +) => Operation; + +/** + * The host facts the coordinator cannot know. + * + * Where temporary trees come from, how bytes are written, and how a Workspace + * filesystem is built over a directory. Supplied by a runtime-named adapter, so + * nothing here names a host. + */ +export interface RemoteWorkspaceRuntime { + readonly files: RunnerFiles; + readonly trees: TemporaryTrees; + readonly reads: RemoteReadLink; + createFilesystem(at: HostPath, authorize: () => void): WorkspaceFilesystem; +} + +interface WorkspaceMutationApi { + run( + database: WorkflowRunDatabase, + mutate: RemoteWorkspaceMutation, + ): Operation; +} + +function unavailable(reason: string): never { + throw new WorkflowTransactionError(reason); +} + +const WorkspaceMutation: Api = createApi( + "executablemd.workflow.remote.workspace.effect.mutation", + { + // deno-lint-ignore require-yield + *run(): Operation { + return unavailable( + "the Workspace effect is not bound to an active remote WorkflowRun transaction.", + ); + }, + }, +); + +/** + * Which database claimed which execution identity. + * + * A `WeakMap` keyed by the identity object, so the claim is the object itself + * rather than anything written down. A second loaded copy of this module has + * its own map and its own identities, and neither can answer for the other's. + */ +const workspaceEffectOwners = (() => { + const owners = new WeakMap(); + return { + claim(identity: object, run: object): void { + owners.set(identity, run); + }, + get(identity: object): object | undefined { + return owners.get(identity); + }, + }; +})(); + +/** + * One remote run, as one thing. + * + * The pieces a Workspace invocation needs — the database handle, the owner link + * its reads and commits go through, the runtime adapters that materialize from + * that owner, the routed journal and the provenance taken over it — describe + * one run only when they came from the same one. Supplied separately they can + * be recombined: pair run B's database with run A's link and journal, and if + * both happen to start at the same root and anchor, one effect journals in A + * and publishes its Workspace in B. + * + * So they are not supplied separately. This constructs them together and hands + * back one opaque value. There is nothing to recombine, and nothing structural + * to forge: the coordinator compares the object it was given, not the run id, + * root or anchor inside it. + */ +export interface RemoteRun { + /** The run's storage handle, for work that is not a Workspace effect. */ + readonly database: WorkflowRunDatabase; + /** + * The run's journal, routed so a Workspace publication lands in its + * transaction. This exact stream is the one provenance was taken over. + */ + readonly journal: DurableStream; +} + +/** What only this module may read off a binding. */ +interface BoundRun extends RemoteRun { + readonly runtime: RemoteWorkspaceRuntime; + readonly provenance: JournalProvenance; +} + +/** + * The private view of a binding, keyed by the binding itself. + * + * A `WeakSet` would answer "did this module make it"; this answers "and here is + * what it was made from", without putting either on the value a host holds. A + * second loaded copy of this module has its own map and cannot answer for one + * of these, which is the loaded-copy contract. + */ +const bindings = (() => { + const held = new WeakMap(); + return { + bind(run: BoundRun): RemoteRun { + const handle: RemoteRun = Object.freeze({ database: run.database, journal: run.journal }); + held.set(handle, run); + return handle; + }, + of(run: RemoteRun | undefined): BoundRun | undefined { + return run === undefined ? undefined : held.get(run); + }, + }; +})(); + +/** What a host supplies to open one remote run. */ +export interface RemoteRunOptions { + /** + * The one owner link this run's database, reads and commits go through. + * + * Deliberately one member. A separate read link could be another owner's, + * and an invocation admitted from one run would commit to the other. + */ + readonly link: RemoteWorkspaceLink; + /** + * The handle that link produced, or nothing to open one now. + * + * A host whose lifecycle already opened this run passes the exact handle its + * begin transition returned, so the document, its journal and this + * coordinator are one association rather than two views of one run. The + * handle has to be one this build opened from a link — its routed journal + * and the provenance taken over it are what a coordinator compares — and a + * foreign or fabricated one is refused here, before any effect exists. + */ + readonly database?: WorkflowRunDatabase; + readonly files: RunnerFiles; + readonly trees: TemporaryTrees; + createFilesystem(at: HostPath, authorize: () => void): WorkspaceFilesystem; +} + +/** + * Open one remote run: its database, its routed journal and its provenance. + * + * The handle carries all three. It is opened from the same link the runtime + * reads through — here, or by the lifecycle that began this execution — so + * "this runtime belongs to this handle" is true by construction rather than by + * a check that could be passed with another handle. + */ +export function useRemoteRun(options: RemoteRunOptions): Operation { + return resource(function* (provide) { + const database = + options.database ?? + (yield* useRemoteRunDatabase(options.link, yield* options.link.frontierSnapshot())); + const journal = database.journal; + const origin = remoteRunOrigin(database); + // The handle has to be one this build opened, and opened from this exact + // link. A handle another client opened describes the same run and answers + // to another owner, and that is the pairing this refuses. + if (origin === undefined || origin.link !== options.link) { + unavailable("this is not a remote run handle this build opened from this link."); + } + const provenance = origin.provenance; + // Installed here because here is where a run's three halves exist at once: + // the acquisition it is reached through, the handle its execution transacts + // on, and the witness over the exact journal an answer would be published + // into. It closes with this scope, so a run that is over answers nothing. + yield* installRemoteSuspensionAnswers({ link: options.link, database, provenance }); + yield* provide( + bindings.bind({ + database, + journal, + provenance, + runtime: { + files: options.files, + trees: options.trees, + // A view of the same object the database and the commits came from, + // not a second link: `frontier` names two different reads on the two + // contracts, and materialization wants the coherent one. + reads: readsOf(options.link), + createFilesystem: options.createFilesystem, + }, + }), + ); + }); +} + +/** The read half of one owner link, presented the way materialization reads it. */ +function readsOf(link: RemoteWorkspaceLink): RemoteReadLink { + return { + frontier: () => link.frontierSnapshot(), + root: (workspaceRootId) => link.root(workspaceRootId), + content: (workspaceRootId, request) => link.content(workspaceRootId, request), + invocationSnapshot: () => link.invocationSnapshot(), + }; +} + +interface ProviderApi { + readonly provider: object | undefined; +} + +const RemoteWorkspaceProvider: Api = createApi( + "executablemd.workflow.remote.workspace.effect.provider", + { provider: undefined }, +); + +interface Registration { + open: boolean; + readonly run: BoundRun; +} + +const registrations = (() => { + const held = new WeakMap(); + return { + register(run: BoundRun) { + const selection = Object.freeze({}); + const registration: Registration = { open: true, run }; + held.set(selection, registration); + return { + selection, + close(): void { + registration.open = false; + held.delete(selection); + }, + }; + }, + get(selection: object): Registration | undefined { + const registration = held.get(selection); + return registration?.open === true ? registration : undefined; + }, + }; +})(); + +/** Install the runner's Workspace coordination for this run, in this scope. */ +export function* useRemoteWorkspaceEffects(run: RemoteRun): Operation { + const bound = bindings.of(run); + if (bound === undefined) { + unavailable("this is not a remote run this build opened."); + } + const registration = registrations.register(bound); + yield* ensure(registration.close); + yield* RemoteWorkspaceProvider.around({ provider: () => registration.selection }, { at: "min" }); +} + +export function withRemoteWorkspaceEffects( + run: RemoteRun, + operation: Operation, +): Operation { + return scoped(function* () { + const selection = yield* RemoteWorkspaceProvider.operations.provider; + const registration = selection === undefined ? undefined : registrations.get(selection); + // The exact binding, not one that describes the same run. Two handles on + // two owners can hold identical records; only one of them is this one. + if (registration === undefined || registration.run !== bindings.of(run)) { + return unavailable("no remote Workspace coordinator is installed for this run."); + } + return yield* withWorkspaceCoordinationProvider(coordinator(registration.run), operation); + }); +} + +export function createRemoteWorkspaceEffect( + run: RemoteRun, + description: EffectDescription, + mutate: RemoteWorkspaceMutation, +): DurableEffect { + const bound = bindings.of(run); + if (bound === undefined) { + unavailable("this is not a remote run this build opened."); + } + const execute = () => WorkspaceMutation.operations.run(bound.database, mutate); + const executionIdentity = Object.freeze({}); + // Claimed for the binding rather than for a database, so an effect cannot be + // created against one run and coordinated by another that holds it. + workspaceEffectOwners.claim(executionIdentity, bound); + return createOwnedDurableWorkspaceOperation(description, execute, executionIdentity); +} + +/** + * Read this run's retained Workspace for one ephemeral attachment. + * + * The two things an attachment may see, over a tree this invocation owns. The + * root is materialized here and the materialization is this scope's, so the + * export the body takes out of it survives and the tree does not: native Git + * runs afterwards, against files, with no owner read and no transaction held + * open across it. + * + * Nothing durable happens. No root is published, no mapping is staged, and the + * owner is asked for one coherent snapshot and the content that snapshot names. + */ +export function readRemoteWorkspace( + run: RemoteRun, + body: (view: WorkspaceAttachmentView) => Operation, +): Operation> { + return scoped(function* () { + const bound = bindings.of(run); + if (bound === undefined) { + unavailable("this is not a remote run this build opened."); + } + const { runtime } = bound; + const reject = (reason: string): never => unavailable(reason); + try { + const snapshot = yield* runtime.reads.invocationSnapshot(); + const materialization = yield* useMaterialization( + runtime.files, + runtime.trees, + runtime.reads, + snapshot.workspaceRootId, + reject, + ); + // Live for as long as this read is. An attachment that kept the + // filesystem would be holding a directory this scope is about to remove. + let live = true; + yield* ensure(() => { + live = false; + }); + const authorize = (): void => { + if (!live) { + unavailable("this remote Workspace read is over."); + } + }; + const mappings = createInvocationMappings(snapshot, authorize); + const filesystem = runtime.createFilesystem(materialization.at, authorize); + return Ok(yield* body({ filesystem, metadata: mappings.metadata })); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } + }); +} + +/** + * Read and commit this run's Agent-session mappings, in one transaction. + * + * Two halves that must not be confused. The body runs on the runner, where the + * provider is, and the mappings it reads are the ones the owner admitted; what + * it changed is collected as bounded deltas and submitted to the owner, which + * revalidates the subject under its own transaction and commits all of them or + * none. No provider call happens inside that transaction — there is no + * transaction open while the body runs — and a body that failed or was + * cancelled sends nothing at all. + */ +export function transactRemoteAgentSessions( + run: RemoteRun, + body: (sessions: AgentSessions) => Operation, +): Operation> { + return scoped(function* () { + const bound = bindings.of(run); + if (bound === undefined) { + unavailable("this is not a remote run this build opened."); + } + const { runtime, database } = bound; + let value: T; + let deltas: readonly RetainedMapping[]; + try { + const snapshot = yield* runtime.reads.invocationSnapshot(); + const mappings = createInvocationMappings(snapshot, () => undefined); + // The body first, and outside any transaction: establishing or asserting + // a conversation is provider work, and an owner transaction is not a + // place a provider call may happen. + value = yield* body(mappings.agentSessions); + deltas = mappings.deltas(); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } + if (deltas.length === 0) { + // Nothing was staged, so there is nothing for the owner to decide. + return Ok(value); + } + // One transaction that carries mappings and nothing else: no event, no + // publication, no answer. The owner revalidates each subject against what + // it holds and commits every delta or refuses them together. + return yield* database.transact(function* (transaction) { + const route = yield* activeWorkspaceRoute(database, transaction); + if (route === undefined) { + unavailable("this transaction is not the one this run's mappings may be retained in."); + } + route.enlistMappings(deltas); + return value; + }); + }); +} + +function coordinator(run: BoundRun): WorkspaceCoordinationProvider { + return { + *run(authority: WorkspaceCoordinationAuthority): Operation { + let transacted; + try { + // Both against the same binding, so there is no pair of checks that a + // recombination could satisfy one at a time. + if (workspaceEffectOwners.get(authority.executionIdentity) !== run) { + unavailable( + "the live Workspace effect is missing, foreign, completed, or stale for this " + + "remote run.", + ); + } + if ( + authority.journalProvenance === undefined || + authority.journalProvenance !== run.provenance + ) { + unavailable( + "the live Workspace journal does not have the provenance of the selected remote run.", + ); + } + transacted = yield* invoke(run, authority); + } catch (error) { + throw yield* authority.activateFailure(error); + } + if (!transacted.ok) { + throw yield* authority.activateFailure(transacted.error); + } + return transacted.value; + }, + }; +} + +function* invoke(run: BoundRun, authority: WorkspaceCoordinationAuthority) { + const { runtime, database } = run; + const reject = (reason: string): never => unavailable(reason); + const snapshot = yield* runtime.reads.invocationSnapshot(); + + // Outside the transaction, deliberately. The collector seals the attempt + // after the transaction body and everything it started have torn down, so an + // attempt owned by the body would already be gone when its proposal is taken. + const materialization: Materialization = yield* useMaterialization( + runtime.files, + runtime.trees, + runtime.reads, + snapshot.workspaceRootId, + reject, + ); + const attempt: Attempt = yield* useAttempt( + runtime.files, + runtime.trees, + runtime.reads, + materialization, + reject, + ); + + return yield* database.transact(function* (transaction) { + const route = yield* activeWorkspaceRoute(database, transaction); + if (route === undefined) { + unavailable( + "the live Workspace coordinator is not inside this WorkflowRun's active transaction.", + ); + } + // Before the document runs, and before anything is sent. If the run moved + // between the snapshot and this transaction, everything admitted describes + // a state this commit would not be against. + if ( + route.anchor.workspaceRootId !== snapshot.workspaceRootId || + route.anchor.journalEventId !== snapshot.journalEventId + ) { + unavailable( + "this Workspace invocation was admitted from a state this run has since moved past.", + ); + } + return yield* coordinateTransaction(run, transaction, route, snapshot, attempt, authority); + }); +} + +function* coordinateTransaction( + run: BoundRun, + transaction: WorkflowRunTransaction, + route: WorkspaceRoute, + snapshot: RemoteInvocationSnapshot, + attempt: Attempt, + authority: WorkspaceCoordinationAuthority, +): Operation { + const { database, runtime } = run; + return yield* scoped(function* () { + let live = true; + // The capabilities exist while this invocation does and no longer. A + // filesystem or mapping view captured for later is asking about a + // Workspace that has already been committed or discarded. + yield* ensure(() => { + live = false; + }); + const authorize = (): void => { + if (!live) { + unavailable("this Workspace capability is completed, cancelled, or stale."); + } + }; + const mappings = createInvocationMappings(snapshot, authorize); + const filesystem = runtime.createFilesystem(attempt.at, authorize); + + let result: DurableResult; + try { + const value = yield* scoped(function* () { + // The undo the shared rules ask for when part of one mutation cannot + // be finished. The attempt is disposable by construction, so undoing + // that part is restoring the attempt from the accepted root — + // correct because one effect performs one mutation, so nothing else in + // this body has changed anything a caller still needs. + yield* Transaction.around( + { + *undoable([body]: [Operation]): Operation { + try { + return yield* body; + } catch (error) { + yield* attempt.restore(); + throw error; + } + }, + }, + { at: "min" }, + ); + yield* WorkspaceMutation.around( + { + *run([candidate, mutate]): Operation { + if (candidate !== database) { + unavailable( + "the Workspace effect is not bound to an active remote WorkflowRun transaction.", + ); + } + return yield* mutate(filesystem, mappings.metadata, mappings.agentSessions); + }, + }, + { at: "min" }, + ); + return yield* authority.execute(); + }); + result = { status: "ok", value }; + // Only a successful result publishes a Workspace. The attempt is named + // rather than captured: the collector seals it after this body tears + // down, so what the owner decides is the tree as it finally is. + route.enlist(attempt, mappings.deltas()); + } catch (error) { + if (!isJournaledEffectFailure(error)) { + throw error; + } + // The effect's own outcome. Nothing is enlisted, so the commit carries + // only this row and the root stays exactly where it was. + result = { status: "err", error: serializeError(error) }; + } + + yield* withRemoteJournalRoute(database, transaction, authority.publish(result)); + return result; + }); +} diff --git a/packages/workflow/src/replay.ts b/packages/workflow/src/replay.ts new file mode 100644 index 000000000..8d95b8450 --- /dev/null +++ b/packages/workflow/src/replay.ts @@ -0,0 +1,182 @@ +/** + * What a completed run replays on, taken from the history its owner already + * holds. + * + * A completed run asked to run again does not run: canonical execution reads + * the terminal its journal recorded and answers with it, importing nothing, + * performing nothing and appending nothing. It still has to be *given* a root + * document, because that value is what the retained history is held to — the + * document a recorded import must have selected, and the one a terminal written + * before any import is bound to. + * + * Locally that value came out of Git, because a checkout was there. A run whose + * durable owner is somewhere else has no checkout at all, and fetching one for + * a replay that imports nothing would be live retrieval performed for a document + * nobody is going to read. So it comes from the run: the retained root import + * holds the exact selection canonical execution made — the document's path, its + * text and the target it resolved to — and a run that failed before importing + * anything holds the same three in the binding core writes into its terminal. + * + * Either way the path is held to the definition the run record retains, and + * that is the load-bearing half. Journal data decides nothing about which + * document a run is a run of; it supplies only the bytes the run already + * recorded for the document its immutable definition names. + * + * The bundle is the same judgment made narrower. A completed replay imports no + * component, so it is handed no component source and no authority to resolve + * one. What it is handed is the admission that holds every component import the + * history recorded to the exact name, canonical path and object id the + * definition declares — so a member the definition declares and this history + * never imported is neither read nor fetched, and grants nothing by existing. + * + * Nothing here reads or writes anything. It is a decision over values, like + * `lifecycle/policy.ts`, so both hosts reach it the same way. + */ + +import { Err, Ok, type Result } from "effection"; +import { retainedSource } from "@executablemd/core/host"; +import type { ExecutionInstallation, RetainedRootDocument } from "@executablemd/core/host"; +import { workflowBundleReplayInstallation } from "./bundle.ts"; +import { retainedWorkflowInstallation } from "./run.ts"; +import { + agreesWithRetainedResult, + preRootSelection, + rootImports, + rootOutcome, + terminal, + terminalFrontier, +} from "./lifecycle/policy.ts"; +import type { JournalEntry } from "./storage/api.ts"; +import type { WorkflowRunRecord } from "./storage/record.ts"; + +/** What a completed run hands canonical execution, and nothing else. */ +export interface RetainedReplay { + /** The root document, exactly as this run's own history recorded it. */ + readonly root: RetainedRootDocument; + /** The run contract and the bundle admission this replay is held to. */ + readonly installations: readonly ExecutionInstallation[]; +} + +/** + * Retained state that describes no completed run. + * + * Fixed diagnostics throughout, and deliberately so: what is being refused is + * journal and lifecycle data, and a refusal that quoted a path, a source or a + * recorded value would publish exactly what it exists to reject. + */ +export class WorkflowReplayHistoryError extends Error { + override name = "WorkflowReplayHistoryError"; +} + +const REFUSALS = Object.freeze({ + live: "this run's retained state is not terminal, so it replays nothing.", + absent: + "this run is retained as ended and its history records no document result, so there is " + + "nothing for a replay to restore. The run is left exactly as it is.", + mixed: + "this run's history continues past the document result it records, so the two describe " + + "different moments of the run. The run is left exactly as it is.", + ambiguous: + "this run's history records more than one root document import, so no single one " + + "describes what it ran. The run is left exactly as it is.", + malformed: + "this run's retained root document cannot be read by this version. The run is left " + + "exactly as it is.", + document: + "this run's retained root document is not the document its definition names. The run is " + + "left exactly as it is.", + disagreed: + "this run's retained state and its recorded document result describe different outcomes, " + + "so neither is the one to replay. The run is left exactly as it is.", + damaged: + "this run's recorded document result cannot be read by this version, so there is no " + + "outcome to restore. The run is left exactly as it is.", +}); + +function refuse(reason: string): Result { + return Err(new WorkflowReplayHistoryError(reason)); +} + +/** + * What this run's owner already holds, as the inputs one canonical replay runs + * on — or why the state it holds describes no completed run. + * + * Every refusal here happens before an attachment, a provider, a materialized + * root or a native operation: the caller has read one coherent frontier and has + * done nothing else with it. + */ +export function retainedReplay( + record: WorkflowRunRecord, + entries: readonly JournalEntry[], +): Result { + if (!terminal(record.status)) { + return refuse(REFUSALS.live); + } + + // The terminal is the frontier, and there is one of it. A history holding a + // second result, or continuing past the one it stands behind, is one whose + // lifecycle row and journal describe different moments of the run — and + // reconciling those is not a replay's to do. The same reading decides it for + // stale recovery, so neither can accept what the other refuses; what this + // adds is only which way it refused. + const frontier = terminalFrontier(entries); + if (frontier.kind === "absent") { + return refuse(REFUSALS.absent); + } + if (frontier.kind === "mixed") { + return refuse(REFUSALS.mixed); + } + + // What the root recorded, read once, by the lifecycle's own judgment: the + // same one stale recovery publishes through and the same one a settlement + // is held to. A second copy of it here would be a second authority. + // + // A terminal this build cannot read is refused before canonical core is + // handed it, because core's rejection would arrive as *this* invocation's + // document failure and be offered as a replacement outcome. + // The root's own import, read by the same rule the lifecycle reads it by — + // the same call, answering with the same parsed selection — so the document + // this replay is built from is the one the outcome below was judged against. + // Asked here first only so a history recording two, or recording one this + // build cannot read, says which rather than arriving as undifferentiated + // damage. + const imports = rootImports(entries); + if (imports.kind === "many") { + return refuse(REFUSALS.ambiguous); + } + if (imports.kind === "malformed") { + return refuse(REFUSALS.malformed); + } + + const canonical = rootOutcome(entries); + if (canonical === undefined || canonical.kind === "damaged") { + return refuse(REFUSALS.damaged); + } + if (!agreesWithRetainedResult(record, canonical)) { + return refuse(REFUSALS.disagreed); + } + + const retained = imports.kind === "none" ? preRootSelection(entries) : imports.selection; + if (retained === undefined) { + return refuse(REFUSALS.malformed); + } + if (retained.path !== record.definition.rootDocumentPath) { + return refuse(REFUSALS.document); + } + + const { path, content, target } = retained; + return Ok({ + root: + target === undefined + ? retainedSource(path, content) + : retainedSource(path, content, { target }), + installations: [ + retainedWorkflowInstallation({ + runId: record.runId, + base: record.base, + pinnedCommit: record.definition.objectId, + }), + workflowBundleReplayInstallation(record.definition), + ], + }); +} diff --git a/packages/workflow/src/software-factory/run-id.ts b/packages/workflow/src/software-factory/run-id.ts new file mode 100644 index 000000000..16b5e8b54 --- /dev/null +++ b/packages/workflow/src/software-factory/run-id.ts @@ -0,0 +1,252 @@ +/** + * The run id a software-factory run is addressed by. + * + * One GitHub issue is one durable run, so the id has to be a function of the + * issue and of nothing that can change while the work is going on. Repository + * names get renamed, issue numbers move between deployments, Project items and + * their statuses are edited constantly, branches and revisions are the point of + * the exercise, and delivery ids and actors differ on every request. None of + * them takes part. What is left is the deployment the issue lives in and the + * opaque node id that deployment gave it, and those two are what this hashes. + * + * Because every input is immutable, admitting one issue twice derives one id and + * reaches one run through ordinary compatible reuse, and no separate idempotency + * concept appears anywhere above it. Two independent implementations handed the + * same authority and node id produce the same 52 characters. + * + * The derivation is specified in `specs/github-actions-software-factory-spec.md` + * §1.1 and restated in `specs/workflow-spec.md` §9.1. It is host-selected public + * run id and nothing more: opaque to everything but equality and lifecycle + * addressing, and a legal one under the storage rule, which wants a non-empty + * string containing no NUL. + */ + +import { until } from "effection"; +import type { Operation } from "effection"; + +/** The version tag the digest opens with. A different scheme takes a different tag. */ +const SCHEME = "github-issue-v1"; + +/** Lowercase RFC 4648 Base32. No padding is ever emitted, so `=` is absent. */ +const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; + +/** + * How many characters a full SHA-256 becomes. + * + * 32 bytes is 256 bits, and Base32 carries five bits per character, so the + * unpadded encoding is `ceil(256 / 5)` characters. Stated rather than computed + * because it is a contract a second implementation is held to. + */ +const FACTORY_RUN_ID_LENGTH = 52; + +/** Why a subject could not be turned into a run id. */ +export type FactoryRunSubjectFailure = + | "authority-empty" + | "authority-has-scheme" + | "authority-has-userinfo" + | "authority-has-path" + | "authority-has-query" + | "authority-has-fragment" + | "authority-has-whitespace" + | "authority-malformed-host" + | "authority-malformed-port" + | "authority-default-port" + | "node-id-empty" + | "node-id-has-nul"; + +/** A subject this build cannot derive an id from, named by what was wrong with it. */ +export class FactoryRunSubjectError extends Error { + override name = "FactoryRunSubjectError"; + + constructor( + readonly reason: FactoryRunSubjectFailure, + detail: string, + ) { + super(`this GitHub subject cannot address a factory run: ${detail}`); + } +} + +/** The exact GitHub subject one factory run is a run of. */ +export interface FactoryRunSubject { + /** + * The canonical GitHub authority: a lowercase DNS hostname, plus `:` and a + * port when that port is not the scheme's default. + */ + readonly authority: string; + /** + * The exact string GitHub's GraphQL API returned for this issue. + * + * Compared byte for byte. It is an opaque provider identity, and normalizing + * one would be inventing a second. + */ + readonly issueNodeId: string; +} + +/** The port `https` implies, and therefore the one an authority may not spell out. */ +const DEFAULT_PORT = 443; + +/** + * A hostname the DNS grammar admits: labels of letters, digits and hyphens, + * each starting and ending with an alphanumeric, separated by dots. + * + * Deliberately not a URL parse. A parser would accept — and silently discard — + * the parts an authority may not carry, and the point here is to refuse them. + */ +const HOSTNAME = + /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/; + +/** + * Normalize what an operator configured into the one spelling this hash uses. + * + * Case folding is the only transformation. Everything else an authority must not + * contain is refused rather than stripped: a value that had to be repaired to be + * usable is a value somebody meant differently, and two spellings that both + * became one authority would be two runs quietly becoming one. + */ +function canonicalGitHubAuthority(value: string): string { + if (value === "") { + throw new FactoryRunSubjectError("authority-empty", "the authority is empty"); + } + if (/\s/.test(value)) { + throw new FactoryRunSubjectError( + "authority-has-whitespace", + "the authority contains whitespace", + ); + } + if (value.includes("//") || /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value)) { + throw new FactoryRunSubjectError( + "authority-has-scheme", + "the authority carries a scheme; write the host alone", + ); + } + if (value.includes("@")) { + throw new FactoryRunSubjectError( + "authority-has-userinfo", + "the authority carries user information", + ); + } + if (value.includes("#")) { + throw new FactoryRunSubjectError("authority-has-fragment", "the authority carries a fragment"); + } + if (value.includes("?")) { + throw new FactoryRunSubjectError("authority-has-query", "the authority carries a query"); + } + if (value.includes("/")) { + throw new FactoryRunSubjectError( + "authority-has-path", + "the authority carries a path or a trailing separator", + ); + } + + const folded = value.toLowerCase(); + const separator = folded.lastIndexOf(":"); + const host = separator === -1 ? folded : folded.slice(0, separator); + const port = separator === -1 ? undefined : folded.slice(separator + 1); + + if (!HOSTNAME.test(host)) { + throw new FactoryRunSubjectError("authority-malformed-host", "the host is not a DNS hostname"); + } + if (port === undefined) { + return host; + } + if (!/^[0-9]{1,5}$/.test(port)) { + throw new FactoryRunSubjectError("authority-malformed-port", "the port is not a number"); + } + const numeric = Number(port); + if (numeric < 1 || numeric > 65535) { + throw new FactoryRunSubjectError("authority-malformed-port", "the port is out of range"); + } + if (numeric === DEFAULT_PORT) { + throw new FactoryRunSubjectError( + "authority-default-port", + "the default port is written out; omit it so one deployment has one spelling", + ); + } + return `${host}:${numeric}`; +} + +/** Hold a node id to what a retained identity has to be, and change nothing about it. */ +function admitIssueNodeId(value: string): string { + if (value === "") { + throw new FactoryRunSubjectError("node-id-empty", "the issue node id is empty"); + } + if (value.includes("\0")) { + throw new FactoryRunSubjectError("node-id-has-nul", "the issue node id contains a NUL"); + } + return value; +} + +/** + * The exact bytes the digest is taken over. + * + * `github-issue-v1`, NUL, the canonical authority, NUL, the node id — all + * UTF-8. The NULs are separators the inputs cannot contain, so no pair of + * (authority, node id) can be rearranged into another pair with the same bytes. + */ +export function factoryRunIdPreimage(subject: FactoryRunSubject): ArrayBuffer { + const encoder = new TextEncoder(); + const scheme = encoder.encode(SCHEME); + const authority = encoder.encode(subject.authority); + const node = encoder.encode(subject.issueNodeId); + // An `ArrayBuffer` rather than a view, because that is what `crypto.subtle` + // accepts without anything having to assert a type at the call site. + const buffer = new ArrayBuffer(scheme.length + 1 + authority.length + 1 + node.length); + const bytes = new Uint8Array(buffer); + let at = 0; + bytes.set(scheme, at); + at += scheme.length; + bytes[at] = 0; + at += 1; + bytes.set(authority, at); + at += authority.length; + bytes[at] = 0; + at += 1; + bytes.set(node, at); + return buffer; +} + +/** Lowercase unpadded RFC 4648 Base32 of exactly these bytes. */ +export function base32Unpadded(bytes: Uint8Array): string { + let out = ""; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += BASE32_ALPHABET[(buffer >> bits) & 31]; + } + } + if (bits > 0) { + out += BASE32_ALPHABET[(buffer << (5 - bits)) & 31]; + } + return out; +} + +/** + * Normalize a subject, refusing anything this build cannot address a run from. + * + * Separate from the derivation so a caller can admit a subject before it has + * anywhere to put the answer — which is what an admission check needs, and what + * a later story comparing a reread subject against a retained one needs too. + */ +export function admitFactoryRunSubject(subject: FactoryRunSubject): FactoryRunSubject { + return { + authority: canonicalGitHubAuthority(subject.authority), + issueNodeId: admitIssueNodeId(subject.issueNodeId), + }; +} + +/** + * The public run id for one GitHub issue. + * + * The subject is admitted first, so a malformed authority or node id is refused + * before any digest exists and long before anything looks for an owner to route + * it to. + */ +export function* deriveFactoryRunId(subject: FactoryRunSubject): Operation { + const admitted = admitFactoryRunSubject(subject); + const digest = yield* until(crypto.subtle.digest("SHA-256", factoryRunIdPreimage(admitted))); + return base32Unpadded(new Uint8Array(digest)); +} diff --git a/packages/workflow/src/deno/rows.ts b/packages/workflow/src/sqlite/rows.ts similarity index 96% rename from packages/workflow/src/deno/rows.ts rename to packages/workflow/src/sqlite/rows.ts index 30951826c..b09889e03 100644 --- a/packages/workflow/src/deno/rows.ts +++ b/packages/workflow/src/sqlite/rows.ts @@ -10,6 +10,12 @@ * A failure names the column and never the value. Props and journal payloads * are retained history, and a row that does not parse is not a reason to print * what it held. + * + * It lives beside the schema rather than under a host because two adapters read + * the same rows back. The Deno host opens a file with `node:sqlite`; the + * Cloudflare owner reads the storage of one Durable Object. What a stored row + * *means* is the same question in both, and a second copy of these parsers + * would be the place the two hosts quietly stopped agreeing. */ import type { Json } from "@executablemd/durable-streams"; diff --git a/packages/workflow/src/sqlite/workflow-schema.ts b/packages/workflow/src/sqlite/workflow-schema.ts new file mode 100644 index 000000000..82a9d61c5 --- /dev/null +++ b/packages/workflow/src/sqlite/workflow-schema.ts @@ -0,0 +1,623 @@ +/** + * The version-1 WorkflowRun schema, as SQLite holds it. + * + * Two adapters keep a run in an embedded SQLite database — the Deno host in a + * file it opens with `node:sqlite`, the Cloudflare owner in the storage of one + * Durable Object — and they must agree about what version 1 *is*. A second copy + * of this DDL under a second adapter would be two schemas that happen to look + * alike, and the first amendment either of them missed would be a run neither + * could recognize. + * + * So the declaration lives here once, and each adapter keeps what is genuinely + * its own: how a connection is opened, how an error is translated, and how the + * identity of the schema is carried. That last one differs because it has to. + * Deno writes `PRAGMA application_id` and `PRAGMA user_version` into the SQLite + * header; Cloudflare's Durable Object storage refuses both pragmas outright, so + * that adapter carries the same two values in a table of its own. The logical + * version is one; only its physical carrier is per-adapter. + * + * Nothing here owns a connection, a path, a transaction or any lifecycle + * authority, and nothing here names a runtime. It is a description of a shape + * and the arithmetic for comparing a database against it. + */ + +/** + * The bytes `XMD1` as a 32-bit integer, written into the SQLite header. + * + * A database carries what wrote it, so a file that is perfectly valid SQLite + * and belongs to something else is refused on sight rather than through the + * confusing shape of its missing tables. + */ +export const APPLICATION_ID = 0x584d4431; + +/** The only schema version this build reads or writes. */ +export const SCHEMA_VERSION = 1; + +/** + * The largest value the schema version can be carried in. + * + * The logical carrier is SQLite's `user_version`, a signed 32-bit integer. Any + * host holding this schema has to represent the same versions, so the bound is + * the carrier's rather than one adapter's. + */ +export const MAX_SCHEMA_VERSION = 0x7fffffff; + +/** + * Whether a retained value could name a schema version at all. + * + * Version numbering starts at 1 and rises. Zero is a database carrying the XMD + * identity without a complete schema, which is a partial initialization and so + * damage; a negative or out-of-range value is retained data that no build of + * this project ever wrote. Neither is a version this build has not learned, so + * neither may travel as one. + */ +export function isSchemaVersion(value: number): boolean { + return Number.isInteger(value) && value >= 1 && value <= MAX_SCHEMA_VERSION; +} + +const STATUSES = "'running', 'suspended', 'interrupted', 'completed', 'failed', 'cancelled'"; + +/** + * A stop reason is three columns wide and has three legal shapes. + * + * Spreading the variant across columns is what lets SQLite hold the invariant + * rather than the code that writes rows: a host reason with an event id, or a + * journal reason with a code, is refused by the database itself. + */ +function coherentStopReason(): string { + return `CHECK ( + (stop_reason_kind IS NULL AND stop_reason_code IS NULL AND stop_reason_event_id IS NULL) + OR (stop_reason_kind = 'host' AND stop_reason_code IS NOT NULL AND stop_reason_event_id IS NULL) + OR (stop_reason_kind = 'journal' AND stop_reason_code IS NULL AND stop_reason_event_id IS NOT NULL) + )`; +} + +/** + * Version 1, one table at a time. + * + * Kept as separate definitions so verification can compare what a file holds + * with what this build writes, rather than settling for the table's name. + * + * The complete version-1 shape includes the pinned DOFS objects, retained + * Workspace roots, journal and metadata. Dependency order is explicit: DOFS + * content precedes root references, and roots precede the journal rows that + * name them. + */ +interface DeclaredObject { + readonly type: "table" | "index"; + readonly sql: string; +} + +export const OBJECTS: ReadonlyMap = new Map([ + [ + "vfs_meta", + { + type: "table", + sql: `CREATE TABLE vfs_meta ( + k TEXT PRIMARY KEY, + v INTEGER NOT NULL + )`, + }, + ], + [ + "vfs_nodes", + { + type: "table", + sql: `CREATE TABLE vfs_nodes ( + inode INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK(type IN ('file','dir','symlink')), + mode INTEGER NOT NULL DEFAULT 493, + mtime INTEGER NOT NULL, + rev INTEGER NOT NULL DEFAULT 0, + mount_root TEXT, + stub_size INTEGER, + manifest_hash BLOB, + link_target TEXT, + size INTEGER NOT NULL DEFAULT 0 + )`, + }, + ], + [ + "vfs_dirents", + { + type: "table", + sql: `CREATE TABLE vfs_dirents ( + parent_inode INTEGER NOT NULL, + name TEXT NOT NULL, + child_inode INTEGER NOT NULL, + PRIMARY KEY (parent_inode, name) + ) WITHOUT ROWID`, + }, + ], + [ + "vfs_dirents_by_child", + { + type: "index", + sql: "CREATE INDEX vfs_dirents_by_child ON vfs_dirents(child_inode)", + }, + ], + [ + "vfs_nodes_by_rev", + { + type: "index", + sql: "CREATE INDEX vfs_nodes_by_rev ON vfs_nodes(rev)", + }, + ], + [ + "vfs_nodes_by_manifest_hash", + { + type: "index", + sql: `CREATE INDEX vfs_nodes_by_manifest_hash + ON vfs_nodes(manifest_hash) WHERE manifest_hash IS NOT NULL`, + }, + ], + [ + "vfs_blobs", + { + type: "table", + sql: `CREATE TABLE vfs_blobs ( + hash BLOB PRIMARY KEY, + size INTEGER NOT NULL, + last_seen INTEGER NOT NULL + )`, + }, + ], + [ + "vfs_blob_bytes", + { + type: "table", + sql: `CREATE TABLE vfs_blob_bytes ( + hash BLOB PRIMARY KEY REFERENCES vfs_blobs(hash) ON DELETE CASCADE, + bytes BLOB NOT NULL + )`, + }, + ], + [ + "vfs_chunks", + { + type: "table", + sql: `CREATE TABLE vfs_chunks ( + inode INTEGER NOT NULL, + idx INTEGER NOT NULL, + hash BLOB NOT NULL, + size INTEGER NOT NULL, + PRIMARY KEY (inode, idx) + ) WITHOUT ROWID`, + }, + ], + [ + "vfs_chunks_by_hash", + { + type: "index", + sql: "CREATE INDEX vfs_chunks_by_hash ON vfs_chunks(hash)", + }, + ], + [ + "vfs_manifests", + { + type: "table", + sql: `CREATE TABLE vfs_manifests ( + hash BLOB PRIMARY KEY, + size INTEGER NOT NULL, + encoded BLOB NOT NULL, + last_seen INTEGER NOT NULL DEFAULT 0 + )`, + }, + ], + [ + "vfs_changes", + { + type: "table", + sql: `CREATE TABLE vfs_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rev INTEGER NOT NULL, + path TEXT NOT NULL, + op TEXT NOT NULL CHECK(op IN ('delete')) + )`, + }, + ], + [ + "vfs_changes_by_rev", + { + type: "index", + sql: "CREATE INDEX vfs_changes_by_rev ON vfs_changes(rev)", + }, + ], + [ + "vfs_changes_by_path", + { + type: "index", + sql: "CREATE INDEX vfs_changes_by_path ON vfs_changes(path, id DESC)", + }, + ], + [ + "_vfs_watermark", + { + type: "table", + sql: `CREATE TABLE _vfs_watermark ( + k TEXT NOT NULL, + backend TEXT NOT NULL DEFAULT 'default', + v INTEGER NOT NULL, + PRIMARY KEY (k, backend) + )`, + }, + ], + [ + "_vfs_fetch_cursor", + { + type: "table", + sql: `CREATE TABLE _vfs_fetch_cursor ( + k TEXT NOT NULL CHECK(k = 'fetch'), + backend TEXT NOT NULL DEFAULT 'default', + path TEXT, + PRIMARY KEY (k, backend) + )`, + }, + ], + [ + "_vfs_mounts", + { + type: "table", + sql: `CREATE TABLE _vfs_mounts ( + root TEXT PRIMARY KEY, + kind TEXT NOT NULL, + indexed INTEGER NOT NULL DEFAULT 0, + mode TEXT NOT NULL DEFAULT 'read-only' + CHECK(mode IN ('read-only', 'read-write')) + )`, + }, + ], + [ + "workspace_roots", + { + type: "table", + sql: `CREATE TABLE workspace_roots ( + root_id TEXT PRIMARY KEY CHECK ( + length(root_id) = 64 AND root_id NOT GLOB '*[^0-9a-f]*' + ), + format_version INTEGER NOT NULL CHECK (format_version = 1), + manifest TEXT NOT NULL CHECK (json_valid(manifest)) +) STRICT`, + }, + ], + [ + "workspace_root_manifest_refs", + { + type: "table", + sql: `CREATE TABLE workspace_root_manifest_refs ( + root_id TEXT NOT NULL REFERENCES workspace_roots(root_id) ON DELETE CASCADE, + manifest_hash BLOB NOT NULL REFERENCES vfs_manifests(hash) ON DELETE RESTRICT, + PRIMARY KEY (root_id, manifest_hash) +) STRICT, WITHOUT ROWID`, + }, + ], + [ + "workspace_root_blob_refs", + { + type: "table", + sql: `CREATE TABLE workspace_root_blob_refs ( + root_id TEXT NOT NULL REFERENCES workspace_roots(root_id) ON DELETE CASCADE, + blob_hash BLOB NOT NULL, + PRIMARY KEY (root_id, blob_hash), + FOREIGN KEY (blob_hash) REFERENCES vfs_blobs(hash) ON DELETE RESTRICT, + FOREIGN KEY (blob_hash) REFERENCES vfs_blob_bytes(hash) ON DELETE RESTRICT +) STRICT, WITHOUT ROWID`, + }, + ], + [ + "agent_sessions", + { + type: "table", + sql: `CREATE TABLE agent_sessions ( + session_key TEXT PRIMARY KEY, + provider TEXT NOT NULL, + agent_command TEXT NOT NULL, + session_identity TEXT NOT NULL, + policy TEXT NOT NULL, + assertion_kind TEXT NOT NULL, + assertion_value TEXT NOT NULL, + created_at TEXT NOT NULL +) STRICT`, + }, + ], + [ + "workspace_state", + { + type: "table", + sql: `CREATE TABLE workspace_state ( + singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), + current_root_id TEXT NOT NULL REFERENCES workspace_roots(root_id) ON DELETE RESTRICT +) STRICT`, + }, + ], + [ + "journal_events", + { + type: "table", + sql: `CREATE TABLE journal_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + record TEXT NOT NULL CHECK (json_valid(record)), + workspace_root_id TEXT NOT NULL REFERENCES workspace_roots(root_id) ON DELETE RESTRICT +) STRICT`, + }, + ], + [ + "workflow_run", + { + type: "table", + sql: `CREATE TABLE workflow_run ( + id INTEGER PRIMARY KEY CHECK (id = 1), + run_id TEXT NOT NULL, + definition TEXT NOT NULL CHECK (json_valid(definition)), + base TEXT NOT NULL, + props TEXT NOT NULL CHECK (json_valid(props) AND json_type(props) = 'object'), + status TEXT NOT NULL CHECK (status IN (${STATUSES})), + stop_reason_kind TEXT CHECK (stop_reason_kind IS NULL OR stop_reason_kind IN ('host', 'journal')), + stop_reason_code TEXT, + stop_reason_event_id TEXT REFERENCES journal_events (event_id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + ${coherentStopReason()} +) STRICT`, + }, + ], + [ + "definition_retrieval", + { + type: "table", + sql: `CREATE TABLE definition_retrieval ( + id INTEGER PRIMARY KEY CHECK (id = 1), + metadata TEXT NOT NULL CHECK (json_valid(metadata)), + revision INTEGER NOT NULL CHECK (revision >= 1 AND revision <= 9007199254740991), + updated_at TEXT NOT NULL +) STRICT`, + }, + ], + [ + "document_executions", + { + type: "table", + sql: `CREATE TABLE document_executions ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id TEXT NOT NULL UNIQUE, + started_at TEXT NOT NULL, + stopped_at TEXT, + stop_status TEXT CHECK (stop_status IS NULL OR stop_status IN (${STATUSES})), + stop_reason_kind TEXT CHECK (stop_reason_kind IS NULL OR stop_reason_kind IN ('host', 'journal')), + stop_reason_code TEXT, + stop_reason_event_id TEXT REFERENCES journal_events (event_id), + CHECK ((stopped_at IS NULL) = (stop_status IS NULL)), + CHECK (stop_status IS NOT NULL OR stop_reason_kind IS NULL), + ${coherentStopReason()} +) STRICT`, + }, + ], + [ + "workspace_repositories", + { + type: "table", + sql: `CREATE TABLE workspace_repositories ( + name TEXT PRIMARY KEY CHECK (length(name) > 0), + locator TEXT NOT NULL CHECK (length(locator) > 0), + locator_fingerprint TEXT NOT NULL CHECK ( + length(locator_fingerprint) = 64 AND locator_fingerprint NOT GLOB '*[^0-9a-f]*' + ), + requested_base TEXT CHECK (requested_base IS NULL OR length(requested_base) > 0), + creation_commit TEXT NOT NULL CHECK (length(creation_commit) > 0), + primary_branch TEXT NOT NULL CHECK (length(primary_branch) > 0), + object_format TEXT NOT NULL CHECK (object_format IN ('sha1', 'sha256')), + checkout_path TEXT NOT NULL UNIQUE CHECK ( + length(checkout_path) > 0 AND substr(checkout_path, 1, 1) = '/' + ) +) STRICT`, + }, + ], + [ + "workspace_worktrees", + { + type: "table", + sql: `CREATE TABLE workspace_worktrees ( + repository_name TEXT NOT NULL REFERENCES workspace_repositories(name) ON DELETE RESTRICT, + name TEXT NOT NULL CHECK (length(name) > 0), + requested_branch TEXT NOT NULL CHECK (length(requested_branch) > 0), + requested_base TEXT CHECK (requested_base IS NULL OR length(requested_base) > 0), + creation_commit TEXT NOT NULL CHECK (length(creation_commit) > 0), + checkout_path TEXT NOT NULL UNIQUE CHECK ( + length(checkout_path) > 0 AND substr(checkout_path, 1, 1) = '/' + ), + PRIMARY KEY (repository_name, name) +) STRICT, WITHOUT ROWID`, + }, + ], + [ + "workflow_suspension_answers", + { + type: "table", + sql: `CREATE TABLE workflow_suspension_answers ( + suspension_id TEXT PRIMARY KEY, + request_event_id TEXT NOT NULL REFERENCES journal_events(event_id) ON DELETE RESTRICT, + request_fingerprint TEXT NOT NULL CHECK ( + length(request_fingerprint) = 64 AND request_fingerprint NOT GLOB '*[^0-9a-f]*' + ), + answer TEXT NOT NULL CHECK (json_valid(answer)), + state TEXT NOT NULL CHECK (state IN ('pending', 'consumed')), + created_at TEXT NOT NULL, + consumed_at TEXT, + CHECK ((state = 'consumed') = (consumed_at IS NOT NULL)) +) STRICT`, + }, + ], + [ + "workflow_fork_lineage", + { + type: "table", + sql: `CREATE TABLE workflow_fork_lineage ( + id INTEGER PRIMARY KEY CHECK (id = 1), + source_run_id TEXT NOT NULL CHECK (length(source_run_id) > 0), + checkpoint_event_id TEXT NOT NULL CHECK (length(checkpoint_event_id) > 0), + checkpoint_workspace_root_id TEXT NOT NULL + REFERENCES workspace_roots(root_id) ON DELETE RESTRICT, + selection_anchor TEXT CHECK ( + selection_anchor IS NULL + OR (length(selection_anchor) = 64 AND selection_anchor NOT GLOB '*[^0-9a-f]*') + ), + run_record_root_id TEXT REFERENCES workspace_roots(root_id) ON DELETE RESTRICT, + root_import_root_id TEXT REFERENCES workspace_roots(root_id) ON DELETE RESTRICT, + created_at TEXT NOT NULL +) STRICT`, + }, + ], + [ + "journal_event_provenance", + { + type: "table", + sql: `CREATE TABLE journal_event_provenance ( + event_id TEXT PRIMARY KEY REFERENCES journal_events(event_id) ON DELETE RESTRICT, + source_run_id TEXT NOT NULL CHECK (length(source_run_id) > 0), + source_event_id TEXT NOT NULL CHECK (length(source_event_id) > 0) +) STRICT, WITHOUT ROWID`, + }, + ], +]); + +export const EXPECTED_SCHEMA = Object.freeze( + [...OBJECTS.entries()].map(([name, object]) => + Object.freeze({ name, type: object.type, sql: normalize(object.sql) }), + ), +); + +/** Objects version 1 declares, including the pinned Cloudflare structure. */ +export const REQUIRED_OBJECTS: readonly string[] = Object.freeze([...OBJECTS.keys()]); + +/** Tables version 1 declares. */ +export const REQUIRED_TABLES: readonly string[] = Object.freeze( + [...OBJECTS.entries()].filter(([, object]) => object.type === "table").map(([name]) => name), +); + +/** Version 1 in full. */ +export const SCHEMA_SQL = [...OBJECTS.values()] + .filter((object) => object.type === "table" && !object.sql.startsWith("CREATE TABLE vfs_")) + .filter((object) => !object.sql.startsWith("CREATE TABLE _vfs_")) + .map((object) => `${object.sql};`) + .join("\n\n"); + +/** One object a database declares, as `sqlite_schema` reports it. */ +export interface SchemaObject { + readonly type: string; + readonly name: string; + readonly sql: string; +} + +/** One statement's shape, independent of how it was laid out. */ +export function normalize(sql: string): string { + return sql.replace(/\s+/g, " ").trim(); +} + +/** + * Every in-place amendment to version 1, newest first. + * + * Each entry names what that amendment added. Peeling them off in order is what + * reconstructs the shapes that once claimed to be a complete version 1, so a + * database an earlier build produced is refused as an incomplete pre-release + * rather than as arbitrary damage. + */ +const AMENDMENTS: readonly (readonly string[])[] = Object.freeze([ + Object.freeze(["workflow_fork_lineage", "journal_event_provenance"]), + Object.freeze(["workflow_suspension_answers"]), + Object.freeze(["workspace_repositories", "workspace_worktrees"]), +]); + +/** What the newest amendment added. Its presence marks a current-shape database. */ +const LATEST_AMENDMENT: readonly string[] = AMENDMENTS[0] ?? []; + +/** The very first pre-release shape, before Workspace root retention existed. */ +const EARLIEST_PRE_RELEASE_SHAPE: readonly string[] = [ + "definition_retrieval", + "document_executions", + "journal_events", + "workflow_run", +]; + +/** + * Every later shape that once claimed to be a complete version 1. + * + * Newest first: version 1 minus the newest amendment, then minus the one before + * it, and so on. + */ +const PRIOR_COMPLETE_SHAPES: readonly (readonly string[])[] = Object.freeze( + AMENDMENTS.map((_, index) => { + const removed = new Set(AMENDMENTS.slice(0, index + 1).flat()); + return Object.freeze(REQUIRED_OBJECTS.filter((name) => !removed.has(name))); + }), +); + +/** + * Whether these declarations describe an earlier shape that once claimed to be + * a complete version 1. + * + * The very first pre-release held only the run, journal and execution tables. + * Every shape after it is version 1 minus whichever amendments had not been + * made yet, and each is named here so the refusal reads as an incomplete + * pre-release rather than as corruption. + */ +export function isIncompletePreReleaseShape(objects: readonly SchemaObject[]): boolean { + const present = new Set(objects.map((object) => object.name)); + if (LATEST_AMENDMENT.some((name) => present.has(name))) { + return false; + } + const earliest = new Set(EARLIEST_PRE_RELEASE_SHAPE); + if (present.size === earliest.size && [...present].every((name) => earliest.has(name))) { + return objects.every((object) => object.type === "table"); + } + return PRIOR_COMPLETE_SHAPES.some((shape) => { + const expected = new Set(shape); + return present.size === expected.size && [...present].every((name) => expected.has(name)); + }); +} + +/** What a structural disagreement is, without either adapter's error types. */ +export type StructureFailure = + | { readonly kind: "incomplete-pre-release" } + | { readonly kind: "undeclared-object"; readonly name: string } + | { readonly kind: "misshapen-object"; readonly name: string } + | { readonly kind: "missing-objects"; readonly names: readonly string[] }; + +/** + * Compare what a database declares with what this build writes. + * + * Answers with the disagreement rather than raising one, because the two + * adapters report the same finding as different failures: a path names the + * file the Deno host refused, and a Durable Object has no path to name. + * + * Recognizing a schema is not reading its table names. A dropped constraint and + * a column that is gone both leave the name intact, so the stored definition of + * every object is compared with the definition version 1 declares. + */ +export function declaredStructureFailure( + objects: readonly SchemaObject[], +): StructureFailure | undefined { + if (isIncompletePreReleaseShape(objects)) { + return { kind: "incomplete-pre-release" }; + } + for (const object of objects) { + const expected = OBJECTS.get(object.name); + if (expected === undefined) { + return { kind: "undeclared-object", name: object.name }; + } + if (object.type !== expected.type || normalize(object.sql) !== normalize(expected.sql)) { + return { kind: "misshapen-object", name: object.name }; + } + } + const present = new Set(objects.map((object) => object.name)); + const missing = REQUIRED_OBJECTS.filter((name) => !present.has(name)); + if (missing.length > 0) { + return { kind: "missing-objects", names: missing }; + } + return undefined; +} + +/** Whether any object version 1 declares is present at all. */ +export function hasAnyDeclaredObject(objects: readonly SchemaObject[]): boolean { + return objects.some((object) => OBJECTS.has(object.name)); +} diff --git a/packages/workflow/src/storage/agent-session.ts b/packages/workflow/src/storage/agent-session.ts new file mode 100644 index 000000000..5fd371494 --- /dev/null +++ b/packages/workflow/src/storage/agent-session.ts @@ -0,0 +1,215 @@ +/** + * What one retained Agent session is, independent of who stores it. + * + * A run remembers that a `` element was attached to a provider's + * conversation so a later execution can reattach to the same one. What it + * remembers is deliberately thin: which provider, which resolved command, the + * engine-derived expansion identity, the policy in force, and the provider's + * own assertion about the session. The conversation is the provider's and is + * never retained, sent, or reconstructed. + * + * Both hosts retain this, so the shape and the key derivation live here rather + * than inside either one. A second derivation would be two keys for one + * session, and reattachment would silently start a new conversation. + */ + +import { sha256Hex } from "../workspace/sha256.ts"; + +/** + * What a provider says about a session it created. + * + * Tagged, because "the adapter's own session id", "an ACP session id" and "a + * record id in some store" are different claims that happen to be strings. A + * host comparing them without the tag would accept one for another. + */ +export interface ProviderAssertion { + readonly kind: string; + readonly value: string; +} + +/** What identifies one logical Agent session. */ +export interface AgentSessionIdentity { + /** Which provider holds the conversation, as that provider names itself. */ + readonly provider: string; + /** The resolved agent command, not the name a document wrote. */ + readonly agentCommand: string; + /** The engine-derived Agent/Session expansion identity. Never authored. */ + readonly sessionIdentity: string; +} + +/** One retained mapping, as a run's storage holds it. */ +export interface AgentSessionRecord extends AgentSessionIdentity { + readonly sessionKey: string; + /** The session policy in force when the provider created this session. */ + readonly policy: string; + readonly assertion: ProviderAssertion; + readonly createdAt: string; +} + +/** + * The key one logical session is retained under, within this run. + * + * The engine-derived Session expansion identity and nothing else. The provider + * and the resolved agent command are compatibility attributes stored beside it: + * changing either refuses reattachment rather than addressing a second mapping, + * because a `` element that changed agent is the same element asking + * for something this run cannot give it. + * + * Digested so it stays bounded, and namespaced so a row is recognizable. + */ +export function agentSessionKey(identity: AgentSessionIdentity): string { + return ["xmd", "workflow", "v1", sha256Hex(identity.sessionIdentity).slice(0, 32)].join(":"); +} + +function text(found: Map, name: string): string | undefined { + const value = found.get(name); + return typeof value === "string" && value !== "" ? value : undefined; +} + +function members(value: unknown): Map | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return new Map(Object.entries(value)); +} + +/** + * Read one retained mapping out of a value nothing has checked. + * + * Every member, and the key recomputed from the identity rather than believed. + * A record whose key does not follow from its own identity is a record that + * would be retained under a name nothing could look it up by. + */ +export function parseAgentSessionRecord(value: unknown): AgentSessionRecord | undefined { + const found = members(value); + if (found === undefined || found.size !== 7) { + return undefined; + } + const provider = text(found, "provider"); + const agentCommand = text(found, "agentCommand"); + const sessionIdentity = text(found, "sessionIdentity"); + const sessionKey = text(found, "sessionKey"); + const policy = text(found, "policy"); + const assertion = members(found.get("assertion")); + if ( + provider === undefined || + agentCommand === undefined || + sessionIdentity === undefined || + sessionKey === undefined || + policy === undefined || + assertion === undefined || + assertion.size !== 2 + ) { + return undefined; + } + const kind = text(assertion, "kind"); + const asserted = text(assertion, "value"); + if (kind === undefined || asserted === undefined) { + return undefined; + } + const identity: AgentSessionIdentity = { provider, agentCommand, sessionIdentity }; + if (agentSessionKey(identity) !== sessionKey) { + return undefined; + } + const createdAt = text(found, "createdAt"); + if (createdAt === undefined || new Date(createdAt).toISOString() !== createdAt) { + return undefined; + } + return Object.freeze({ + ...identity, + sessionKey, + policy, + assertion: Object.freeze({ kind, value: asserted }), + createdAt, + }); +} + +/** A retained Agent session this host will not continue under. */ +export class WorkflowAgentSessionError extends Error { + override name = "WorkflowAgentSessionError"; +} + +/** Every retained mapping one run holds, as a coordinator may reach it. */ +export interface AgentSessions { + read(sessionKey: string): AgentSessionRecord | undefined; + commit(record: AgentSessionRecord): void; +} + +/** What a continuation may do with the session a key names. */ +export type AgentSessionResolution = + | { readonly kind: "create"; readonly sessionKey: string } + | { readonly kind: "reattach"; readonly record: AgentSessionRecord }; + +/** + * Decide what this attachment may do with the session this identity names. + * + * `asserted` is every canonical identity the provider currently asserts for that + * key — none, one, or more than one. It is deliberately not "does the provider + * hold this key": occupancy says something is there, not what conversation it + * is, and adopting one on that basis is how a run continues a session it cannot + * name. + */ +export function resolveAgentSession( + retained: AgentSessionRecord | undefined, + policy: string, + asserted: readonly ProviderAssertion[], + identity: AgentSessionIdentity, +): AgentSessionResolution { + const sessionKey = agentSessionKey(identity); + if (asserted.length > 1) { + throw new WorkflowAgentSessionError( + "the provider asserts more than one durable identity for this run's Agent session, so " + + "this host cannot tell which conversation it would be continuing. Start a new run " + + "rather than continuing this one.", + ); + } + const current = asserted[0]; + + if (retained === undefined) { + if (current === undefined) { + // Neither side holds anything: nothing was ever established here. + return { kind: "create", sessionKey }; + } + // The pre-commit window. An attempt was interrupted between the provider + // asserting an identity and this run recording it, and exactly one + // canonical assertion is what reconciles it — nothing else may. + return { + kind: "reattach", + record: { + sessionKey, + ...identity, + policy, + assertion: current, + createdAt: new Date().toISOString(), + }, + }; + } + + if ( + retained.provider !== identity.provider || + retained.agentCommand !== identity.agentCommand || + retained.sessionIdentity !== identity.sessionIdentity || + retained.policy !== policy + ) { + throw new WorkflowAgentSessionError( + "this run's Agent session was established under a different provider, agent or session " + + "policy than this host states, and a session created under one ceiling is not " + + "continued under another. Start a new run rather than continuing this one.", + ); + } + if (current === undefined) { + throw new WorkflowAgentSessionError( + "the provider asserts no durable identity for the Agent session this run retained, and " + + "this host does not reconstruct a conversation by replaying it into a new session. " + + "Start a new run rather than continuing this one.", + ); + } + if (current.kind !== retained.assertion.kind || current.value !== retained.assertion.value) { + throw new WorkflowAgentSessionError( + "the provider asserts a different durable identity than the Agent session this run " + + "retained, so it did not resume the conversation this run was having. This host does " + + "not continue under a replacement session.", + ); + } + return { kind: "reattach", record: retained }; +} diff --git a/packages/workflow/src/storage/create-request.ts b/packages/workflow/src/storage/create-request.ts new file mode 100644 index 000000000..21f03fe76 --- /dev/null +++ b/packages/workflow/src/storage/create-request.ts @@ -0,0 +1,112 @@ +/** + * One creation request, parsed as a closed shape before any member is read. + * + * The type describes what a caller meant. What arrives is whatever the language + * allows, and reading `.runId` off `null` fails as a `TypeError` rather than as + * an answer about the request. + * + * Shared because both providers admit the same request: the local one from a + * caller in its own process, the remote one from a command that crossed a + * connection. Two parsers would be two definitions of what a run *is*, and the + * more permissive one would decide. + */ + +import { Err, Ok, type Result } from "effection"; +import { + type JsonObject, + type Members, + parseJsonObject, + parseMembers, + requireMemberNames, +} from "./members.ts"; +import { parseWorkflowDefinition } from "./definition.ts"; +import { WorkflowRequestError } from "./errors.ts"; +import { parseRunId } from "./record.ts"; +import type { CreateWorkflowRunRequest } from "./api.ts"; +import type { WorkflowDefinition } from "./definition.ts"; + +const REQUEST_MEMBERS: readonly string[] = ["runId", "definition", "base", "props"]; + +/** A request whose every member has been checked rather than believed. */ +export interface CheckedRequest { + readonly runId: string; + readonly definition: WorkflowDefinition; + readonly base: string; + readonly props: JsonObject; +} + +export function checkRunId(runId: unknown): Result { + try { + return Ok(parseRunId(runId, "$", runIdFailure)); + } catch (error) { + if (error instanceof WorkflowRequestError) { + return Err(error); + } + throw error; + } +} + +function runIdFailure(reason: string): Error { + return new WorkflowRequestError(`${reason}.`); +} + +/** + * The whole request, parsed as a closed shape before any member is read. + * + * The type describes what a caller meant. What arrives is whatever the + * language allows, and reading `.runId` off `null` fails as a `TypeError` + * rather than as an answer about the request. + */ +export function parseCreateRequest(offered: unknown): Result { + let members: Members; + try { + members = parseMembers(offered, "$", requestFailure); + requireMemberNames(members, REQUEST_MEMBERS, "$", requestFailure); + } catch (error) { + if (error instanceof WorkflowRequestError) { + return Err(error); + } + throw error; + } + + const runId = checkRunId(members.get("runId")); + if (!runId.ok) { + return runId; + } + + const base = members.get("base"); + if (typeof base !== "string" || base === "") { + return Err( + new WorkflowRequestError("a base is required: it is what the run's starting state is."), + ); + } + + const definition = parseWorkflowDefinition(members.get("definition")); + if (!definition.ok) { + return definition; + } + + let props: JsonObject; + try { + props = parseJsonObject(members.get("props"), "$", propsFailure); + } catch (error) { + if (error instanceof WorkflowRequestError) { + return Err(error); + } + throw error; + } + + return Ok({ runId: runId.value, definition: definition.value, base, props }); +} + +function requestFailure(reason: string, path: string): Error { + return new WorkflowRequestError( + `the request does not describe a workflow run: ${reason} at ${path}`, + ); +} + +function propsFailure(reason: string, path: string): Error { + return new WorkflowRequestError( + `the normalized props are not a JSON value: ${reason} at ${path}`, + ); +} diff --git a/packages/workflow/src/storage/definition.ts b/packages/workflow/src/storage/definition.ts index cda336a5b..935e038b7 100644 --- a/packages/workflow/src/storage/definition.ts +++ b/packages/workflow/src/storage/definition.ts @@ -16,7 +16,11 @@ */ import { Err, Ok, type Result } from "effection"; -import { isCanonicalDocumentTarget, isComponentName } from "@executablemd/core"; +// The node-free subpaths: this module is reached from a Cloudflare Worker, and +// the package root's barrel resolves host modules a Worker cannot load. Both +// predicates are the same public functions, selected through a narrower path. +import { isCanonicalDocumentTarget } from "@executablemd/core/document-target"; +import { isComponentName } from "@executablemd/core/component-name"; import type { Json } from "@executablemd/durable-streams"; import { WorkflowDefinitionError } from "./errors.ts"; import { diff --git a/packages/workflow/src/storage/record.ts b/packages/workflow/src/storage/record.ts index 37c78d0f5..c626fa958 100644 --- a/packages/workflow/src/storage/record.ts +++ b/packages/workflow/src/storage/record.ts @@ -14,7 +14,10 @@ */ import { Err, Ok, type Result } from "effection"; -import { canonicalize } from "@executablemd/core"; +// The node-free subpath: this module is reached from a Cloudflare Worker, and +// the package root's barrel resolves `node:crypto` and the rest of the host +// surface. Same function, narrower resolution path. +import { canonicalize } from "@executablemd/core/canonicalize"; import type { Json } from "@executablemd/durable-streams"; import type { WorkflowDefinition } from "./definition.ts"; import { WorkflowRequestError } from "./errors.ts"; diff --git a/packages/workflow/src/suspension/answer.ts b/packages/workflow/src/suspension/answer.ts index e62ebefc2..b388212b4 100644 --- a/packages/workflow/src/suspension/answer.ts +++ b/packages/workflow/src/suspension/answer.ts @@ -44,9 +44,8 @@ import { type Result, } from "@executablemd/durable-streams"; import { WorkflowSuspensionRequestError, type WorkflowSuspensionRequest } from "./api.ts"; - -/** The effect type one delivered answer is retained under. */ -export const SUSPENSION_ANSWER = "suspension_answer"; +import { SUSPENSION_ANSWER } from "./effects.ts"; +export { SUSPENSION_ANSWER } from "./effects.ts"; /** What a live answer publication is given, and what it may do with it. */ export interface SuspensionAnswerAuthority { diff --git a/packages/workflow/src/suspension/api.ts b/packages/workflow/src/suspension/api.ts index 8151a652a..71b45ca20 100644 --- a/packages/workflow/src/suspension/api.ts +++ b/packages/workflow/src/suspension/api.ts @@ -44,7 +44,9 @@ import { type Api, createApi } from "@effectionx/context-api"; import type { Operation } from "effection"; -import { canonicalFingerprint, type Json, type JsonObject } from "@executablemd/core"; +import type { Json } from "@executablemd/durable-streams"; +import type { JsonObject } from "../storage/members.ts"; +import { fingerprintOfValue } from "./fingerprint.ts"; import { WorkflowStorageError } from "../storage/errors.ts"; /** What one durable wait is for, and what may end it. */ @@ -148,7 +150,7 @@ export function parseSuspensionRequest(value: unknown): WorkflowSuspensionReques * answer to the same question. */ export function suspensionRequestFingerprint(request: WorkflowSuspensionRequest): string { - return canonicalFingerprint({ + return fingerprintOfValue({ request: request.request, responseSchema: request.responseSchema, }); diff --git a/packages/workflow/src/suspension/delivery.ts b/packages/workflow/src/suspension/delivery.ts index 60849cd34..f983a775e 100644 --- a/packages/workflow/src/suspension/delivery.ts +++ b/packages/workflow/src/suspension/delivery.ts @@ -30,9 +30,10 @@ */ import { type Api, createApi } from "@effectionx/context-api"; -import type { Operation, Result } from "effection"; +import { Err, Ok, type Operation, type Result } from "effection"; import type { Json } from "@executablemd/core"; -import { WorkflowStorageError } from "../storage/errors.ts"; +import { checkRunId } from "../storage/create-request.ts"; +import { WorkflowRequestError, WorkflowStorageError } from "../storage/errors.ts"; /** One typed value offered to one retained durable wait. */ export interface WorkflowAnswerDelivery { @@ -94,3 +95,97 @@ export const WorkflowInputDelivery: Api = throw new WorkflowInputDeliveryProviderError(); }, }); + +/** A delivery whose every member has been checked rather than believed. */ +export interface CheckedAnswerDelivery { + readonly runId: string; + readonly suspensionId: string; + readonly value: Json; + readonly secretDetection: boolean; +} + +const DELIVERY_MEMBERS = ["runId", "suspensionId", "value", "secretDetection"]; + +/** + * The whole request, parsed as a closed shape before any member is read. + * + * The type describes what a caller meant; what arrives is whatever the language + * allows. A suspension id is opaque and every character of it is part of it, so + * the only thing asked of it is that it is a non-empty string this run could + * have derived. + */ +export function parseAnswerDelivery( + offered: WorkflowAnswerDelivery, +): Result { + if (typeof offered !== "object" || offered === null || Array.isArray(offered)) { + return Err(new WorkflowRequestError("a delivery takes an object describing one answer.")); + } + const names = new Set(Object.keys(offered)); + const missing = DELIVERY_MEMBERS.filter((name) => !names.has(name)); + if (missing.length > 0) { + return Err(new WorkflowRequestError(`the delivery is missing ${missing.join(", ")}.`)); + } + + const runId = checkRunId(Reflect.get(offered, "runId")); + if (!runId.ok) { + return runId; + } + + const suspensionId = Reflect.get(offered, "suspensionId"); + if (typeof suspensionId !== "string" || suspensionId === "") { + return Err( + new WorkflowRequestError( + "a delivery names the wait it answers, and a suspension id is a non-empty string.", + ), + ); + } + + const secretDetection = Reflect.get(offered, "secretDetection"); + if (typeof secretDetection !== "boolean") { + return Err( + new WorkflowRequestError("a delivery says whether it crosses the secret gate, as a boolean."), + ); + } + + const value = retainableJson(Reflect.get(offered, "value")); + if (value === undefined) { + return Err( + new WorkflowRequestError( + "an answer is retained in this run's storage, so it must be JSON this run can store.", + ), + ); + } + + return Ok({ runId: runId.value, suspensionId, value, secretDetection }); +} + +/** The value, if every part of it is JSON this run can retain. */ +function retainableJson(value: unknown): Json | undefined { + let encoded: string | undefined; + try { + encoded = JSON.stringify(value); + } catch { + return undefined; + } + if (encoded === undefined) { + return undefined; + } + const parsed: unknown = JSON.parse(encoded); + return isJson(parsed) ? parsed : undefined; +} + +function isJson(value: unknown): value is Json { + if (value === null || typeof value === "string" || typeof value === "number") { + return true; + } + if (typeof value === "boolean") { + return true; + } + if (Array.isArray(value)) { + return value.every(isJson); + } + if (typeof value === "object") { + return Object.values(value).every(isJson); + } + return false; +} diff --git a/packages/workflow/src/suspension/effects.ts b/packages/workflow/src/suspension/effects.ts new file mode 100644 index 000000000..0ebce1da9 --- /dev/null +++ b/packages/workflow/src/suspension/effects.ts @@ -0,0 +1,35 @@ +/** + * What names a durable wait: its two effect types, and its identity. + * + * Their own module because they are names rather than behaviour, and the + * modules that implement the behaviour reach a whole document runtime. A run's + * owner has to recognize both effect types — a request is how it knows what a + * run is waiting at, and an answer is what ends it — and an owner is not a + * document runtime. + */ + +import type { DurablePosition } from "@executablemd/durable-streams"; +import { fingerprintOfValue } from "./fingerprint.ts"; + +/** The effect type one durable wait's request is retained under. */ +export const SUSPENSION_REQUEST = "suspension_request"; + +/** The effect type one delivered answer is retained under. */ +export const SUSPENSION_ANSWER = "suspension_answer"; + +/** + * The opaque name one wait has, in this run, at this position. + * + * A digest rather than the three values joined, because the parts are a run + * identifier a caller chose and a coroutine identifier with its own separators; + * joined, two different triples could spell one string. It is opaque on + * purpose: #300 will correlate an answer to it, and a correlation key that + * revealed the position it came from would invite guessing a neighbouring wait. + */ +export function suspensionId(runId: string, position: DurablePosition): string { + return fingerprintOfValue({ + runId, + coroutineId: position.coroutineId, + index: position.index, + }).slice(0, 32); +} diff --git a/packages/workflow/src/suspension/fingerprint.ts b/packages/workflow/src/suspension/fingerprint.ts new file mode 100644 index 000000000..af1309458 --- /dev/null +++ b/packages/workflow/src/suspension/fingerprint.ts @@ -0,0 +1,18 @@ +/** + * A stable name for one value, computed the same way on every host. + * + * `canonicalFingerprint` says exactly this and reaches `node:crypto` to say it, + * which is a host builtin a run's owner cannot load. The two halves it is made + * of are both here already — the canonicalization the shared record module + * uses, and the SHA-256 the Workspace identities are computed with — so this + * composes them and produces the same digest for the same value. + */ + +import type { Json } from "@executablemd/durable-streams"; +import { canonicalJson } from "../storage/record.ts"; +import { sha256Hex } from "../workspace/sha256.ts"; + +/** The SHA-256 of a canonicalized value, as hex. */ +export function fingerprintOfValue(value: Json): string { + return sha256Hex(canonicalJson(value)); +} diff --git a/packages/workflow/src/suspension/position.ts b/packages/workflow/src/suspension/position.ts new file mode 100644 index 000000000..7ba951099 --- /dev/null +++ b/packages/workflow/src/suspension/position.ts @@ -0,0 +1,110 @@ +/** + * Whether an execution is standing at the wait it says it is. + * + * Shared rather than any one host's, because every host that ends a wait asks + * exactly this question and none of them may answer it differently. What + * decides is position: a run's storage is reached the same way through the + * neutral handle wherever it lives, and standing somewhere is not something a + * caller can claim. + */ + +import type { Operation } from "effection"; +import { fingerprintOfValue } from "./fingerprint.ts"; +import { durablePosition } from "@executablemd/durable-streams"; +import type { EffectDescription } from "@executablemd/durable-streams"; +import type { WorkflowRunDatabase } from "../storage/api.ts"; +import { parseSuspensionRequest, type WorkflowSuspensionRequest } from "./api.ts"; +import { SUSPENSION_REQUEST, suspensionId } from "./effects.ts"; + +/** + * Whether this execution is, right now, at the wait it says it is. + * + * Authority is the *current* execution reaching its own request, not the + * existence of a matching row. Retained history alone cannot decide this: on a + * resume the request from the previous execution is already in the journal, so + * a caller that ran before replay reached it could present its identifier and be + * believed. What separates the real wait from that is where the execution is. + * + * `suspendFor()` publishes its request and then enters, so by the time it gets + * here the coroutine has settled exactly one more durable yield than it had when + * the request was made — the request's own. The identifier is therefore the one + * this run derives for the position immediately behind this one, and a caller + * standing anywhere else derives a different identifier and is refused. + * + * The journal is then read to confirm that the yield at that exact position is + * this request, describing what is being presented. That is publication + * evidence, and it is checked at one position rather than searched for. + */ +export function* atOwnRequest( + database: WorkflowRunDatabase, + suspension: string, + request: WorkflowSuspensionRequest, +): Operation { + const position = yield* durablePosition(); + if (position.index === 0) { + return NOT_AT_A_WAIT; + } + const published = { + coroutineId: position.coroutineId, + index: position.index - 1, + }; + if (suspensionId(database.record.runId, published) !== suspension) { + return NOT_AT_A_WAIT; + } + + const entries = yield* database.readJournalEntries(); + if (!entries.ok) { + return NOT_AT_A_WAIT; + } + + const counts = new Map(); + let found: EffectDescription | undefined; + for (const entry of entries.value) { + if (entry.event.type !== "yield") { + continue; + } + const coroutineId = entry.event.coroutineId; + const index = counts.get(coroutineId) ?? 0; + counts.set(coroutineId, index + 1); + if (coroutineId === published.coroutineId && index === published.index) { + found = entry.event.description; + } + } + if (found === undefined || found.type !== SUSPENSION_REQUEST || found.name !== suspension) { + return NOT_AT_A_WAIT; + } + // Parsed, not merely read. A retained description is journal data, and this + // one is reached through a public durable operation any document can publish, + // so what it holds is a claim about a request rather than a request. Comparing + // raw fields would let a row that could never have come from `suspendFor()` — + // a `responseSchema` that is an array, say — admit a wait whose schema nothing + // could later validate an answer against. + let retained: WorkflowSuspensionRequest; + try { + retained = parseSuspensionRequest({ + request: found.request, + responseSchema: found.responseSchema, + }); + } catch (error) { + return ( + "the request retained at this position is not one a durable wait can be entered " + + `for: ${error instanceof Error ? error.message : String(error)}` + ); + } + + const same = + fingerprintOfValue({ + request: request.request, + responseSchema: request.responseSchema, + }) === + fingerprintOfValue({ + request: retained.request, + responseSchema: retained.responseSchema, + }); + return same ? undefined : NOT_AT_A_WAIT; +} + +export const NOT_AT_A_WAIT = + "this execution is not at that durable wait. A wait is entered by the execution that has " + + "just published its request, at the position that request was made — not by presenting an " + + "identifier a run retains somewhere else."; diff --git a/packages/workflow/src/suspension/suspend.ts b/packages/workflow/src/suspension/suspend.ts index 4c0f46e96..d41330c3b 100644 --- a/packages/workflow/src/suspension/suspend.ts +++ b/packages/workflow/src/suspension/suspend.ts @@ -44,9 +44,9 @@ */ import type { Operation } from "effection"; -import { canonicalFingerprint, type Json } from "@executablemd/core"; +import type { Json } from "@executablemd/durable-streams"; import { createDurableOperation, durablePosition } from "@executablemd/durable-streams"; -import type { DurablePosition, EffectDescription, Workflow } from "@executablemd/durable-streams"; +import type { EffectDescription, Workflow } from "@executablemd/durable-streams"; import { getWorkflowRun } from "../run.ts"; import { parseJsonValue } from "../storage/members.ts"; import { suspensionAnswerEffect } from "./answer.ts"; @@ -56,26 +56,8 @@ import { WorkflowSuspensionRequestError, type WorkflowSuspensionRequest, } from "./api.ts"; - -/** The effect type one durable wait's request is retained under. */ -export const SUSPENSION_REQUEST = "suspension_request"; - -/** - * The opaque name one wait has, in this run, at this position. - * - * A digest rather than the three values joined, because the parts are a run - * identifier a caller chose and a coroutine identifier with its own separators; - * joined, two different triples could spell one string. It is opaque on - * purpose: #300 will correlate an answer to it, and a correlation key that - * revealed the position it came from would invite guessing a neighbouring wait. - */ -export function suspensionId(runId: string, position: DurablePosition): string { - return canonicalFingerprint({ - runId, - coroutineId: position.coroutineId, - index: position.index, - }).slice(0, 32); -} +import { SUSPENSION_REQUEST, suspensionId } from "./effects.ts"; +export { SUSPENSION_REQUEST, suspensionId } from "./effects.ts"; function describeSuspension(id: string, request: WorkflowSuspensionRequest): EffectDescription { return { diff --git a/packages/workflow/src/workspace/capture.ts b/packages/workflow/src/workspace/capture.ts new file mode 100644 index 000000000..a49b0b21f --- /dev/null +++ b/packages/workflow/src/workspace/capture.ts @@ -0,0 +1,203 @@ +/** + * Turning a tree of nodes into the canonical root that names it. + * + * Capture happens in two places that share nothing else. The local host walks + * the DOFS tables inside its SQLite file; a remote runner walks a real + * directory it materialized on disk. Neither walk is shareable — one reads rows + * and the other reads a filesystem — but what the walk *means* has to be + * identical, because the root identity is a digest of the encoding and two + * hosts that encoded differently would produce two roots for one Workspace. + * + * So the walk stays with whoever can perform it, and everything after the walk + * lives here: ordering, hardlink numbering, manifest encoding, chunk identity + * and the root digest. A caller hands over what it found and receives the root + * that describes it, or a refusal saying it does not describe one. + * + * The rule this exists to protect is narrow and worth stating plainly: an + * untouched materialization must capture back to the exact root it came from. + * If it did not, every no-op Workspace operation would propose a new root, and + * a run would appear to change its Workspace by looking at it. + */ + +import { + compareUtf8, + type WorkspaceRejection, + type WorkspaceRootEntry, + validateWorkspaceRootEntries, + WORKSPACE_ROOT_DOMAIN, + WORKSPACE_ROOT_FORMAT, +} from "./root-manifest.ts"; +import { + CHUNK_SIZE, + type ContentChunkReference, + encodeContentManifest, +} from "./content-manifest.ts"; +import { sha256Hex } from "./sha256.ts"; + +/** One node a walk found, before anything is ordered or numbered. */ +export type CapturedNode = + | { + readonly path: string; + readonly kind: "directory"; + readonly mode: number; + readonly mtime: number; + } + | { + readonly path: string; + readonly kind: "symlink"; + readonly mode: number; + readonly mtime: number; + readonly target: string; + } + | { + readonly path: string; + readonly kind: "file"; + readonly mode: number; + readonly mtime: number; + readonly size: number; + /** The content manifest identity of this file's bytes. */ + readonly manifest: string; + /** + * What makes two paths the same file rather than two copies. + * + * An inode on a real filesystem, an inode number in DOFS. Two entries + * sharing one are a hardlink group; `undefined` is a file reached by one + * path. Identical bytes are deliberately *not* enough — two independent + * files that happen to match are two files, and a capture that merged + * them would materialize back as something the run never had. + */ + readonly identity: string | undefined; + }; + +/** What one file's bytes are, once chunked. */ +export interface CapturedContent { + readonly manifest: string; + readonly manifestBytes: Uint8Array; + readonly chunks: readonly ContentChunkReference[]; +} + +/** The root one capture describes, and the content it closes over. */ +export interface CapturedRoot { + readonly rootId: string; + readonly manifest: string; + readonly entries: readonly WorkspaceRootEntry[]; + /** Every content manifest identity this root names, in canonical order. */ + readonly manifests: readonly string[]; + /** Every blob identity those manifests name, in canonical order. */ + readonly blobs: readonly string[]; +} + +const encoder = new TextEncoder(); + +/** + * Split one file's bytes the way the content store splits them. + * + * An empty file has no chunks, which is not the same as having one chunk of + * nothing: its manifest names zero bytes and is still a manifest, and every + * empty file in a Workspace shares it. + */ +export function captureContent(bytes: Uint8Array): CapturedContent { + const chunks: ContentChunkReference[] = []; + for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) { + const slice = bytes.subarray(offset, Math.min(offset + CHUNK_SIZE, bytes.length)); + chunks.push({ hash: sha256Hex(slice), size: slice.length }); + } + const manifestBytes = encodeContentManifest(chunks); + return { manifest: sha256Hex(manifestBytes), manifestBytes, chunks }; +} + +/** The identity a canonical root manifest has. */ +export function workspaceRootIdOf(manifest: string): string { + return sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${manifest}`); +} + +/** + * Order the nodes, number the hardlink groups, and encode the root. + * + * Ordering is by UTF-8 bytes because that is what the format declares, and + * hardlink groups are numbered by the byte order of their first path so that + * the same tree numbers the same way whoever walked it — a group numbered by + * discovery order would depend on the walk, and the two walks are different. + * + * The result is validated against the same entry rules a stored root is read + * back through. A capture that produced something the reader would refuse is a + * bug worth finding here rather than at the owner. + */ +export function captureWorkspaceRoot( + nodes: readonly CapturedNode[], + contents: ReadonlyMap, + reject: WorkspaceRejection, +): CapturedRoot { + const ordered = nodes.toSorted((left, right) => compareUtf8(left.path, right.path)); + + const shared = new Map(); + for (const node of ordered) { + if (node.kind === "file" && node.identity !== undefined) { + shared.set(node.identity, [...(shared.get(node.identity) ?? []), node.path]); + } + } + const group = new Map(); + const groups = [...shared.values()] + .filter((paths) => paths.length > 1) + .map((paths) => paths.toSorted(compareUtf8)) + .toSorted((left, right) => compareUtf8(left[0] ?? "", right[0] ?? "")); + for (const [index, paths] of groups.entries()) { + for (const path of paths) { + group.set(path, `h${index}`); + } + } + + const entries: WorkspaceRootEntry[] = ordered.map((node) => { + if (node.kind === "directory") { + return { path: node.path, kind: node.kind, mode: node.mode, mtime: node.mtime }; + } + if (node.kind === "symlink") { + return { + path: node.path, + kind: node.kind, + mode: node.mode, + mtime: node.mtime, + target: node.target, + }; + } + return { + path: node.path, + kind: node.kind, + mode: node.mode, + mtime: node.mtime, + size: node.size, + manifest: node.manifest, + hardlink: group.get(node.path) ?? null, + }; + }); + + validateWorkspaceRootEntries(entries, reject); + const manifest = JSON.stringify({ format: WORKSPACE_ROOT_FORMAT, entries }); + + const manifests = new Set(); + const blobs = new Set(); + for (const entry of entries) { + if (entry.kind !== "file") { + continue; + } + const content = contents.get(entry.manifest); + if (content === undefined) { + reject("a captured Workspace file names content the capture did not produce"); + } + if (entry.size !== content.chunks.reduce((total, chunk) => total + chunk.size, 0)) { + reject("a captured Workspace file size disagrees with its content"); + } + manifests.add(entry.manifest); + for (const chunk of content.chunks) { + blobs.add(chunk.hash); + } + } + + return { + rootId: workspaceRootIdOf(manifest), + manifest, + entries, + manifests: [...manifests].toSorted(compareUtf8), + blobs: [...blobs].toSorted(compareUtf8), + }; +} diff --git a/packages/workflow/src/workspace/content-manifest.ts b/packages/workflow/src/workspace/content-manifest.ts new file mode 100644 index 000000000..82c04b836 --- /dev/null +++ b/packages/workflow/src/workspace/content-manifest.ts @@ -0,0 +1,129 @@ +/** + * How a file's bytes are described, once they are in the content store. + * + * A Workspace root names a file's content by one identity; it says nothing + * about how those bytes are kept. That is this format's job: an ordered list of + * chunks, each named by its own digest, encoded canonically so that identical + * bytes always produce one identity. + * + * Every host keeps content this way, so the rules are shared and name no host. + * Which store implements them, and in what tables, is the storage adapter's + * business and stays there — a neutral module that named one would be the + * Workspace surface learning where it happened to be kept. + * + * Nothing here opens a store, hashes anything or names a runtime. It decides + * whether a sequence of bytes is a canonically encoded manifest, and produces + * the bytes one ought to be. + */ + +import { SHA256, type WorkspaceRejection } from "./root-manifest.ts"; + +/** + * The size a file's bytes are split at. + * + * Pinned to what the vendored content layer uses. A writer that chunked + * differently would compute different manifest identities for identical bytes, + * and the store would then hold two names for one file. + */ +export const CHUNK_SIZE = 512 * 1024; + +/** One chunk a file's bytes are stored as. */ +export interface ContentChunkReference { + readonly hash: string; + readonly size: number; +} + +/** One file's bytes, as the store describes them. */ +export interface ContentManifest { + readonly size: number; + readonly chunks: readonly ContentChunkReference[]; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }); + +/** The bytes a content manifest is stored and identified as. */ +export function encodeContentManifest(chunks: readonly ContentChunkReference[]): Uint8Array { + return encoder.encode( + JSON.stringify({ + version: 1, + chunks: chunks.map((chunk) => ({ hash: chunk.hash, size: chunk.size })), + }), + ); +} + +function isSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value); +} + +function members(value: unknown): Map | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return new Map(Object.entries(value)); +} + +function declares(found: Map, expected: readonly string[]): boolean { + return found.size === expected.length && expected.every((name) => found.has(name)); +} + +/** + * The manifest one encoding describes, without a store to look anything up + * in. + * + * The same bytes are validated in more than one place — by a live run reading + * its own content store, by a reader checking a detached copy, and by an owner + * about to send a copy to a runner. What is decided here is only whether these + * bytes are a canonically encoded content manifest at all, and what size the + * chunks it names add up to. That the chunks exist is whoever called's to prove. + */ +export function decodeContentManifest( + encoded: Uint8Array, + reject: WorkspaceRejection, +): ContentManifest { + let text: string; + let offered: unknown; + try { + text = decoder.decode(encoded); + offered = JSON.parse(text); + } catch { + reject("a content manifest is not canonical UTF-8 JSON"); + } + const found = members(offered); + const chunks = found?.get("chunks"); + if ( + found === undefined || + !declares(found, ["version", "chunks"]) || + found.get("version") !== 1 || + !Array.isArray(chunks) + ) { + reject("a content manifest is not canonically encoded"); + } + const references: ContentChunkReference[] = []; + for (const chunk of chunks) { + const entry = members(chunk); + const hash = entry?.get("hash"); + const size = entry?.get("size"); + if ( + entry === undefined || + !declares(entry, ["hash", "size"]) || + typeof hash !== "string" || + !SHA256.test(hash) || + !isSafeInteger(size) || + size < 1 + ) { + // A zero-length chunk names no bytes, so a manifest that lists one is + // describing content it does not have. + reject("a content manifest is not canonically encoded"); + } + references.push({ hash, size }); + } + if (JSON.stringify({ version: 1, chunks: references }) !== text) { + reject("a content manifest is not canonically encoded"); + } + const total = references.reduce((sum, chunk) => sum + chunk.size, 0); + if (!Number.isSafeInteger(total)) { + reject("a content manifest names more bytes than a size can hold"); + } + return Object.freeze({ size: total, chunks: Object.freeze(references) }); +} diff --git a/packages/workflow/src/workspace/effects.ts b/packages/workflow/src/workspace/effects.ts new file mode 100644 index 000000000..5aa7c66e9 --- /dev/null +++ b/packages/workflow/src/workspace/effects.ts @@ -0,0 +1,174 @@ +/** + * How a document's Workspace work becomes one durable effect, whichever host + * holds the run. + * + * There is one set of rules for what ``, ``, ``, + * `` and the Git components mean, and there are two places a run's storage + * can live. Writing the rules twice is how the two would start to differ, so + * the rules stay where they are and this is the one thing they ask a host for: + * turn this mutation into the effect that performs it. + * + * The host answers with an effect bound to what it already proved. The local + * host closes over the lease it validated; the runner closes over the exact + * remote run its own acquisition opened. Neither authority is in this contract + * and neither can be supplied by a caller. + * + * Which binding answers is decided by the exact handle, not by anything a scope + * can reach. An attachment registers its binding under the handle it was given + * — the one its own lifecycle produced — and unregisters it when the attachment + * ends. So a document holding a second run's handle finds that run's binding or + * none, a handle nobody attached finds none, and a replaceable context value is + * never what says a database, journal or publication target belongs to a run. + */ + +import { ensure, type Operation, type Result } from "effection"; +import type { DurableEffect, EffectDescription, Json } from "@executablemd/durable-streams"; +import type { WorkflowRunDatabase } from "../storage/api.ts"; +import { WorkflowTransactionError } from "../storage/errors.ts"; +import type { WorkspaceFilesystem } from "./filesystem.ts"; +import type { WorkspaceMetadata } from "./metadata.ts"; +import type { AgentSessions } from "../storage/agent-session.ts"; + +/** + * What a Workspace mutation is given. + * + * The authoritative filesystem first, because most mutations are only about + * bytes. Retained Repository and Worktree identity follows it, in the same + * transaction, so a mutation that needs both commits both or neither. + */ +export type WorkspaceMutation = ( + filesystem: WorkspaceFilesystem, + metadata: WorkspaceMetadata, +) => Operation; + +/** + * What an ephemeral attachment is allowed to see. + * + * A checkout is rebuilt from the Workspace and proved to be the retained one on + * every partial execution, and doing that needs two things: the bytes, and the + * record that names them. It needs nothing else — no publication, no capture, + * no restore, no root selection — so this is the whole of what it is handed, + * and a host cannot hand it more by supplying a wider object. + */ +export interface WorkspaceAttachmentView { + readonly filesystem: WorkspaceFilesystem; + readonly metadata: WorkspaceMetadata; +} + +/** + * What a host contributes for one run, and the whole of it. + * + * Three things, because a document reaches exactly three kinds of Workspace + * work: the effect a mutation becomes, the read an ephemeral attachment needs, + * and the transaction an Agent-session mapping is retained by. Everything else + * the rules do is arithmetic above these. + */ +export interface WorkspaceHostBinding { + /** + * The effect that performs this mutation. + * + * Synchronous, because an effect is a value a document yields rather than + * work of its own: building one is not a place a scope may suspend, and a + * `Workflow` body yields effects and nothing else. + */ + create( + description: EffectDescription, + mutate: WorkspaceMutation, + ): DurableEffect; + /** + * Read this run's retained Workspace for one ephemeral attachment. + * + * Nothing durable happens here: a checkout is exported to a host directory + * and proved, and the Workspace is left exactly as it was found. The scope + * this opens closes before native work runs against what it exported, so no + * transaction and no owner read is held across a Git process. + */ + read(body: (view: WorkspaceAttachmentView) => Operation): Operation>; + /** + * Read and commit this run's Agent-session mappings, in one transaction. + * + * The narrow half of the run's retained state, and the only half a host + * installing an Agent profile needs. A conversation and the row naming it are + * one fact, so the mapping this returns is retained by the run's own owner or + * by nothing — and the provider that establishes the conversation is never + * called from inside that transaction. + */ + sessions(body: (sessions: AgentSessions) => Operation): Operation>; +} + +const bindings = (() => { + const held = new WeakMap(); + return { + attach(database: WorkflowRunDatabase, binding: WorkspaceHostBinding): void { + held.set(database, binding); + }, + detach(database: WorkflowRunDatabase): void { + held.delete(database); + }, + of(database: WorkflowRunDatabase): WorkspaceHostBinding | undefined { + return held.get(database); + }, + }; +})(); + +/** + * Bind this run's Workspace effects for as long as the attachment lasts. + * + * Registered for the exact handle the host attached and removed when that scope + * ends, however it ends — so an effect created after the attachment is over + * finds nothing rather than a stale filesystem or a closed transaction. + */ +export function* useWorkspaceHost( + database: WorkflowRunDatabase, + binding: WorkspaceHostBinding, +): Operation { + // What was bound before, restored when this scope ends. A handle's own host + // binds one when it opens the handle at all — reading a mapping needs no + // attachment — and an attachment binds a narrower one for as long as the + // document runs. Detaching to nothing would leave the handle unusable for + // the rest of its own life. + const outer = bindings.of(database); + bindings.attach(database, binding); + yield* ensure(() => { + if (outer === undefined) { + bindings.detach(database); + return; + } + bindings.attach(database, outer); + }); +} + +/** + * The binding this run is attached through. + * + * Refuses rather than answering with nothing: a document that reached a + * Workspace operation with no attachment is asking for a mutation no host has + * agreed to perform, and performing it against whatever filesystem happens to + * be in scope is the one outcome that must not be possible. + */ +export function workspaceHostFor(database: WorkflowRunDatabase): WorkspaceHostBinding { + const binding = bindings.of(database); + if (binding === undefined) { + throw new WorkflowTransactionError( + "this workflow run has no Workspace attachment, so a Workspace effect cannot be " + + "created. A host attaches one for a live or partial document execution.", + ); + } + return binding; +} + +/** + * Read and commit this run's Agent-session mappings, wherever it lives. + * + * The same name and the same narrow behavior a host installing an Agent profile + * has always used, answered by whichever host attached this exact handle. A + * conversation and the row naming it are one fact, so what the body stages is + * retained by the run's own owner or by nothing — and no provider call happens + * inside that transaction. + */ +export function transactAgentSessions( + database: WorkflowRunDatabase, + body: (sessions: AgentSessions) => Operation, +): Operation> { + return workspaceHostFor(database).sessions(body); +} diff --git a/packages/workflow/src/workspace/failure.ts b/packages/workflow/src/workspace/failure.ts new file mode 100644 index 000000000..d13f65df7 --- /dev/null +++ b/packages/workflow/src/workspace/failure.ts @@ -0,0 +1,28 @@ +/** + * A failure the Workspace effect publishes instead of raising. + * + * The distinction is not "what went wrong" but "who this belongs to". A failure + * of this kind is part of what the effect *did*: it is written into the journal + * as the effect's result, the Workspace root stays where it was, and a replay + * reproduces it without performing anything. Every other failure is the run + * failing, and travels as an ordinary raise. + * + * It is a base class rather than a predicate over shapes so that being + * publishable is something a failure declares by construction. A module that + * wants its own refusal published extends this; nothing acquires the property + * by resembling something. + * + * Shared because both coordinators have to make the same choice, and two + * classifiers would eventually disagree about which failures are the run's. + */ +export abstract class JournaledEffectFailure extends Error {} + +/** + * Whether this failure is the effect's outcome rather than the run's failure. + * + * Asked by the one place in each host that has to choose between writing a + * result and letting a failure through. + */ +export function isJournaledEffectFailure(error: unknown): error is Error { + return error instanceof JournaledEffectFailure; +} diff --git a/packages/workflow/src/workspace/filesystem.ts b/packages/workflow/src/workspace/filesystem.ts new file mode 100644 index 000000000..07bfc57bc --- /dev/null +++ b/packages/workflow/src/workspace/filesystem.ts @@ -0,0 +1,43 @@ +/** + * The Workspace filesystem, as an operation rather than a place. + * + * Both hosts run the same Workspace work and neither one's storage is the + * contract. The Deno host's Workspace is rows in the run's own SQLite database + * reached through DOFS; the runner's is a real directory it materialized from + * the owner. What a mutation is allowed to ask for is the same either way, so + * it is stated here and implemented twice. + * + * Every member is an `Operation`. That is not decoration: one implementation is + * synchronous by necessity and the other is asynchronous by necessity, and a + * caller written against either shape would only work against that one. + */ + +import type { Operation } from "effection"; + +export interface WorkspaceEntry { + readonly name: string; + readonly kind: "file" | "directory" | "symlink"; +} + +export interface WorkspaceStat { + readonly kind: "file" | "directory" | "symlink"; + readonly mode: number; + readonly mtime: number; + readonly size: number; +} + +export interface WorkspaceFilesystem { + readFile(path: string): Operation; + readTextFile(path: string): Operation; + stat(path: string): Operation; + lstat(path: string): Operation; + readlink(path: string): Operation; + readdir(path: string): Operation; + writeFile(path: string, content: string | Uint8Array, mode?: number): Operation; + mkdir(path: string, options?: { recursive?: boolean; mode?: number }): Operation; + remove(path: string, options?: { recursive?: boolean; force?: boolean }): Operation; + rename(from: string, to: string): Operation; + chmod(path: string, mode: number): Operation; + symlink(target: string, path: string): Operation; + link(existingPath: string, newPath: string): Operation; +} diff --git a/packages/workflow/src/workspace/metadata.ts b/packages/workflow/src/workspace/metadata.ts new file mode 100644 index 000000000..905ac9803 --- /dev/null +++ b/packages/workflow/src/workspace/metadata.ts @@ -0,0 +1,33 @@ +/** + * Retained Repository and Worktree identity, as a mutation may reach it. + * + * These rows are immutable creation identity. Insertion adds one and never + * mutates one; a reused name is answered by reading the row back and comparing + * it. Where the rows live is the host's business — SQLite rows inside the Deno + * transaction's savepoint, a detached invocation snapshot and staged deltas on + * the runner — and the rules that decide whether a reused name is the same + * repository are shared, so they are stated against this interface rather than + * against either store. + * + * The locator is retained beside the record rather than inside it. Deciding + * whether a reused name asks for the same repository needs the bytes; the + * journal and the document need only the fingerprint, and a URL that turned out + * to carry a credential is then one column rather than one history. + */ + +import type { RepositoryRecord, WorktreeRecord } from "../composition/records.ts"; + +/** A Repository row: its journal-safe record, and the locator only storage sees. */ +export interface StoredRepository { + readonly record: RepositoryRecord; + readonly locator: string; +} + +export interface WorkspaceMetadata { + readRepository(name: string): StoredRepository | undefined; + readRepositories(): StoredRepository[]; + insertRepository(stored: StoredRepository): void; + readWorktree(repositoryName: string, name: string): WorktreeRecord | undefined; + readWorktreesForRepository(repositoryName: string): WorktreeRecord[]; + insertWorktree(record: WorktreeRecord): void; +} diff --git a/packages/workflow/src/workspace/root-manifest.ts b/packages/workflow/src/workspace/root-manifest.ts new file mode 100644 index 000000000..13374a090 --- /dev/null +++ b/packages/workflow/src/workspace/root-manifest.ts @@ -0,0 +1,367 @@ +/** + * What a retained Workspace root *is*, independent of who stored it. + * + * A root is a canonical JSON manifest and the content-addressed objects its + * entries name. Its identity is the SHA-256 of a domain-separated encoding of + * that manifest, so two hosts holding the same bytes hold the same root and + * neither has to be asked. + * + * Two adapters retain roots — the Deno host in a SQLite file, the Cloudflare + * owner in the storage of one Durable Object — and a second copy of these rules + * under a second adapter would be the place they stopped agreeing. Whichever + * one is looser decides what the other must accept, and the looser one is + * always the newer one. So the rules live here once. + * + * Nothing here opens a database, hashes anything, or names a runtime. Hashing + * is deliberately absent: each host has its own primitive for it, and this + * module has no business choosing between them. What it decides is whether a + * sequence of bytes is a canonically encoded manifest at all, and what that + * manifest says. + * + * A caller supplies `reject`, because the same disagreement is reported very + * differently depending on who found it: a path names the file the Deno host + * refused, a Durable Object has no path to name, and a sealed artifact is not a + * run database at all. + */ + +/** The only root format this build reads or writes. */ +export const WORKSPACE_ROOT_FORMAT = 1; + +/** + * What a root identity is taken over, before the manifest itself. + * + * Domain separation, so a digest of a Workspace root can never collide with a + * digest of anything else this system hashes. + */ +export const WORKSPACE_ROOT_DOMAIN = "xmd-workspace-root\0v1\0"; + +/** A lowercase SHA-256 identity, which is the only spelling any of this uses. */ +export const SHA256 = /^[0-9a-f]{64}$/; + +/** How a reader says these bytes are not a root it can accept. */ +export type WorkspaceRejection = (reason: string) => never; + +/** One directory in a root. */ +export interface WorkspaceDirectoryEntry { + readonly path: string; + readonly kind: "directory"; + readonly mode: number; + readonly mtime: number; +} + +/** One file in a root, named by the DOFS manifest holding its bytes. */ +export interface WorkspaceFileEntry { + readonly path: string; + readonly kind: "file"; + readonly mode: number; + readonly mtime: number; + readonly size: number; + readonly manifest: string; + readonly hardlink: string | null; +} + +/** One symbolic link in a root. */ +export interface WorkspaceSymlinkEntry { + readonly path: string; + readonly kind: "symlink"; + readonly mode: number; + readonly mtime: number; + readonly target: string; +} + +export type WorkspaceRootEntry = + | WorkspaceDirectoryEntry + | WorkspaceFileEntry + | WorkspaceSymlinkEntry; + +export interface WorkspaceRootManifest { + readonly format: typeof WORKSPACE_ROOT_FORMAT; + readonly entries: readonly WorkspaceRootEntry[]; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }); + +/** + * Compare two paths by their UTF-8 bytes. + * + * Byte order rather than `String` order, because a root's canonical ordering is + * a property of its encoding: two hosts that sorted differently would disagree + * about whether the same entries are the same root. + */ +export function compareUtf8(left: string, right: string): number { + const a = encoder.encode(left); + const b = encoder.encode(right); + const shared = Math.min(a.length, b.length); + for (let index = 0; index < shared; index += 1) { + const first = a[index] ?? 0; + const second = b[index] ?? 0; + if (first !== second) { + return first < second ? -1 : 1; + } + } + return a.length === b.length ? 0 : a.length < b.length ? -1 : 1; +} + +/** The directory one canonical path sits in. */ +export function parentPath(path: string): string { + const boundary = path.lastIndexOf("/"); + return boundary === 0 ? "/" : path.slice(0, boundary); +} + +/** Depth first, then byte order — the order a restore creates entries in. */ +export function parentFirst(left: WorkspaceRootEntry, right: WorkspaceRootEntry): number { + const depth = left.path.split("/").length - right.path.split("/").length; + return depth === 0 ? compareUtf8(left.path, right.path) : depth; +} + +/** + * Whether text contains a code unit that is not part of a valid pair. + * + * An unpaired surrogate survives a round trip through JSON and does not survive + * one through UTF-8, so a manifest carrying one is a manifest whose bytes + * cannot be reproduced. + */ +export function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) { + return true; + } + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return true; + } + } + return false; +} + +function isSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value); +} + +function members(value: unknown): Map | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return new Map(Object.entries(value)); +} + +/** + * Whether an object declares exactly these members and no others. + * + * A manifest is compared with its own re-encoding further down, so an extra + * member would already be caught. It is refused here as well because the reason + * matters: an unknown member is a manifest this build does not understand, + * which is a different thing from bytes that were laid out differently. + */ +function declares(found: Map, expected: readonly string[]): boolean { + if (found.size !== expected.length) { + return false; + } + return expected.every((name) => found.has(name)); +} + +function mode(value: unknown): boolean { + return isSafeInteger(value) && value >= 0 && value <= 0o7777; +} + +/** + * Read one entry, in the exact member order this build writes. + * + * The order matters and is not a style choice: a manifest is compared with its + * own re-encoding, and a re-encoding that named the same members in a different + * order would be refused as noncanonical. So each branch builds its object + * literally rather than by spreading a shared prefix. + */ +function entryOf(value: unknown): WorkspaceRootEntry | undefined { + const found = members(value); + if (found === undefined) { + return undefined; + } + const path = found.get("path"); + const kind = found.get("kind"); + const entryMode = found.get("mode"); + const mtime = found.get("mtime"); + if ( + typeof path !== "string" || + !mode(entryMode) || + !isSafeInteger(entryMode) || + !isSafeInteger(mtime) + ) { + return undefined; + } + + if (kind === "directory") { + return declares(found, ["path", "kind", "mode", "mtime"]) + ? { path, kind, mode: entryMode, mtime } + : undefined; + } + if (kind === "symlink") { + const target = found.get("target"); + if (typeof target !== "string") { + return undefined; + } + return declares(found, ["path", "kind", "mode", "mtime", "target"]) + ? { path, kind, mode: entryMode, mtime, target } + : undefined; + } + if (kind !== "file") { + return undefined; + } + const size = found.get("size"); + const manifest = found.get("manifest"); + const hardlink = found.get("hardlink"); + if (!isSafeInteger(size) || size < 0 || typeof manifest !== "string" || !SHA256.test(manifest)) { + return undefined; + } + if (hardlink !== null && (typeof hardlink !== "string" || !/^h[0-9]+$/.test(hardlink))) { + return undefined; + } + return declares(found, ["path", "kind", "mode", "mtime", "size", "manifest", "hardlink"]) + ? { path, kind, mode: entryMode, mtime, size, manifest, hardlink } + : undefined; +} + +/** + * Read one root manifest out of the exact text a store retained. + * + * Three separate questions, in order: is it JSON, is it a manifest this build + * declares, and are these the exact bytes this build would have written for + * that manifest. The last one is what makes the identity meaningful — a root ID + * is a digest of these bytes, so a manifest that means the same thing and is + * spelled differently is a different root and must not be admitted as this one. + */ +export function parseWorkspaceRootManifest( + manifest: string, + reject: WorkspaceRejection, +): WorkspaceRootManifest { + let offered: unknown; + try { + offered = JSON.parse(manifest); + } catch { + reject("one of its retained Workspace roots is not JSON"); + } + const found = members(offered); + const declared = found !== undefined && declares(found, ["format", "entries"]); + const entries = found?.get("entries"); + if (!declared || found?.get("format") !== WORKSPACE_ROOT_FORMAT || !Array.isArray(entries)) { + reject("one of its retained Workspace roots has an invalid manifest"); + } + const parsed: WorkspaceRootEntry[] = []; + for (const entry of entries) { + const admitted = entryOf(entry); + if (admitted === undefined) { + reject("one of its retained Workspace roots has an invalid manifest"); + } + parsed.push(admitted); + } + const root: WorkspaceRootManifest = { format: WORKSPACE_ROOT_FORMAT, entries: parsed }; + validateWorkspaceRootEntries(parsed, reject); + if (JSON.stringify(root) !== manifest) { + reject("one of its retained Workspace roots is not canonically encoded"); + } + return root; +} + +/** + * Whether these entries describe a Workspace at all. + * + * Shape is not enough. A root is a tree, and its manifest is a flat list, so + * the tree lives in these rules: the list starts at the root directory, every + * path is canonical, order is total and by bytes, every entry has a parent that + * was already declared, and a hardlink group is numbered in the order it first + * appears and agrees with itself. + */ +export function validateWorkspaceRootEntries( + entries: readonly WorkspaceRootEntry[], + reject: WorkspaceRejection, +): void { + if (entries.length === 0 || entries[0]?.path !== "/" || entries[0]?.kind !== "directory") { + reject("a Workspace root does not begin with its root directory"); + } + + let previous: string | undefined; + let nextHardlink = 0; + const directories = new Set(); + const hardlinkMembers = new Map(); + const hardlinkFirst = new Map(); + + for (const entry of entries) { + validateCanonicalWorkspacePath(entry.path, reject); + if (previous !== undefined && compareUtf8(previous, entry.path) >= 0) { + reject("a Workspace root's paths are duplicated or out of canonical order"); + } + previous = entry.path; + + if (entry.path !== "/" && !directories.has(parentPath(entry.path))) { + reject("a Workspace root contains an entry without a parent directory"); + } + if (entry.kind === "directory") { + directories.add(entry.path); + } + if ( + entry.kind === "symlink" && + (entry.target.includes("\0") || hasUnpairedSurrogate(entry.target)) + ) { + reject("a Workspace root contains an invalid symbolic-link target"); + } + if (entry.kind === "file" && entry.hardlink !== null) { + const first = hardlinkFirst.get(entry.hardlink); + if (first === undefined) { + if (entry.hardlink !== `h${nextHardlink}`) { + reject("a Workspace root's hardlinks are not canonically numbered"); + } + nextHardlink += 1; + hardlinkFirst.set(entry.hardlink, entry); + } else if ( + first.mode !== entry.mode || + first.mtime !== entry.mtime || + first.size !== entry.size || + first.manifest !== entry.manifest + ) { + reject("a Workspace root's hardlink group has inconsistent metadata"); + } + hardlinkMembers.set(entry.hardlink, (hardlinkMembers.get(entry.hardlink) ?? 0) + 1); + } + } + + for (const count of hardlinkMembers.values()) { + if (count < 2) { + reject("a Workspace root contains a one-member hardlink group"); + } + } +} + +/** One absolute path with no traversal, no empty component and no surprises. */ +export function validateCanonicalWorkspacePath(value: string, reject: WorkspaceRejection): void { + if (value === "/") { + return; + } + if ( + !value.startsWith("/") || + value.endsWith("/") || + value.includes("\0") || + hasUnpairedSurrogate(value) + ) { + reject("a Workspace root contains a noncanonical path"); + } + for (const part of value.slice(1).split("/")) { + if (part === "" || part === "." || part === "..") { + reject("a Workspace root contains a noncanonical path component"); + } + } +} + +/** + * The Workspace every run starts from. + * + * One directory and nothing in it. Shared rather than written twice: the root + * identity is the hash of these exact bytes, so two spellings of "empty" would + * be two different starting Workspaces, and a run created by one host would not + * be recognized by the other. + */ +export const EMPTY_WORKSPACE_MANIFEST = + '{"format":1,"entries":[{"path":"/","kind":"directory","mode":493,"mtime":0}]}'; diff --git a/packages/workflow/src/workspace/sha256.ts b/packages/workflow/src/workspace/sha256.ts new file mode 100644 index 000000000..957601942 --- /dev/null +++ b/packages/workflow/src/workspace/sha256.ts @@ -0,0 +1,119 @@ +/** + * SHA-256, in the language itself. + * + * Every host this package runs on has a SHA-256 already, and none of them has + * one this code can use. `node:crypto` is a host specifier, and the whole point + * of a shared module is that it names no host. `crypto.subtle.digest()` is + * asynchronous, and the place this is needed most is inside a Durable Object's + * synchronous transaction, where there is nothing to await into. + * + * So the arithmetic lives here. A content identity is what decides whether two + * hosts are holding the same Workspace root, and a digest that differed between + * them would be two systems quietly disagreeing about history. FIPS 180-4 is + * fixed, small, and has published answers, which is why this is a reasonable + * thing to carry: the tests hold it to those answers and to the identity the + * Deno host computes with its own primitive. + * + * It hashes bytes already in memory. It is not a streaming interface and is not + * for anything large; the private protocol bounds every piece it is used on. + */ + +const INITIAL = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]); + +const ROUND = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +function rotate(value: number, bits: number): number { + return (value >>> bits) | (value << (32 - bits)); +} + +function padded(input: Uint8Array): Uint8Array { + const length = Math.ceil((input.length + 9) / 64) * 64; + const bytes = new Uint8Array(length); + bytes.set(input); + bytes[input.length] = 0x80; + const bits = BigInt(input.length) * 8n; + for (let index = 0; index < 8; index += 1) { + bytes[length - 1 - index] = Number((bits >> BigInt(index * 8)) & 0xffn); + } + return bytes; +} + +export function sha256(value: Uint8Array | string): Uint8Array { + const input = typeof value === "string" ? new TextEncoder().encode(value) : value; + const bytes = padded(input); + const state = new Uint32Array(INITIAL); + const words = new Uint32Array(64); + for (let offset = 0; offset < bytes.length; offset += 64) { + for (let index = 0; index < 16; index += 1) { + const at = offset + index * 4; + words[index] = + ((bytes[at] ?? 0) << 24) | + ((bytes[at + 1] ?? 0) << 16) | + ((bytes[at + 2] ?? 0) << 8) | + (bytes[at + 3] ?? 0); + } + for (let index = 16; index < 64; index += 1) { + const x = words[index - 15] ?? 0; + const y = words[index - 2] ?? 0; + const sigma0 = rotate(x, 7) ^ rotate(x, 18) ^ (x >>> 3); + const sigma1 = rotate(y, 17) ^ rotate(y, 19) ^ (y >>> 10); + words[index] = ((words[index - 16] ?? 0) + sigma0 + (words[index - 7] ?? 0) + sigma1) >>> 0; + } + let a = state[0] ?? 0; + let b = state[1] ?? 0; + let c = state[2] ?? 0; + let d = state[3] ?? 0; + let e = state[4] ?? 0; + let f = state[5] ?? 0; + let g = state[6] ?? 0; + let h = state[7] ?? 0; + for (let index = 0; index < 64; index += 1) { + const sum1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25); + const choice = (e & f) ^ (~e & g); + const first = (h + sum1 + choice + (ROUND[index] ?? 0) + (words[index] ?? 0)) >>> 0; + const sum0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22); + const majority = (a & b) ^ (a & c) ^ (b & c); + const second = (sum0 + majority) >>> 0; + h = g; + g = f; + f = e; + e = (d + first) >>> 0; + d = c; + c = b; + b = a; + a = (first + second) >>> 0; + } + state[0] = ((state[0] ?? 0) + a) >>> 0; + state[1] = ((state[1] ?? 0) + b) >>> 0; + state[2] = ((state[2] ?? 0) + c) >>> 0; + state[3] = ((state[3] ?? 0) + d) >>> 0; + state[4] = ((state[4] ?? 0) + e) >>> 0; + state[5] = ((state[5] ?? 0) + f) >>> 0; + state[6] = ((state[6] ?? 0) + g) >>> 0; + state[7] = ((state[7] ?? 0) + h) >>> 0; + } + const digest = new Uint8Array(32); + for (let index = 0; index < state.length; index += 1) { + const word = state[index] ?? 0; + digest[index * 4] = word >>> 24; + digest[index * 4 + 1] = word >>> 16; + digest[index * 4 + 2] = word >>> 8; + digest[index * 4 + 3] = word; + } + return digest; +} + +export function sha256Hex(value: Uint8Array | string): string { + return Array.from(sha256(value), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/packages/workflow/src/workspace/undoable.ts b/packages/workflow/src/workspace/undoable.ts new file mode 100644 index 000000000..4d89f5892 --- /dev/null +++ b/packages/workflow/src/workspace/undoable.ts @@ -0,0 +1,57 @@ +/** + * Undoing part of one Workspace mutation, whichever host is performing it. + * + * One mutation may change several things and then find that it cannot finish + * one of them. What the shared Files and composition rules do about that is + * ask for the failed part to be undone and carry on — a write that created two + * parent directories and was then refused leaves neither behind, and the + * refusal is journaled against the Workspace as it was. + * + * How that undo is performed is the host's, and its name here is deliberately + * not the local host's: that host nests a real SQLite savepoint inside the + * transaction it is already in, and the runner works in a disposable attempt + * and restores it from the accepted root. Both answer the same question, so + * the rules above them do not know which one they are running on — and a scope + * with no host answering at all refuses rather than performing work nothing can + * take back. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; +import { WorkflowTransactionError } from "../storage/errors.ts"; + +export interface TransactionApi { + /** + * Run `body` so that its work is discarded if it fails. + * + * Answers with what the body answered. A failure undoes what the body did + * and propagates, leaving the surrounding transaction open and free to + * continue or to fail on its own terms. + */ + undoable(body: Operation): Operation; +} + +/** No transaction is open in this scope, so there is nothing to undo inside. */ +export class NoOpenTransactionError extends WorkflowTransactionError { + override name = "NoOpenTransactionError"; + + constructor() { + super( + "undoing part of a mutation needs a transaction to be inside, and this scope is not " + + "inside one. Ask for one within the body a transaction hands you.", + ); + } +} + +export const Transaction: Api = createApi( + "executablemd.workflow.workspace.undoable", + { + // deno-lint-ignore require-yield + *undoable(_body: Operation): Operation { + throw new NoOpenTransactionError(); + }, + }, +); + +/** The undo operation, for whoever is inside a transaction. */ +export const undoable: TransactionApi["undoable"] = Transaction.operations.undoable; diff --git a/packages/workflow/tests/cloudflare/env.d.ts b/packages/workflow/tests/cloudflare/env.d.ts new file mode 100644 index 000000000..e0dfafaa6 --- /dev/null +++ b/packages/workflow/tests/cloudflare/env.d.ts @@ -0,0 +1,13 @@ +import type { ExecutorObject } from "./support/executor-object.ts"; +import type { OwnerObject } from "./support/owner-object.ts"; +import type { StorageProbeObject } from "./support/probe-object.ts"; + +declare global { + namespace Cloudflare { + interface Env { + STORAGE_PROBE: DurableObjectNamespace; + OWNER: DurableObjectNamespace; + EXECUTOR: DurableObjectNamespace; + } + } +} diff --git a/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts new file mode 100644 index 000000000..90f979768 --- /dev/null +++ b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts @@ -0,0 +1,422 @@ +/** + * Who may execute a run, on real workerd. + * + * Admission order and acquisition lifetime are the two things this suite is + * about. The order matters because a mismatched build must not reach a token + * and a bad token must not reach run state; the lifetime matters because the + * connection *is* the acquisition, with no lease to expire and no heartbeat to + * miss, so the only proof that ownership ended is that the socket did. + */ + +import { env, runInDurableObject } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import type { ExecutorObject } from "./support/executor-object.ts"; +import { POLICY, VALID_CLAIMS } from "./support/executor-object.ts"; +import { generateKeys, signToken, tamper, type TestKeys } from "./support/tokens.ts"; + +let unique = 0; + +function executor() { + unique += 1; + return env.EXECUTOR.get(env.EXECUTOR.idFromName(`executor-${unique}-${Math.random()}`)); +} + +function on( + stub: ReturnType, + body: (instance: ExecutorObject) => T, +): Promise> { + return runInDurableObject(stub, body) as Promise>; +} + +const RUN_ID = "5cktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; + +/** The clock the owner is configured with, so expiry is exact. */ +const NOW = 1_800_000_000; + +let keys: TestKeys; +let otherKeys: TestKeys; + +beforeAll(async () => { + keys = await generateKeys(); + otherKeys = await generateKeys("other-key"); +}); + +/** Claims a correctly issued token carries, plus any override. */ +function claims(overrides: Record = {}): Record { + return { ...VALID_CLAIMS, iat: NOW - 10, nbf: NOW - 10, exp: NOW + 600, ...overrides }; +} + +/** An owner configured with the real public key, ready to be connected to. */ +async function admitted( + stub: ReturnType, + request: Record = {}, + signWith: TestKeys = keys, + header: Record = {}, +): Promise { + await on(stub, (o) => o.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const token = "token" in request ? request["token"] : await signToken(signWith, claims(), header); + return await on(stub, (o) => o.admitConnection({ ...request, token })); +} + +describe("admitting an executor", () => { + it("admits a matching build with authenticated claims", async () => { + const stub = executor(); + expect(await admitted(stub)).toBe("admitted"); + expect(await on(stub, (o) => o.holders())).toBe(1); + }); + + it("refuses a build the owner did not agree to, before reading the token", async () => { + const stub = executor(); + // The token is deliberately unusable. If the release were checked after it, + // the refusal would name the token rather than the build. + expect(await admitted(stub, { release: "other-build", token: "not a token" })).toBe( + "release:release-mismatch", + ); + expect(await on(stub, (o) => o.holders())).toBe(0); + }); + + it("refuses an absent or malformed build identity", async () => { + const stub = executor(); + expect(await on(stub, (o) => o.admitConnection({ release: undefined }))).toBe( + "release:release-absent", + ); + expect(await on(stub, (o) => o.admitConnection({ release: "not a fingerprint" }))).toBe( + "release:release-malformed", + ); + expect(await on(stub, (o) => o.holders())).toBe(0); + }); + + it("refuses every claim the policy names, one at a time", async () => { + const cases: [string, Record][] = [ + ["admission:issuer", { iss: "https://evil.example" }], + ["admission:audience", { aud: "https://somebody-else" }], + ["admission:repository-id", { repository_id: "999" }], + ["admission:repository-owner-id", { repository_owner_id: "999" }], + ["admission:event-name", { event_name: "push" }], + [ + "admission:workflow-ref", + { + workflow_ref: "octo/repo/.github/workflows/other.yml@refs/heads/main", + }, + ], + [ + "admission:workflow-sha", + { + workflow_sha: "1111111111111111111111111111111111111111", + }, + ], + [ + "admission:workflow-identity", + { + job_workflow_ref: "octo/repo/.github/workflows/other.yml@refs/heads/main", + }, + ], + ]; + for (const [expected, overrides] of cases) { + const stub = executor(); + const token = await signToken(keys, claims(overrides)); + expect(await admitted(stub, { token })).toBe(expected); + expect(await on(stub, (o) => o.holders())).toBe(0); + } + }); + + it("accepts an audience array containing the configured one", async () => { + const stub = executor(); + const token = await signToken(keys, claims({ aud: ["https://other", POLICY.audience] })); + expect(await admitted(stub, { token })).toBe("admitted"); + }); + + it("refuses a token whose payload was edited after signing", async () => { + const stub = executor(); + const token = tamper(await signToken(keys, claims()), claims({ repository_id: "999" })); + expect(await admitted(stub, { token })).toBe("token:bad-signature"); + expect(await on(stub, (o) => o.holders())).toBe(0); + }); + + it("refuses a token naming a key the deployment does not hold", async () => { + const stub = executor(); + // Signed by another issuer, and saying so: no configured key is even a + // candidate, which is a different refusal from one that failed to verify. + expect(await admitted(stub, {}, otherKeys)).toBe("token:unknown-key"); + }); + + it("refuses a token signed with the wrong key under a configured key id", async () => { + const stub = executor(); + const token = await signToken(otherKeys, claims(), { kid: keys.kid }); + expect(await admitted(stub, { token })).toBe("token:bad-signature"); + expect(await on(stub, (o) => o.holders())).toBe(0); + }); + + it("refuses an algorithm it does not support", async () => { + const stub = executor(); + const token = await signToken(keys, claims(), { alg: "none" }); + expect(await admitted(stub, { token })).toBe("token:unsupported-algorithm"); + }); + + it("refuses a token that is absent or not a compact JWS", async () => { + const stub = executor(); + expect(await admitted(stub, { token: undefined })).toBe("token:token-absent"); + expect(await admitted(stub, { token: "one.two" })).toBe("token:token-malformed"); + }); + + it("requires every temporal claim, rather than treating an absent one as met", async () => { + for (const missing of ["exp", "iat", "nbf"]) { + const stub = executor(); + const without = claims(); + delete without[missing]; + expect(await admitted(stub, { token: await signToken(keys, without) })).toBe( + "token:malformed-claims", + ); + } + // And a claim that is present but not a NumericDate. + for (const wrong of [{ exp: "soon" }, { iat: 1.5 }, { nbf: null }]) { + const stub = executor(); + expect(await admitted(stub, { token: await signToken(keys, claims(wrong)) })).toBe( + "token:malformed-claims", + ); + } + }); + + it("treats the expiration boundary itself as expired", async () => { + // RFC 7519 wants the current time strictly before `exp`. With no skew, a + // token expiring exactly now is spent. + const exact = executor(); + await on(exact, (o) => o.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const boundary = await signToken(keys, claims({ exp: NOW })); + expect( + await on(exact, (o) => o.admitConnection({ token: boundary, release: POLICY.release })), + ).toBe("token:expired"); + }); + + it("requires a key id naming exactly one configured key", async () => { + const absent = executor(); + expect( + await admitted(absent, { token: await signToken(keys, claims(), { kid: undefined }) }), + ).toBe("token:unknown-key"); + const empty = executor(); + expect(await admitted(empty, { token: await signToken(keys, claims(), { kid: "" }) })).toBe( + "token:unknown-key", + ); + const unknown = executor(); + expect( + await admitted(unknown, { token: await signToken(keys, claims(), { kid: "nope" }) }), + ).toBe("token:unknown-key"); + }); + + it("requires the header to say it is a JWT", async () => { + const stub = executor(); + const token = await signToken(keys, claims(), { typ: "at+jwt" }); + expect(await admitted(stub, { token })).toBe("token:unsupported-type"); + }); + + it("refuses a clock configuration it cannot trust", async () => { + const negative = executor(); + await on(negative, (o) => o.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW, -1)); + expect( + await on(negative, (o) => o.admitConnection({ release: POLICY.release, token: "a.b.c" })), + ).toBe("token:misconfigured-clock"); + + const huge = executor(); + await on(huge, (o) => o.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW, 86_400)); + expect( + await on(huge, (o) => o.admitConnection({ release: POLICY.release, token: "a.b.c" })), + ).toBe("token:misconfigured-clock"); + }); + + it("refuses a token outside its validity window", async () => { + const expired = executor(); + expect( + await admitted(expired, { token: await signToken(keys, claims({ exp: NOW - 3600 })) }), + ).toBe("token:expired"); + const early = executor(); + expect( + await admitted(early, { token: await signToken(keys, claims({ nbf: NOW + 3600 })) }), + ).toBe("token:not-yet-valid"); + }); + + it("refuses a run id that could not address an owner", async () => { + const stub = executor(); + expect(await admitted(stub, { runId: "" })).toBe("run-id:run-id-empty"); + expect(await admitted(stub, { runId: 42 })).toBe("run-id:run-id-absent"); + expect(await on(stub, (o) => o.holders())).toBe(0); + }); +}); + +/** A record the owner will accept: exactly what the serializer produces. */ +function serializedEvent(name: string): string { + return serializeDurableEvent({ + type: "yield", + coroutineId: "root", + description: { type: "test", name }, + result: { status: "ok", value: name }, + }); +} + +describe("holding an acquisition", () => { + it("refuses a second healthy executor rather than following it", async () => { + const stub = executor(); + expect(await admitted(stub)).toBe("admitted"); + expect(await admitted(stub)).toBe("acquisition:already-running"); + expect(await on(stub, (o) => o.holders())).toBe(1); + }); + + it("mints its own correlation, which no caller can select or reuse", async () => { + const first = executor(); + await admitted(first); + const one = await on(first, (o) => o.acquisitionId()); + await on(first, (o) => o.closeConnection(1)); + await admitted(first); + const two = await on(first, (o) => o.acquisitionId()); + + // Bounded, unpredictable, and different for a second acquisition of the + // same run — so private staging belonging to the first cannot be addressed + // by the second. + expect(one).toMatch(/^[0-9a-f]{32}$/); + expect(two).toMatch(/^[0-9a-f]{32}$/); + expect(two).not.toBe(one); + }); + + it("lets the admitted connection send, and answers what it performed", async () => { + const stub = executor(); + await on(stub, (o) => o.initialize()); + await admitted(stub); + const answer = await on(stub, (o) => + o.send(1, JSON.stringify({ id: "1", command: "frontier" })), + ); + expect(answer).toMatchObject({ id: "1", outcome: "performed" }); + }); + + it("refuses a socket it never admitted", async () => { + const stub = executor(); + await admitted(stub); + expect( + await on(stub, (o) => o.sendAsStranger(JSON.stringify({ id: "1", command: "frontier" }))), + ).toEqual({ id: "", outcome: "refused", refusal: "acquisition:foreign-connection" }); + }); + + it("does not treat copied attachment bytes as an acquisition", async () => { + const stub = executor(); + await admitted(stub); + expect( + await on(stub, (o) => + o.sendWithCopiedAttachment(JSON.stringify({ id: "1", command: "frontier" })), + ), + ).toEqual({ id: "", outcome: "refused", refusal: "acquisition:foreign-connection" }); + }); + + it("owns nothing once the connection ends, and rolls nothing back", async () => { + const stub = executor(); + await admitted(stub); + await on(stub, (o) => o.closeConnection(1)); + expect(await on(stub, (o) => o.holders())).toBe(0); + // And the next executor may take it, with no lease having expired. + expect(await admitted(stub)).toBe("admitted"); + }); + + it("owns nothing once the runner closes its own end", async () => { + const stub = executor(); + await on(stub, (o) => o.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const token = await signToken(keys, claims()); + // Admitted the way a runner is really admitted: an upgrade, answered with + // the runner's own end of the socket, on the other side of the object. + const upgraded = await stub.fetch("https://executor.invalid/", { + headers: { + Upgrade: "websocket", + "x-run-id": RUN_ID, + "x-release": POLICY.release, + authorization: `Bearer ${token}`, + }, + }); + const runner = upgraded.webSocket; + expect(upgraded.status).toBe(101); + expect(runner).not.toBe(null); + runner?.accept(); + expect(await on(stub, (o) => o.holders())).toBe(1); + + // The runner closes its socket and tells the owner nothing else — all a + // client that has given up an acquisition can do. The release has to come + // from the platform delivering that close. + runner?.close(1000, "done"); + let holders = await on(stub, (o) => o.holders()); + for (let attempt = 0; holders !== 0 && attempt < 200; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)); + holders = await on(stub, (o) => o.holders()); + } + + expect(holders).toBe(0); + // And the replacement admission a retired acquisition depends on succeeds. + expect(await admitted(stub)).toBe("admitted"); + }); + + it("proves the acquisition before it reads a command", async () => { + const stub = executor(); + // Nothing is admitted, so even a well-formed command is refused for + // ownership rather than for its shape. + expect( + await on(stub, (o) => o.sendAsStranger(JSON.stringify({ id: "1", command: "frontier" }))), + ).toEqual({ id: "", outcome: "refused", refusal: "acquisition:not-acquired" }); + }); +}); + +describe("reading a runner command", () => { + it("refuses what it cannot read as one", async () => { + const stub = executor(); + await admitted(stub); + const refuse = async (raw: string) => + (await on(stub, (o) => o.send(1, raw))) as { refusal: string }; + expect((await refuse("not json")).refusal).toBe("command:not-an-object"); + expect((await refuse(JSON.stringify([1, 2]))).refusal).toBe("command:not-an-object"); + expect((await refuse(JSON.stringify({ id: "1", command: "explode" }))).refusal).toBe( + "command:unknown-command", + ); + expect((await refuse(JSON.stringify({ id: "1", command: "frontier", extra: 1 }))).refusal).toBe( + "command:unknown-member", + ); + expect((await refuse(JSON.stringify({ command: "frontier" }))).refusal).toBe( + "command:malformed-member", + ); + expect((await refuse(JSON.stringify({ id: "1", command: "root" }))).refusal).toBe( + "command:malformed-member", + ); + }); + + it("reads a commit intent whole, then refuses one proposed against a moved frontier", async () => { + const stub = executor(); + await on(stub, (o) => o.initialize()); + await admitted(stub); + const raw = JSON.stringify({ + id: "7", + command: "commit", + expectedWorkspaceRootId: `a${"0".repeat(63)}`, + expectedJournalEventId: null, + publication: { + proposedWorkspaceRootId: `b${"1".repeat(63)}`, + proposedManifest: "{}", + content: [], + }, + mappings: [], + events: [serializedEvent("read whole")], + answer: null, + }); + // The shape is read — an unknown member or a malformed root would refuse + // differently — and then declined on its merits: this run's frontier is not + // the root the proposal says it started from. + expect(await on(stub, (o) => o.send(1, raw))).toEqual({ + id: "7", + outcome: "refused", + refusal: "command:stale-root", + }); + expect( + await on(stub, (o) => o.send(1, JSON.stringify({ ...JSON.parse(raw), id: "8", extra: 1 }))), + ).toEqual({ id: "", outcome: "refused", refusal: "command:unknown-member" }); + }); +}); + +describe("routing a run to its owner", () => { + it("reaches one object for one run id, without a registry", () => { + const first = env.EXECUTOR.idFromName(RUN_ID).toString(); + expect(env.EXECUTOR.idFromName(RUN_ID).toString()).toBe(first); + expect(env.EXECUTOR.idFromName(`${RUN_ID}x`).toString()).not.toBe(first); + }); +}); diff --git a/packages/workflow/tests/cloudflare/owner-storage.vitest.ts b/packages/workflow/tests/cloudflare/owner-storage.vitest.ts new file mode 100644 index 000000000..1a28ea72c --- /dev/null +++ b/packages/workflow/tests/cloudflare/owner-storage.vitest.ts @@ -0,0 +1,179 @@ +/** + * The owner's storage, on real workerd. + * + * Initialization, recognition and the one transaction an owner commit runs + * inside are all properties of the runtime rather than of a model of it: the + * marker exists because the pragmas are refused, and the direct DOFS enlistment + * exists because a reentrant transaction is refused. Each object below gets a + * fresh name so its storage starts pristine. + */ + +import { env, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import type { OwnerObject } from "./support/owner-object.ts"; + +let unique = 0; + +function owner() { + unique += 1; + const name = `owner-${unique}-${Math.random().toString(36).slice(2)}`; + return env.OWNER.get(env.OWNER.idFromName(name)); +} + +function on( + stub: ReturnType, + body: (instance: OwnerObject) => T, +): Promise> { + return runInDurableObject(stub, body) as Promise>; +} + +describe("initializing an owner object", () => { + it("creates the schema, the DOFS schema, the run row and the marker together", async () => { + const stub = owner(); + expect(await on(stub, (o) => o.initialize())).toBe("initialized"); + expect(await on(stub, (o) => o.marker())).toEqual([ + { application_id: 0x584d4431, schema_version: 1 }, + ]); + expect(await on(stub, (o) => o.recognize())).toBe("recognized"); + }); + + it("refuses storage that already holds something", async () => { + const stub = owner(); + await on(stub, (o) => o.addForeignObject()); + expect(await on(stub, (o) => o.initialize())).toBe("refused:foreign"); + }); +}); + +describe("recognizing an owner object", () => { + it("refuses storage that holds nothing at all", async () => { + expect(await on(owner(), (o) => o.recognize())).toBe("refused:foreign"); + }); + + it("refuses storage carrying objects but no marker", async () => { + const stub = owner(); + await on(stub, (o) => o.addForeignObject()); + expect(await on(stub, (o) => o.recognize())).toBe("refused:foreign"); + }); + + it("refuses another application's identity", async () => { + const stub = owner(); + await on(stub, (o) => o.initialize()); + await on(stub, (o) => o.rewriteMarker(0x11111111, 1)); + expect(await on(stub, (o) => o.recognize())).toBe("refused:foreign"); + }); + + it("refuses a version this build does not implement", async () => { + const stub = owner(); + await on(stub, (o) => o.initialize()); + await on(stub, (o) => o.rewriteMarker(0x584d4431, 2)); + expect(await on(stub, (o) => o.recognize())).toBe("refused:unsupported-version"); + }); + + it("calls version zero a partial initialization rather than an old version", async () => { + // This project's identity with nothing finished under it. There has never + // been a version zero to be behind, so reporting one would send a host + // looking for a migration that cannot exist. + const stub = owner(); + await on(stub, (o) => o.initialize()); + await on(stub, (o) => o.rewriteMarker(0x584d4431, 0)); + expect(await on(stub, (o) => o.recognize())).toBe("refused:corrupt"); + }); + + it("carries a version wider than the refusal's old grammar", async () => { + const stub = owner(); + await on(stub, (o) => o.initialize()); + await on(stub, (o) => o.rewriteMarker(0x584d4431, 1_000_000)); + expect(await on(stub, (o) => o.recognize())).toBe("refused:unsupported-version"); + }); + + it("calls a version the carrier could never hold damaged retained data", async () => { + // Negative, and past the signed 32-bit carrier: no build of this project + // wrote either. A version this build has not learned and a row that cannot + // be a version are different facts. + for (const version of [-1, 0x80000000]) { + const stub = owner(); + await on(stub, (o) => o.initialize()); + await on(stub, (o) => o.rewriteMarker(0x584d4431, version)); + expect([version, await on(stub, (o) => o.recognize())]).toEqual([version, "refused:corrupt"]); + } + }); + + it("refuses a shape that disagrees with what version 1 declares", async () => { + const stub = owner(); + await on(stub, (o) => o.initialize()); + await on(stub, (o) => o.damage("workflow_suspension_answers")); + expect(await on(stub, (o) => o.recognize())).toBe("refused:corrupt"); + }); + + it("refuses a missing Cloudflare-private protocol table", async () => { + const stub = owner(); + await on(stub, (o) => o.initialize()); + await on(stub, (o) => o.damage("_xmd_executor_commands")); + expect(await on(stub, (o) => o.recognize())).toBe("refused:corrupt"); + }); +}); + +describe("an owner commit", () => { + it("publishes DOFS content and WorkflowRun rows together", async () => { + const stub = owner(); + await on(stub, (o) => o.initialize()); + expect(await on(stub, (o) => o.frontier())).toEqual({ status: "running", publishedPaths: 0 }); + + expect(await on(stub, (o) => o.commitMixedChange(false))).toBe("committed"); + expect(await on(stub, (o) => o.frontier())).toEqual({ + status: "suspended", + publishedPaths: 1, + }); + }); + + it("rolls both categories back when the body fails after changing each", async () => { + const stub = owner(); + await on(stub, (o) => o.initialize()); + expect(await on(stub, (o) => o.commitMixedChange(true))).toContain("threw:"); + + // Neither the filesystem write nor the row update may survive, and the next + // operation must not read either of them out of a cache the failed + // transaction populated. + expect(await on(stub, (o) => o.frontier())).toEqual({ status: "running", publishedPaths: 0 }); + expect(await on(stub, (o) => o.recognize())).toBe("recognized"); + }); + + it("commits after a failed attempt, from the frontier the failure left", async () => { + const stub = owner(); + await on(stub, (o) => o.initialize()); + await on(stub, (o) => o.commitMixedChange(true)); + expect(await on(stub, (o) => o.commitMixedChange(false))).toBe("committed"); + expect(await on(stub, (o) => o.frontier())).toEqual({ + status: "suspended", + publishedPaths: 1, + }); + }); +}); + +describe("owner transaction ownership", () => { + it("refuses a nested transaction on the same storage", async () => { + const stub = owner(); + await on(stub, (o) => o.initialize()); + expect(await on(stub, (o) => o.nestOnSameStorage())).toBe("refused:nested"); + }); + + it("does not couple a transaction on one storage to another storage", async () => { + // A module-level flag would refuse the second transaction because the first + // was open. Every Durable Object in an isolate shares this module and + // shares nothing else, so the guard is keyed by the storage it governs. + const stub = owner(); + await on(stub, (o) => o.initialize()); + expect(await on(stub, (o) => o.transactOnADifferentStorage())).toBe( + "committed while another storage transacted", + ); + }); + + it("releases the storage however its transaction ended", async () => { + const stub = owner(); + await on(stub, (o) => o.initialize()); + // A throwing transaction must leave the storage free for the next one. + await on(stub, (o) => o.commitMixedChange(true)); + expect(await on(stub, (o) => o.commitMixedChange(false))).toBe("committed"); + expect(await on(stub, (o) => o.nestOnSameStorage())).toBe("refused:nested"); + }); +}); diff --git a/packages/workflow/tests/cloudflare/remote-delivery.vitest.ts b/packages/workflow/tests/cloudflare/remote-delivery.vitest.ts new file mode 100644 index 000000000..beb07135d --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-delivery.vitest.ts @@ -0,0 +1,1120 @@ +/** + * Answering a run on its real owner. + * + * The facts here are the ones only a Durable Object can settle: that a delivery + * is one row written in one transaction and a refusal writes nothing at all, + * that answering takes no acquisition and leaves a live executor's socket, + * execution and lifecycle exactly where they were, and that spending the answer + * and publishing the event that answers the wait are one transaction — so a + * commit either leaves the row pending with no event, or consumed with exactly + * one. + */ + +import { env, runInDurableObject } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import type { Json } from "@executablemd/durable-streams"; +import type { ExecutorObject } from "./support/executor-object.ts"; +import { POLICY, RUN_ID, VALID_CLAIMS } from "./support/executor-object.ts"; +import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; +import { run, until, type Operation } from "effection"; +import { sha256Hex } from "../../src/workspace/sha256.ts"; +import { + cloudflareDeliveryLink, + type DeliveryAdmission, +} from "../../src/cloudflare/delivery-client.ts"; +import { canonicalJson } from "../../src/storage/record.ts"; + +let unique = 0; +const NOW = 1_800_000_000; +let keys: TestKeys; + +const SUSPENSION = "wait-1"; +const REQUEST = { kind: "approval", release: "1.4" }; +const SCHEMA = { + type: "object", + properties: { + approved: { type: "boolean" }, + note: { type: "string" }, + // Admitted by the schema on purpose: what refuses a credential here has to + // be the gate rather than the shape of the value. + password: { type: "string" }, + }, + required: ["approved"], + additionalProperties: false, +}; +const ANSWER = { approved: true }; +const FINGERPRINT = sha256Hex(canonicalJson({ request: REQUEST, responseSchema: SCHEMA })); + +/** + * A synthetic credential, assembled at run time. + * + * Written out as a literal it would be rejected by push protection, and joining + * the parts leaves the runtime value identical — so what the gate sees here is + * exactly what it would see in a delivered answer. + */ +const CANARY = `ghp_${"abcdefghijklmnopqrstuvwxyz0123456789".slice(0, 36)}`; + +/** + * A safe canary the repository's own credential rule matches. + * + * Not an issued token and not a real secret: a credential-named field carrying + * an opaque-looking value. It is here because it is exactly the shape a weaker + * detector lets through, so it is what proves the owner runs the real gate. + */ +const SAFE_CANARY = "example-Purple7Elephant"; + +beforeAll(async () => { + keys = await generateKeys(); +}); + +function executor() { + unique += 1; + return env.EXECUTOR.get(env.EXECUTOR.idFromName(`delivery-${unique}-${Math.random()}`)); +} + +async function on( + stub: ReturnType, + body: (instance: ExecutorObject) => T | Promise, +): Promise { + return await runInDurableObject(stub, body); +} + +async function token(): Promise { + return await signToken(keys, { ...VALID_CLAIMS, iat: NOW - 10, nbf: NOW - 10, exp: NOW + 600 }); +} + +/** One owner with its keys configured and one admitted executor connection. */ +async function connected(stub: ReturnType): Promise { + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const presented = await token(); + const admitted = await on(stub, (owner) => owner.admitConnection({ token: presented })); + expect(admitted).toBe("admitted"); +} + +function creation(): Record { + return { + runId: RUN_ID, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + }; +} + +/** Send one command as the connection admitted most recently. */ +async function ask( + stub: ReturnType, + command: Record, +): Promise> { + const answered = await on(stub, (owner) => owner.sendLatest(JSON.stringify(command))); + if (answered === null || typeof answered !== "object") { + throw new Error("expected one command answer"); + } + return Object.fromEntries(Object.entries(answered)); +} + +/** Ask the delivery plane, with a correct admission unless one is supplied. */ +async function deliver( + stub: ReturnType, + body: Record | string, + admission: Partial<{ release: string | null; token: string | null; runId: string | null }> = {}, +): Promise> { + const presented = "token" in admission ? (admission.token ?? null) : await token(); + const named = { + release: "release" in admission ? (admission.release ?? null) : POLICY.release, + token: presented, + runId: "runId" in admission ? (admission.runId ?? null) : RUN_ID, + }; + const encoded = typeof body === "string" ? body : JSON.stringify(body); + const answered = await on(stub, (owner) => owner.deliverRequest(named, encoded)); + return JSON.parse(answered); +} + +function requestEvent(suspensionId = SUSPENSION, responseSchema: Json = SCHEMA): string { + return serializeDurableEvent({ + type: "yield", + coroutineId: "root", + description: { + type: "suspension_request", + name: suspensionId, + suspensionId, + request: REQUEST, + responseSchema, + }, + result: { status: "ok", value: null }, + }); +} + +function answerEvent(suspensionId = SUSPENSION, value: Json = ANSWER): string { + return serializeDurableEvent({ + type: "yield", + coroutineId: "root", + description: { type: "suspension_answer", name: suspensionId, suspensionId }, + result: { status: "ok", value }, + }); +} + +/** What the owner's current frontier is, for a proposal to be built against. */ +async function frontier( + stub: ReturnType, +): Promise<{ rootId: string; eventId: string | null }> { + return await on(stub, (owner) => ({ + rootId: owner.currentRootId(), + eventId: owner.journalRecords().at(-1)?.eventId ?? null, + })); +} + +/** + * One run left suspended at one wait, with a live executor connection. + * + * The whole of what delivery reads: a run whose status is `suspended` and whose + * stop reason names the retained request event it stopped on. + */ +async function suspended( + stub: ReturnType, + suspensionId = SUSPENSION, + responseSchema: Json = SCHEMA, +): Promise { + await connected(stub); + const begun = await ask(stub, { + id: "begin-1", + command: "begin", + runId: RUN_ID, + action: "start", + creation: creation(), + retrieval: null, + executionId: "execution-1", + }); + expect(begun["outcome"]).toBe("performed"); + + const at = await frontier(stub); + const committed = await ask(stub, { + id: "commit-1", + command: "commit", + expectedWorkspaceRootId: at.rootId, + expectedJournalEventId: at.eventId, + publication: null, + mappings: [], + events: [requestEvent(suspensionId, responseSchema)], + answer: null, + }); + expect(committed["outcome"]).toBe("performed"); + const decided = committed["value"]; + const minted = + decided !== null && typeof decided === "object" + ? Reflect.get(decided, "journalEventIds") + : undefined; + const eventId = Array.isArray(minted) ? String(minted[0]) : ""; + + const settled = await ask(stub, { + id: "settle-1", + command: "settle", + completion: { + executionId: "execution-1", + status: "suspended", + reason: { kind: "journal", eventId }, + }, + expectedWorkspaceRootId: at.rootId, + }); + expect(settled["outcome"]).toBe("performed"); + return eventId; +} + +/** + * One delivery, as it crosses. + * + * It carries the value and the gate decision and nothing else — there is no + * lower operation that takes a value without them, which is the point. + */ +function delivery(overrides: Record = {}): Record { + return { + operation: "deliver", + suspensionId: SUSPENSION, + answer: canonicalJson(ANSWER), + secretDetection: true, + ...overrides, + }; +} + +/** + * Resume the suspended run on a fresh acquisition, and hold the execution open. + * + * What a claim needs: an acquisition that began an execution the run has not + * moved past. A settled execution and a bare admitted socket are both proved + * elsewhere in this file to obtain nothing. + */ +async function resumed( + stub: ReturnType, + executionId = "execution-2", +): Promise { + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + const begun = await ask(stub, { + id: `begin-${executionId}`, + command: "begin", + runId: RUN_ID, + action: "resume", + creation: null, + retrieval: null, + executionId, + }); + expect(begun["outcome"]).toBe("performed"); +} + +describe("delivering an answer to a run's owner", () => { + it("answers what the run is waiting at, and retains one value for it", async () => { + const stub = executor(); + const eventId = await suspended(stub); + + const waiting = await deliver(stub, { operation: "wait", suspensionId: SUSPENSION }); + expect(waiting["outcome"]).toBe("performed"); + expect(waiting["value"]).toEqual({ + runId: RUN_ID, + suspensionId: SUSPENSION, + requestEventId: eventId, + request: REQUEST, + responseSchema: SCHEMA, + requestFingerprint: FINGERPRINT, + }); + + const retained = await deliver(stub, delivery()); + expect(retained).toEqual({ + outcome: "performed", + value: { runId: RUN_ID, suspensionId: SUSPENSION }, + }); + + const rows = await on(stub, (owner) => owner.retainedAnswers()); + expect(rows).toHaveLength(1); + expect(rows[0]?.["suspension_id"]).toBe(SUSPENSION); + expect(rows[0]?.["request_event_id"]).toBe(eventId); + expect(rows[0]?.["request_fingerprint"]).toBe(FINGERPRINT); + expect(rows[0]?.["answer"]).toBe(canonicalJson(ANSWER)); + expect(rows[0]?.["state"]).toBe("pending"); + expect(rows[0]?.["consumed_at"]).toBe(null); + }); + + it("takes nothing, and leaves a live executor exactly where it was", async () => { + const stub = executor(); + await suspended(stub); + const before = await on(stub, (owner) => ({ + run: owner.runRow(), + executions: owner.executionRows(), + journal: owner.journalRecords(), + root: owner.currentRootId(), + holders: owner.holders(), + acquisition: owner.acquisitionId(), + })); + + expect(await deliver(stub, delivery())).toMatchObject({ outcome: "performed" }); + + const after = await on(stub, (owner) => ({ + run: owner.runRow(), + executions: owner.executionRows(), + journal: owner.journalRecords(), + root: owner.currentRootId(), + holders: owner.holders(), + acquisition: owner.acquisitionId(), + })); + // The whole run, unchanged: no execution, no event, no status, no root — + // and the acquisition that was live is the same acquisition. + expect(after).toEqual(before); + // The executor can still act, which is what "took nothing" means. + expect((await ask(stub, { id: "frontier-1", command: "frontier" }))["outcome"]).toBe( + "performed", + ); + }); + + it("re-observes one decision when its answer was lost, and refuses a different one", async () => { + const stub = executor(); + await suspended(stub); + const first = await deliver(stub, delivery()); + + // The same delivery again, after its answer never arrived. + const again = await deliver(stub, delivery()); + expect(again).toEqual(first); + expect(await on(stub, (owner) => owner.retainedAnswers())).toHaveLength(1); + + // A different value under the same wait is a second answer, not a retry. + const conflicting = await deliver( + stub, + delivery({ answer: canonicalJson({ approved: false }) }), + ); + expect(conflicting).toEqual({ + outcome: "refused", + refusal: "command:duplicate-conflict", + }); + const rows = await on(stub, (owner) => owner.retainedAnswers()); + expect(rows).toHaveLength(1); + expect(rows[0]?.["answer"]).toBe(canonicalJson(ANSWER)); + }); + + it("refuses everything it is not, and writes nothing on the way", async () => { + const stub = executor(); + await suspended(stub); + const before = await on(stub, (owner) => ({ + answers: owner.retainedAnswers(), + journal: owner.journalRecords(), + run: owner.runRow(), + })); + + const refusals = { + wrongWait: await deliver(stub, { operation: "wait", suspensionId: "wait-elsewhere" }), + wrongRequest: await deliver(stub, delivery({ suspensionId: "wait-elsewhere" })), + // A value the retained schema does not admit, offered on exactly the + // authenticated surface a valid one is offered on. + rejectedValue: await deliver(stub, delivery({ answer: canonicalJson({ approved: "yes" }) })), + // The same surface, with content the credential gate matches. + credential: await deliver( + stub, + delivery({ answer: canonicalJson({ approved: true, note: CANARY }) }), + ), + // The shape a weaker detector lets through and the configured gate does + // not. This is the whole difference between running the real gate and + // summarizing it. + safeCanary: await deliver( + stub, + delivery({ answer: canonicalJson({ approved: true, password: SAFE_CANARY }) }), + ), + // The gate decision is a required member, so omitting it is not a way of + // making it. + ungated: await deliver(stub, { + operation: "deliver", + suspensionId: SUSPENSION, + answer: canonicalJson(ANSWER), + }), + unknownOperation: await deliver(stub, { operation: "publish" }), + malformed: await deliver(stub, "{"), + // An unauthenticated request is refused before the run is named, so a + // body naming nothing this owner holds still refuses for the token. + unauthenticatedFirst: await deliver(stub, "{", { token: "not a token" }), + unknownMember: await deliver(stub, { ...delivery(), extra: 1 }), + uncanonical: await deliver(stub, delivery({ answer: '{"approved":true,"a":1}' })), + oversized: await deliver(stub, delivery({ answer: canonicalJson("x".repeat(2_000_000)) })), + // The token is deliberately unusable. If the release were checked after + // it, the refusal would name the token rather than the build. + badRelease: await deliver(stub, delivery(), { + release: "other-build", + token: "not a token", + }), + badToken: await deliver(stub, delivery(), { token: "not a token" }), + wrongRun: await deliver(stub, delivery(), { runId: "9".repeat(52) }), + }; + + for (const [named, answered] of Object.entries(refusals)) { + expect([named, answered["outcome"]]).toEqual([named, "refused"]); + } + expect(refusals.wrongWait["refusal"]).toBe("command:wrong-suspension"); + expect(refusals.wrongRequest["refusal"]).toBe("command:wrong-suspension"); + expect(refusals.rejectedValue["refusal"]).toBe("command:answer-rejected"); + expect(refusals.credential["refusal"]).toBe("command:credential-detected"); + expect(refusals.safeCanary["refusal"]).toBe("command:credential-detected"); + expect(refusals.ungated["refusal"]).toBe("storage:corrupt"); + expect(refusals.badRelease["refusal"]).toBe("release:release-mismatch"); + expect(String(refusals.unauthenticatedFirst["refusal"]).startsWith("token:")).toBe(true); + expect(refusals.wrongRun["refusal"]).toBe("command:wrong-run"); + // Byte for byte, row for row: a refusal is not a small write. + expect( + await on(stub, (owner) => ({ + answers: owner.retainedAnswers(), + journal: owner.journalRecords(), + run: owner.runRow(), + })), + ).toEqual(before); + }); + + it("runs the configured gate, and lets only the explicit opt-out past it", async () => { + const stub = executor(); + await suspended(stub); + const carrying = canonicalJson({ approved: true, password: SAFE_CANARY }); + const before = await on(stub, (owner) => ({ + answers: owner.retainedAnswers(), + journal: owner.journalRecords(), + run: owner.runRow(), + executions: owner.executionRows(), + root: owner.currentRootId(), + acquisition: owner.acquisitionId(), + private: owner.scratch(), + })); + + // The same detection the durable journal is written through refuses it, + // and nothing about the run moves. + const gated = await deliver(stub, delivery({ answer: carrying })); + expect(gated["refusal"]).toBe("command:credential-detected"); + // The refusal names a category and nothing else: not the value, not the + // rule, not what was matched. + expect(JSON.stringify(gated)).not.toContain(SAFE_CANARY); + expect( + await on(stub, (owner) => ({ + answers: owner.retainedAnswers(), + journal: owner.journalRecords(), + run: owner.runRow(), + executions: owner.executionRows(), + root: owner.currentRootId(), + acquisition: owner.acquisitionId(), + private: owner.scratch(), + })), + ).toEqual(before); + + // The documented opt-out is the only path that retains it. + const opted = await deliver(stub, delivery({ answer: carrying, secretDetection: false })); + expect(opted).toEqual({ + outcome: "performed", + value: { runId: RUN_ID, suspensionId: SUSPENSION }, + }); + const rows = await on(stub, (owner) => owner.retainedAnswers()); + expect(rows).toHaveLength(1); + expect(rows[0]?.["answer"]).toBe(carrying); + }); + + it("refuses a run that is not waiting, and one that is not here", async () => { + const running = executor(); + await connected(running); + expect( + ( + await ask(running, { + id: "begin-1", + command: "begin", + runId: RUN_ID, + action: "start", + creation: creation(), + retrieval: null, + executionId: "execution-1", + }) + )["outcome"], + ).toBe("performed"); + expect( + (await deliver(running, { operation: "wait", suspensionId: SUSPENSION }))["refusal"], + ).toBe("command:not-suspended"); + + const pristine = executor(); + await on(pristine, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + expect( + (await deliver(pristine, { operation: "wait", suspensionId: SUSPENSION }))["refusal"], + ).toBe("command:absent"); + // Naming an absent run creates nothing. + expect(await on(pristine, (owner) => owner.hasWorkflowSchema())).toBe(false); + }); +}); + +describe("reaching the delivery plane through the production client", () => { + it("asks what the run is waiting at and retains a value through the real link", async () => { + const stub = executor(); + const eventId = await suspended(stub); + const presented = await token(); + const sent: string[] = []; + const link = cloudflareDeliveryLink( + { + *send(admission: DeliveryAdmission, body: string): Operation { + sent.push(body); + return yield* until( + on(stub, (owner) => + owner.deliverRequest( + { release: admission.release, token: admission.token, runId: admission.runId }, + body, + ), + ), + ); + }, + }, + { + release: POLICY.release, + // deno-lint-ignore require-yield + *token(): Operation { + return presented; + }, + }, + ); + + const outcome = await run(function* () { + const waiting = yield* link.wait(RUN_ID, SUSPENSION); + if (!waiting.ok) { + throw waiting.error; + } + const retained = yield* link.retain({ + runId: RUN_ID, + suspensionId: SUSPENSION, + answer: ANSWER, + secretDetection: true, + }); + if (!retained.ok) { + throw retained.error; + } + return { waiting: waiting.value, retained: retained.value }; + }); + + // The client read the owner's own account of the wait, and the owner + // retained the value under exactly the identities that account named. + expect(outcome.waiting).toEqual({ + runId: RUN_ID, + suspensionId: SUSPENSION, + requestEventId: eventId, + request: REQUEST, + responseSchema: SCHEMA, + requestFingerprint: FINGERPRINT, + }); + expect(outcome.retained).toEqual({ runId: RUN_ID, suspensionId: SUSPENSION }); + const rows = await on(stub, (owner) => owner.retainedAnswers()); + expect(rows).toHaveLength(1); + expect(rows[0]?.["answer"]).toBe(canonicalJson(ANSWER)); + // Two requests, and neither of them opened a socket or named a command. + expect(sent.map((body) => JSON.parse(body)["operation"])).toEqual(["wait", "deliver"]); + expect(await on(stub, (owner) => owner.holders())).toBe(1); + }); + + it("reports an owner refusal without naming how the owner was reached", async () => { + const stub = executor(); + await suspended(stub); + const presented = await token(); + const link = cloudflareDeliveryLink( + { + *send(admission: DeliveryAdmission, body: string): Operation { + return yield* until( + on(stub, (owner) => + owner.deliverRequest( + { release: admission.release, token: admission.token, runId: admission.runId }, + body, + ), + ), + ); + }, + }, + { + release: POLICY.release, + // deno-lint-ignore require-yield + *token(): Operation { + return presented; + }, + }, + ); + + const refused = await run(function* () { + return yield* link.wait(RUN_ID, "wait-elsewhere"); + }); + + expect(refused.ok).toBe(false); + const message = refused.ok === false ? refused.error.message : ""; + expect(message).not.toContain(presented); + expect(message).not.toContain("deliverRequest"); + }); +}); + +describe("judging a delivered value at the owner", () => { + it("reaches the settled draft-07 verdict, including the arithmetic one", async () => { + const stub = executor(); + // A wait whose schema the compiler this replaced and the shared validator + // disagreed about. Draft-07 says 0.3 is a multiple of 0.1, and that is the + // verdict every boundary now reaches. + await suspended(stub, SUSPENSION, { type: "number", multipleOf: 0.1 }); + + const accepted = await deliver( + stub, + delivery({ answer: canonicalJson(0.3), secretDetection: false }), + ); + expect(accepted).toEqual({ + outcome: "performed", + value: { runId: RUN_ID, suspensionId: SUSPENSION }, + }); + expect((await on(stub, (owner) => owner.retainedAnswers()))[0]?.["answer"]).toBe("0.3"); + }); + + it("refuses a value the retained schema does not admit, and writes nothing", async () => { + const stub = executor(); + await suspended(stub, SUSPENSION, { type: "number", multipleOf: 0.1 }); + const before = await on(stub, (owner) => ({ + answers: owner.retainedAnswers(), + journal: owner.journalRecords(), + run: owner.runRow(), + executions: owner.executionRows(), + private: owner.scratch(), + })); + + const refused = await deliver( + stub, + delivery({ answer: canonicalJson("not a number"), secretDetection: false }), + ); + + expect(refused["refusal"]).toBe("command:answer-rejected"); + expect( + await on(stub, (owner) => ({ + answers: owner.retainedAnswers(), + journal: owner.journalRecords(), + run: owner.runRow(), + executions: owner.executionRows(), + private: owner.scratch(), + })), + ).toEqual(before); + }); + + it("resolves a self-contained reference, and refuses one that leaves", async () => { + const contained = executor(); + await suspended(contained, SUSPENSION, { + definitions: { decision: { type: "string", enum: ["approve", "reject"] } }, + type: "object", + properties: { decision: { $ref: "#/definitions/decision" } }, + required: ["decision"], + }); + + expect( + ( + await deliver( + contained, + delivery({ answer: canonicalJson({ decision: "approve" }), secretDetection: false }), + ) + )["outcome"], + ).toBe("performed"); + + const leaving = executor(); + await suspended(leaving, SUSPENSION, { + type: "object", + properties: { decision: { $ref: "other.json#/x" } }, + }); + const refused = await deliver( + leaving, + delivery({ answer: canonicalJson({ decision: "approve" }), secretDetection: false }), + ); + + // A schema no answer can be judged against is refused rather than retained + // on weaker terms. + expect(refused["refusal"]).toBe("command:unjudgeable-schema"); + expect(await on(leaving, (owner) => owner.retainedAnswers())).toEqual([]); + }); + + it("keeps a schema's data and declared names, and refuses a dangling reference", async () => { + // A literal that happens to carry a `format` key. Only the exact value is + // the value, and the key is data rather than an annotation. + const literal = executor(); + await suspended(literal, SUSPENSION, { const: { format: "email", x: 1 } }); + expect( + ( + await deliver( + literal, + delivery({ + answer: canonicalJson({ format: "email", x: 1 }), + secretDetection: false, + }), + ) + )["outcome"], + ).toBe("performed"); + expect((await on(literal, (owner) => owner.retainedAnswers()))[0]?.["answer"]).toBe( + canonicalJson({ format: "email", x: 1 }), + ); + + const altered = executor(); + await suspended(altered, SUSPENSION, { const: { format: "email", x: 1 } }); + const before = await on(altered, (owner) => ({ + answers: owner.retainedAnswers(), + journal: owner.journalRecords(), + run: owner.runRow(), + executions: owner.executionRows(), + private: owner.scratch(), + })); + expect( + ( + await deliver( + altered, + delivery({ answer: canonicalJson({ x: 1 }), secretDetection: false }), + ) + )["refusal"], + ).toBe("command:answer-rejected"); + expect( + await on(altered, (owner) => ({ + answers: owner.retainedAnswers(), + journal: owner.journalRecords(), + run: owner.runRow(), + executions: owner.executionRows(), + private: owner.scratch(), + })), + ).toEqual(before); + + // A property whose authored name is `format`. It is declared, so it is not + // an additional property, and the annotation beneath it constrains nothing. + const named = executor(); + await suspended(named, SUSPENSION, { + type: "object", + properties: { format: { type: "string", format: "email" } }, + required: ["format"], + additionalProperties: false, + }); + expect( + ( + await deliver( + named, + delivery({ answer: canonicalJson({ format: "not-email" }), secretDetection: false }), + ) + )["outcome"], + ).toBe("performed"); + + // A required member the value inherits rather than holds. + const inherited = executor(); + await suspended(inherited, SUSPENSION, { + type: "object", + properties: { toString: { type: "string" } }, + required: ["toString"], + additionalProperties: false, + }); + expect( + (await deliver(inherited, delivery({ answer: canonicalJson({}), secretDetection: false })))[ + "refusal" + ], + ).toBe("command:answer-rejected"); + + // A reference the schema does not define is unusable, so nothing is judged + // against it and nothing is retained. + const dangling = executor(); + await suspended(dangling, SUSPENSION, { + type: "object", + properties: { a: { $ref: "#/definitions/missing" } }, + }); + expect( + ( + await deliver( + dangling, + delivery({ answer: canonicalJson({ a: 1 }), secretDetection: false }), + ) + )["refusal"], + ).toBe("command:unjudgeable-schema"); + expect(await on(dangling, (owner) => owner.retainedAnswers())).toEqual([]); + }); + + it("judges without generating code, on the path a delivery actually takes", async () => { + const stub = executor(); + await suspended(stub, SUSPENSION, { type: "number", multipleOf: 0.1 }); + const presented = await token(); + const named = { release: POLICY.release, token: presented, runId: RUN_ID }; + const body = JSON.stringify(delivery({ answer: canonicalJson(0.3), secretDetection: false })); + + // Counted inside the object, around the delivery itself: what the owner + // does when it judges a value is what is being measured, not what a helper + // does somewhere else. A Worker refuses code generation during a request, + // and the pool only hides that because it proxies `Function` into an + // unsafe evaluation binding. + const generated = await on(stub, async (owner) => { + const original = globalThis.Function; + let made = 0; + globalThis.Function = new Proxy(original, { + construct(target, args, newTarget) { + made += 1; + return Reflect.construct(target, args, newTarget); + }, + apply(target, thisArg, args) { + made += 1; + return Reflect.apply(target, thisArg, args); + }, + }); + try { + const answered = await owner.deliverRequest(named, body); + return { made, answered }; + } finally { + globalThis.Function = original; + } + }); + + expect(JSON.parse(generated.answered)["outcome"]).toBe("performed"); + expect(generated.made).toBe(0); + }); +}); + +describe("spending a retained answer", () => { + it("releases the value only to an acquisition holding the open execution", async () => { + const stub = executor(); + const eventId = await suspended(stub); + await deliver(stub, delivery()); + const claim = (id: string) => ({ + id, + command: "answer", + suspensionId: SUSPENSION, + requestEventId: eventId, + }); + + // The socket that suspended the run is still admitted and its execution is + // settled. It holds no execution, so it is told nothing. + const settled = await ask(stub, claim("answer-settled")); + expect(settled).toEqual({ + id: "answer-settled", + outcome: "refused", + refusal: "command:wrong-execution", + }); + + // A replacement acquisition that has begun nothing is in the same position. + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + const admitted = await ask(stub, claim("answer-admitted")); + expect(admitted).toEqual({ + id: "answer-admitted", + outcome: "refused", + refusal: "command:wrong-execution", + }); + + // The execution that resumed the run is the one that may read it. + await resumed(stub); + const claiming = await ask(stub, claim("answer-open")); + expect(claiming["outcome"]).toBe("performed"); + expect(claiming["value"]).toEqual({ + suspensionId: SUSPENSION, + requestEventId: eventId, + requestFingerprint: FINGERPRINT, + answer: canonicalJson(ANSWER), + state: "pending", + }); + + // And it may read only the wait this run is standing at, named by the + // event that run published its request as. + expect( + ( + await ask(stub, { + id: "answer-elsewhere", + command: "answer", + suspensionId: "wait-elsewhere", + requestEventId: eventId, + }) + )["refusal"], + ).toBe("command:wrong-suspension"); + expect( + ( + await ask(stub, { + id: "answer-wrong-event", + command: "answer", + suspensionId: SUSPENSION, + requestEventId: "event-elsewhere", + }) + )["refusal"], + ).toBe("command:wrong-suspension"); + }); + + it("says nothing about a wait nothing was delivered to", async () => { + const stub = executor(); + const eventId = await suspended(stub); + await resumed(stub); + + // The wait exists and is the one this run is standing at; no value is + // retained for it. That is nothing rather than a refusal. + const answered = await ask(stub, { + id: "answer-1", + command: "answer", + suspensionId: SUSPENSION, + requestEventId: eventId, + }); + + expect(answered).toEqual({ id: "answer-1", outcome: "performed", value: null }); + }); + + it("consumes the row and appends its event in one transaction", async () => { + const stub = executor(); + const eventId = await suspended(stub); + await deliver(stub, delivery()); + await resumed(stub); + const at = await frontier(stub); + + const committed = await ask(stub, { + id: "commit-answer", + command: "commit", + expectedWorkspaceRootId: at.rootId, + expectedJournalEventId: at.eventId, + publication: null, + mappings: [], + events: [answerEvent()], + answer: { + suspensionId: SUSPENSION, + requestEventId: eventId, + requestFingerprint: FINGERPRINT, + }, + }); + + expect(committed["outcome"]).toBe("performed"); + const rows = await on(stub, (owner) => owner.retainedAnswers()); + expect(rows[0]?.["state"]).toBe("consumed"); + expect(rows[0]?.["consumed_at"]).not.toBe(null); + const journal = await on(stub, (owner) => owner.journalRecords()); + expect(journal.filter((entry) => entry.record === answerEvent())).toHaveLength(1); + }); + + it("spends nothing and appends nothing when the two do not agree", async () => { + const stub = executor(); + const eventId = await suspended(stub); + await deliver(stub, delivery()); + await resumed(stub); + const at = await frontier(stub); + const before = await on(stub, (owner) => ({ + answers: owner.retainedAnswers(), + journal: owner.journalRecords(), + })); + + const proposals = { + // The event carries a different value from the one that was delivered. + otherValue: { + events: [answerEvent(SUSPENSION, { approved: false })], + answer: { + suspensionId: SUSPENSION, + requestEventId: eventId, + requestFingerprint: FINGERPRINT, + }, + }, + // The consumption names a wait, and the event answers another one. + otherWait: { + events: [answerEvent("wait-2")], + answer: { + suspensionId: SUSPENSION, + requestEventId: eventId, + requestFingerprint: FINGERPRINT, + }, + }, + // Nothing is published at all, and the row is asked to be spent anyway. + noEvent: { + events: [], + answer: { + suspensionId: SUSPENSION, + requestEventId: eventId, + requestFingerprint: FINGERPRINT, + }, + }, + // The delivery this names is not the one the owner retained. + otherRequest: { + events: [answerEvent()], + answer: { + suspensionId: SUSPENSION, + requestEventId: eventId, + requestFingerprint: "c".repeat(64), + }, + }, + // One matching answer event, and a second one for the same wait carrying + // another value. One retained answer ends one wait. + twoForOneWait: { + events: [answerEvent(), answerEvent(SUSPENSION, { approved: false })], + answer: { + suspensionId: SUSPENSION, + requestEventId: eventId, + requestFingerprint: FINGERPRINT, + }, + }, + // One matching answer event, and a second one for another wait entirely. + twoForTwoWaits: { + events: [answerEvent(), answerEvent("wait-2")], + answer: { + suspensionId: SUSPENSION, + requestEventId: eventId, + requestFingerprint: FINGERPRINT, + }, + }, + // An answer event no consumption authorizes at all. + unauthorized: { events: [answerEvent()], answer: null }, + // The same, for a wait nothing was ever delivered to. + unauthorizedElsewhere: { events: [answerEvent("wait-2")], answer: null }, + }; + + let attempt = 0; + for (const [named, proposal] of Object.entries(proposals)) { + attempt += 1; + const refused = await ask(stub, { + id: `spend-${attempt}`, + command: "commit", + expectedWorkspaceRootId: at.rootId, + expectedJournalEventId: at.eventId, + publication: null, + mappings: [], + ...proposal, + }); + const expected = + named === "twoForOneWait" || + named === "twoForTwoWaits" || + named === "unauthorized" || + named === "unauthorizedElsewhere" || + // A consumption with no answer event at all is the same violation seen + // from the other side: nothing it spends would end anything. + named === "noEvent" + ? "command:answer-unauthorized" + : "command:answer-unavailable"; + expect([named, refused["outcome"], refused["refusal"]]).toEqual([named, "refused", expected]); + } + + // Neither half happened: the row is still pending and no event was kept. + expect( + await on(stub, (owner) => ({ + answers: owner.retainedAnswers(), + journal: owner.journalRecords(), + })), + ).toEqual(before); + }); + + it("refuses a consumption from an acquisition that holds no open execution", async () => { + const stub = executor(); + const eventId = await suspended(stub); + await deliver(stub, delivery()); + const at = await frontier(stub); + const before = await on(stub, (owner) => ({ + answers: owner.retainedAnswers(), + journal: owner.journalRecords(), + })); + const spend = (id: string) => ({ + id, + command: "commit", + expectedWorkspaceRootId: at.rootId, + expectedJournalEventId: at.eventId, + publication: null, + mappings: [], + events: [answerEvent()], + answer: { + suspensionId: SUSPENSION, + requestEventId: eventId, + requestFingerprint: FINGERPRINT, + }, + }); + + // The acquisition that suspended the run: still admitted, execution + // settled. A socket is not an execution. + expect((await ask(stub, spend("spend-settled")))["refusal"]).toBe("command:wrong-execution"); + + // A replacement acquisition that has begun nothing. + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + expect((await ask(stub, spend("spend-admitted")))["refusal"]).toBe("command:wrong-execution"); + + expect( + await on(stub, (owner) => ({ + answers: owner.retainedAnswers(), + journal: owner.journalRecords(), + })), + ).toEqual(before); + }); + + it("refuses to spend an answer a second time", async () => { + const stub = executor(); + const eventId = await suspended(stub); + await deliver(stub, delivery()); + await resumed(stub); + const at = await frontier(stub); + const spend = (id: string, expected: string | null) => ({ + id, + command: "commit", + expectedWorkspaceRootId: at.rootId, + expectedJournalEventId: expected, + publication: null, + mappings: [], + events: [answerEvent()], + answer: { + suspensionId: SUSPENSION, + requestEventId: eventId, + requestFingerprint: FINGERPRINT, + }, + }); + + expect((await ask(stub, spend("spend-1", at.eventId)))["outcome"]).toBe("performed"); + const after = await frontier(stub); + const again = await ask(stub, spend("spend-2", after.eventId)); + + expect(again).toEqual({ + id: "spend-2", + outcome: "refused", + refusal: "command:answer-unavailable", + }); + // One answer event, and the row spent once. + const journal = await on(stub, (owner) => owner.journalRecords()); + expect(journal.filter((entry) => entry.record === answerEvent())).toHaveLength(1); + expect((await on(stub, (owner) => owner.retainedAnswers()))[0]?.["state"]).toBe("consumed"); + }); +}); diff --git a/packages/workflow/tests/cloudflare/remote-fork.vitest.ts b/packages/workflow/tests/cloudflare/remote-fork.vitest.ts new file mode 100644 index 000000000..bcbf9cf43 --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-fork.vitest.ts @@ -0,0 +1,847 @@ +/** + * Committing a fork on a real destination owner. + * + * Staging is scratch and the commit is one transaction: what this suite settles + * is that a destination is either absent or whole, that the parts it was built + * from stop being anything the moment they are adopted, and that a transfer + * which does not add up refuses without leaving a half-run behind. + */ + +import { env, runInDurableObject } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import type { ExecutorObject } from "./support/executor-object.ts"; +import { + BLOB_ID, + DOFS_MANIFEST, + FILE_BYTES, + MANIFEST_ID, + POLICY, + ROOT_ID, + ROOT_MANIFEST, + RUN_ID, + VALID_CLAIMS, +} from "./support/executor-object.ts"; +import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; +import { forkSelectionAnchor } from "../../src/cloudflare/fork-anchor.ts"; +import { sha256Hex } from "../../src/workspace/sha256.ts"; +import { WORKSPACE_ROOT_DOMAIN } from "../../src/workspace/root-manifest.ts"; +import { forkRunRecordEvent } from "../../src/journal-events.ts"; + +let unique = 0; +const NOW = 1_800_000_000; +const SOURCE_RUN_ID = "6dktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; +let keys: TestKeys; + +beforeAll(async () => { + keys = await generateKeys(); +}); + +function executor() { + unique += 1; + return env.EXECUTOR.get(env.EXECUTOR.idFromName(`fork-${unique}-${Math.random()}`)); +} + +function on( + stub: ReturnType, + body: (instance: ExecutorObject) => T, +): Promise { + return runInDurableObject(stub, body); +} + +async function connected(stub: ReturnType): Promise { + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const token = await signToken(keys, { + ...VALID_CLAIMS, + iat: NOW - 10, + nbf: NOW - 10, + exp: NOW + 600, + }); + expect(await on(stub, (owner) => owner.admitConnection({ token }))).toBe("admitted"); +} + +async function ask( + stub: ReturnType, + command: Record, +): Promise> { + const answered = await on(stub, (owner) => owner.sendLatest(JSON.stringify(command))); + if (answered === null || typeof answered !== "object") { + throw new Error("expected one command answer"); + } + return Object.fromEntries(Object.entries(answered)); +} + +function base64(bytes: Uint8Array): string { + let text = ""; + for (const byte of bytes) { + text += String.fromCharCode(byte); + } + return btoa(text); +} + +function event(name: string): string { + return serializeDurableEvent({ + type: "yield", + coroutineId: "root", + description: { type: "test", name }, + result: { status: "ok", value: name }, + }); +} + +// The canonical record this destination's own identity implies. Composed by +// the shared helper, because the owner holds the head to exactly that. +const HEAD = serializeDurableEvent( + forkRunRecordEvent({ runId: RUN_ID, base: "main", pinnedCommit: "0".repeat(40) }), +); + +const IMPORT = serializeDurableEvent({ + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { status: "ok", value: { kind: "repository", path: "README.md", content: "# fork" } }, +}); + +const ROOT_PART = { + rootId: ROOT_ID, + formatVersion: 1, + manifest: ROOT_MANIFEST, + manifestHashes: [MANIFEST_ID], + blobHashes: [BLOB_ID], +}; +const MANIFEST_PART = { hash: MANIFEST_ID, size: FILE_BYTES.length, lastSeen: 7 }; +const BLOB_PART = { hash: BLOB_ID, size: FILE_BYTES.length, lastSeen: 9 }; +const INHERITED_PART = { + eventId: "event-work", + record: event("work"), + workspaceRootId: ROOT_ID, +}; + +/** The anchor this selection has, computed the way the source computes it. */ +function anchorOf(overrides: Record = {}): string { + return forkSelectionAnchor({ + checkpointEventId: "event-work", + checkpointWorkspaceRootId: ROOT_ID, + runRecordWorkspaceRootId: ROOT_ID, + rootImportWorkspaceRootId: ROOT_ID, + inherited: [INHERITED_PART], + roots: [ROOT_PART], + manifests: [{ ...MANIFEST_PART, encoded: base64(new TextEncoder().encode(DOFS_MANIFEST)) }], + blobs: [BLOB_PART], + checkouts: [], + ...overrides, + }); +} + +/** Offer everything one small source is made of, as the runner would. */ +async function offer(stub: ReturnType, id: () => string): Promise { + expect( + ( + await ask(stub, { + id: id(), + command: "stage", + kind: "manifest", + digest: MANIFEST_ID, + bytes: base64(new TextEncoder().encode(DOFS_MANIFEST)), + }) + )["outcome"], + ).toBe("performed"); + expect( + ( + await ask(stub, { + id: id(), + command: "stage", + kind: "blob", + digest: BLOB_ID, + bytes: base64(FILE_BYTES), + }) + )["outcome"], + ).toBe("performed"); + expect( + ( + await ask(stub, { + id: id(), + command: "fork-stage", + section: "roots", + position: 0, + part: ROOT_PART, + }) + )["outcome"], + ).toBe("performed"); + expect( + ( + await ask(stub, { + id: id(), + command: "fork-stage", + section: "inherited", + position: 0, + part: INHERITED_PART, + }) + )["outcome"], + ).toBe("performed"); + // The metadata a digest cannot stand for, offered beside the content. + const metadata: { section: string; part: Record }[] = [ + { section: "manifests", part: MANIFEST_PART }, + { section: "blobs", part: BLOB_PART }, + ]; + for (const offered of metadata) { + expect( + ( + await ask(stub, { + id: id(), + command: "fork-stage", + section: offered.section, + position: 0, + part: offered.part, + }) + )["outcome"], + ).toBe("performed"); + } +} + +/** The event ids one published snapshot reports, in journal order. */ +function journalOf(published: Record): string[] { + const events = published["events"]; + return Array.isArray(events) + ? events.map((row) => String(Reflect.get(Object(row), "event_id"))) + : []; +} + +function commit(overrides: Record = {}): Record { + return { + id: "fork-commit", + command: "fork", + runId: RUN_ID, + creation: { + runId: RUN_ID, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + }, + origin: { + sourceRunId: SOURCE_RUN_ID, + checkpointEventId: "event-work", + checkpointWorkspaceRootId: ROOT_ID, + runRecordWorkspaceRootId: ROOT_ID, + rootImportWorkspaceRootId: ROOT_ID, + anchor: anchorOf(), + }, + retrieval: null, + counts: { inherited: 1, roots: 1, manifests: 1, blobs: 1, checkouts: 0 }, + runRecord: HEAD, + rootImport: IMPORT, + executionId: "execution-1", + ...overrides, + }; +} + +describe("committing a fork on its destination owner", () => { + it("makes the destination whole on pristine storage, in one commit", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + const id = () => `command-${(minted += 1)}`; + await offer(stub, id); + + expect(await on(stub, (owner) => owner.hasWorkflowSchema())).toBe(false); + const forked = await ask(stub, commit()); + expect(forked["outcome"]).toBe("performed"); + + const state = await on(stub, (owner) => ({ + run: owner.runRow(), + executions: owner.executionRows(), + workspace: owner.published(), + held: owner.heldExecutions(), + parts: owner.forkParts(), + })); + // The prefix it inherited, its own two head records, its Workspace, its + // lineage and its first execution all arrived together. + expect(state.run?.["status"]).toBe("running"); + expect(state.workspace["currentRootId"]).toBe(ROOT_ID); + expect(journalOf(state.workspace)).toContain("event-work"); + expect(journalOf(state.workspace)).toHaveLength(3); + expect(state.executions).toHaveLength(1); + expect(state.held).toHaveLength(1); + // The parts stopped being anything the moment they were adopted. + expect(state.parts).toEqual([]); + }); + + it("copies the content, so the destination needs no source afterwards", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + await ask(stub, commit()); + + const workspace = await on(stub, (owner) => owner.published()); + // The blob and the reference are the destination's own rows now. + expect(Number(workspace["blobs"])).toBeGreaterThan(0); + expect(Number(workspace["blobRefs"])).toBeGreaterThan(0); + expect(Number(workspace["roots"])).toBeGreaterThan(0); + }); + + it("refuses a transfer that does not add up, and leaves nothing behind", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + + // One more inherited row than was ever offered. + const refused = await ask(stub, commit({ counts: { inherited: 2, roots: 1, checkouts: 0 } })); + + expect(refused["outcome"]).toBe("refused"); + // No half-run: the destination still holds no schema at all. + expect(await on(stub, (owner) => owner.hasWorkflowSchema())).toBe(false); + }); + + it("refuses a head root the transfer never carried", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + + const refused = await ask( + stub, + commit({ + origin: { + sourceRunId: SOURCE_RUN_ID, + checkpointEventId: "event-work", + checkpointWorkspaceRootId: ROOT_ID, + runRecordWorkspaceRootId: "b".repeat(64), + rootImportWorkspaceRootId: ROOT_ID, + anchor: anchorOf({ runRecordWorkspaceRootId: "b".repeat(64) }), + }, + }), + ); + + expect(refused["outcome"]).toBe("refused"); + expect(await on(stub, (owner) => owner.hasWorkflowSchema())).toBe(false); + }); + + it("answers a repeated commit with the destination it already made", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + + const first = await ask(stub, commit()); + const again = await ask(stub, commit()); + + expect(again).toEqual(first); + // One run, one execution, one journal: the retry found the decision. + const state = await on(stub, (owner) => ({ + executions: owner.executionRows(), + workspace: owner.published(), + })); + expect(state.executions).toHaveLength(1); + expect(journalOf(state.workspace)).toHaveLength(3); + }); + + it("keeps one connection's offered parts to itself", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + expect(await on(stub, (owner) => owner.forkParts())).toHaveLength(4); + + // A replacement acquisition inherits nothing its predecessor offered. + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + + expect(await on(stub, (owner) => owner.forkParts())).toEqual([]); + }); + + it("refuses an anchor that is not this selection's, before it creates anything", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + + // Well-formed, and not the digest this selection produces. + const refused = await ask( + stub, + commit({ + origin: { + sourceRunId: SOURCE_RUN_ID, + checkpointEventId: "event-work", + checkpointWorkspaceRootId: ROOT_ID, + runRecordWorkspaceRootId: ROOT_ID, + rootImportWorkspaceRootId: ROOT_ID, + anchor: "f".repeat(64), + }, + }), + ); + + expect(refused["outcome"]).toBe("refused"); + expect(await on(stub, (owner) => owner.hasWorkflowSchema())).toBe(false); + }); + + it("refuses a changed watermark, which no content digest stands for", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + const id = () => `command-${(minted += 1)}`; + // Everything as before, except the blob's copied watermark. + await ask(stub, { + id: id(), + command: "stage", + kind: "manifest", + digest: MANIFEST_ID, + bytes: base64(new TextEncoder().encode(DOFS_MANIFEST)), + }); + await ask(stub, { + id: id(), + command: "stage", + kind: "blob", + digest: BLOB_ID, + bytes: base64(FILE_BYTES), + }); + await ask(stub, { + id: id(), + command: "fork-stage", + section: "roots", + position: 0, + part: ROOT_PART, + }); + await ask(stub, { + id: id(), + command: "fork-stage", + section: "inherited", + position: 0, + part: INHERITED_PART, + }); + await ask(stub, { + id: id(), + command: "fork-stage", + section: "manifests", + position: 0, + part: MANIFEST_PART, + }); + await ask(stub, { + id: id(), + command: "fork-stage", + section: "blobs", + position: 0, + part: { ...BLOB_PART, lastSeen: BLOB_PART.lastSeen + 1 }, + }); + + const refused = await ask(stub, commit()); + expect(refused["outcome"]).toBe("refused"); + expect(await on(stub, (owner) => owner.hasWorkflowSchema())).toBe(false); + }); + + it("refuses a head record that is not the one this fork's identity implies", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + + const refused = await ask( + stub, + commit({ + runRecord: serializeDurableEvent( + forkRunRecordEvent({ runId: RUN_ID, base: "other", pinnedCommit: "0".repeat(40) }), + ), + }), + ); + + expect(refused["outcome"]).toBe("refused"); + expect(await on(stub, (owner) => owner.hasWorkflowSchema())).toBe(false); + }); + + it("retains the watermarks the source copied rather than starting them again", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + await ask(stub, commit()); + + const watermarks = await on(stub, (owner) => owner.contentWatermarks()); + expect(watermarks.manifests).toEqual([MANIFEST_PART.lastSeen]); + expect(watermarks.blobs).toEqual([BLOB_PART.lastSeen]); + }); + + it("recovers the previous executor's work before a later fork begins again", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + expect((await ask(stub, commit()))["outcome"]).toBe("performed"); + + // The executor that committed the fork is gone with its execution open. + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + await offer(stub, () => `later-${(minted += 1)}`); + const again = await ask(stub, commit({ id: "fork-again", executionId: "execution-2" })); + + expect(again["outcome"]).toBe("performed"); + const executions = await on(stub, (owner) => owner.executionRows()); + // The first was closed by recovery, and exactly one replacement began. + expect(executions).toHaveLength(2); + expect(executions[0]?.["stop_status"]).toBe("interrupted"); + expect(executions[1]?.["stopped_at"]).toBe(null); + expect(executions.filter((row) => row["stopped_at"] === null)).toHaveLength(1); + }); + + it("writes the retrieval its creation carried, with the run", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + await ask(stub, commit({ retrieval: { kind: "git", remote: "origin" } })); + + const retrieval = await on(stub, (owner) => owner.retrieval()); + expect(retrieval?.["revision"]).toBe(1); + expect(JSON.parse(String(retrieval?.["metadata"]))).toEqual({ + kind: "git", + remote: "origin", + }); + }); +}); + +/** Take up a destination that already holds this fork, naming no source. */ +function continuation(overrides: Record = {}): Record { + return { + id: "fork-continue", + command: "fork-continue", + runId: RUN_ID, + creation: commit()["creation"], + origin: { sourceRunId: SOURCE_RUN_ID, checkpointEventId: "event-work" }, + runRecord: HEAD, + rootImport: IMPORT, + executionId: "execution-2", + ...overrides, + }; +} + +describe("continuing a fork the destination already holds", () => { + it("answers absent when there is nothing here, and needs no source", async () => { + const stub = executor(); + await connected(stub); + + expect(await ask(stub, continuation())).toEqual({ + id: "fork-continue", + outcome: "refused", + refusal: "command:absent", + }); + expect(await on(stub, (owner) => owner.hasWorkflowSchema())).toBe(false); + }); + + it("recovers and begins one replacement, without reading any source", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + await ask(stub, commit()); + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + + const continued = await ask(stub, continuation()); + + expect(continued["outcome"]).toBe("performed"); + const value = Object(Object(continued["value"])["value"]); + expect(value["replay"]).toBe(false); + // The lost executor's execution was closed on the way in and surfaced. + expect(Object(value["recovered"])["stopStatus"]).toBe("interrupted"); + const executions = await on(stub, (owner) => owner.executionRows()); + expect(executions.filter((row) => row["stopped_at"] === null)).toHaveLength(1); + }); + + it("reports a terminal destination as a replay, and leaves it terminal", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + await ask(stub, commit()); + const root = await on(stub, (owner) => owner.currentRootId()); + await ask(stub, { + id: "settle-1", + command: "settle", + completion: { executionId: "execution-1", status: "completed" }, + expectedWorkspaceRootId: root, + }); + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + + const continued = await ask(stub, continuation()); + + expect(continued["outcome"]).toBe("performed"); + const value = Object(Object(continued["value"])["value"]); + expect(value["replay"]).toBe(true); + // The outcome that won is not made mutable again. + expect((await on(stub, (owner) => owner.runRow()))?.["status"]).toBe("completed"); + }); + + it("refuses a continuation whose root import is not the one it retains", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + await ask(stub, commit()); + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + + const refused = await ask( + stub, + continuation({ + rootImport: serializeDurableEvent({ + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { + status: "ok", + value: { kind: "repository", path: "README.md", content: "# elsewhere" }, + }, + }), + }), + ); + + expect(refused["outcome"]).toBe("performed"); + expect(Object(refused["value"])["conflict"]).toEqual(["lineage"]); + // No replacement execution began. + expect(await on(stub, (owner) => owner.executionRows())).toHaveLength(1); + }); + + it("refuses a continuation whose run record is not the one this fork implies", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + await ask(stub, commit()); + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + + const refused = await ask( + stub, + continuation({ + runRecord: serializeDurableEvent( + forkRunRecordEvent({ runId: RUN_ID, base: "other", pinnedCommit: "0".repeat(40) }), + ), + }), + ); + + expect(Object(refused["value"])["conflict"]).toEqual(["lineage"]); + expect(await on(stub, (owner) => owner.executionRows())).toHaveLength(1); + }); +}); + +describe("copying content whose digest is valid in both roles", () => { + it("keeps the manifest and blob watermarks apart", async () => { + // A Workspace whose file is the bytes of one content manifest, and whose + // content manifest for that file names those same bytes as its chunk. The + // digest is then both a manifest identity and a blob identity, with its + // own watermark in each table. + const inner = new TextEncoder().encode(DOFS_MANIFEST); + const shared = MANIFEST_ID; + const outer = JSON.stringify({ + version: 1, + chunks: [{ hash: shared, size: inner.length }], + }); + const outerBytes = new TextEncoder().encode(outer); + const outerId = sha256Hex(outerBytes); + const manifest = JSON.stringify({ + format: 1, + entries: [ + { path: "/", kind: "directory", mode: 493, mtime: 0 }, + { + path: "/MANIFEST.json", + kind: "file", + mode: 420, + mtime: 0, + size: inner.length, + manifest: outerId, + hardlink: null, + }, + ], + }); + const rootId = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${manifest}`); + const root = { + rootId, + formatVersion: 1, + manifest, + manifestHashes: [outerId], + blobHashes: [shared], + }; + const manifestPart = { hash: outerId, size: inner.length, lastSeen: 11 }; + // The same digest, in the other role, with its own watermark. + const blobPart = { hash: shared, size: inner.length, lastSeen: 23 }; + const inheritedPart = { + eventId: "event-work", + record: event("work"), + workspaceRootId: rootId, + }; + + const stub = executor(); + await connected(stub); + let minted = 0; + const id = () => `command-${(minted += 1)}`; + await ask(stub, { + id: id(), + command: "stage", + kind: "manifest", + digest: outerId, + bytes: base64(outerBytes), + }); + await ask(stub, { + id: id(), + command: "stage", + kind: "blob", + digest: shared, + bytes: base64(inner), + }); + for (const part of [ + { section: "roots", body: root }, + { section: "inherited", body: inheritedPart }, + { section: "manifests", body: manifestPart }, + { section: "blobs", body: blobPart }, + ]) { + expect( + ( + await ask(stub, { + id: id(), + command: "fork-stage", + section: part.section, + position: 0, + part: part.body, + }) + )["outcome"], + ).toBe("performed"); + } + + const forked = await ask(stub, { + ...commit(), + origin: { + sourceRunId: SOURCE_RUN_ID, + checkpointEventId: "event-work", + checkpointWorkspaceRootId: rootId, + runRecordWorkspaceRootId: rootId, + rootImportWorkspaceRootId: rootId, + anchor: forkSelectionAnchor({ + checkpointEventId: "event-work", + checkpointWorkspaceRootId: rootId, + runRecordWorkspaceRootId: rootId, + rootImportWorkspaceRootId: rootId, + inherited: [inheritedPart], + roots: [root], + manifests: [{ ...manifestPart, encoded: base64(outerBytes) }], + blobs: [blobPart], + checkouts: [], + }), + }, + }); + + expect(forked["outcome"]).toBe("performed"); + const watermarks = await on(stub, (owner) => owner.contentWatermarks()); + // Two roles, two watermarks, neither overwriting the other. + expect(watermarks.manifests).toEqual([11]); + expect(watermarks.blobs).toEqual([23]); + }); + it("refuses a continuation naming another source or another checkpoint", async () => { + for (const changed of [ + { sourceRunId: "8fktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa" }, + { checkpointEventId: "event-elsewhere" }, + ]) { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + await ask(stub, commit()); + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + + const refused = await ask( + stub, + continuation({ + origin: { + sourceRunId: SOURCE_RUN_ID, + checkpointEventId: "event-work", + ...changed, + }, + }), + ); + + expect(Object(refused["value"])["conflict"]).toEqual(["lineage"]); + // No recovery and no replacement: the first execution is still open. + const executions = await on(stub, (owner) => owner.executionRows()); + expect(executions).toHaveLength(1); + expect(executions[0]?.["stopped_at"]).toBe(null); + } + }); + + it("refuses a head reassociated to another root this store retains", async () => { + for (const head of ["run_record", "root_import"]) { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + await ask(stub, commit()); + // Another valid retained root, and the head now names it. Membership is + // not identity: this is not the association the fork committed. + await on(stub, (owner) => owner.reassociateHead(head)); + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + + const refused = await ask(stub, continuation()); + + expect([head, Object(refused["value"])["conflict"]]).toEqual([head, ["lineage"]]); + expect(await on(stub, (owner) => owner.executionRows())).toHaveLength(1); + } + }); + + it("adopts the execution a lost continuation began, and settles once", async () => { + const stub = executor(); + await connected(stub); + let minted = 0; + await offer(stub, () => `command-${(minted += 1)}`); + await ask(stub, commit()); + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + const first = await ask(stub, continuation()); + expect(first["outcome"]).toBe("performed"); + + // Its answer never arrived and the connection died. + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + const again = await ask(stub, continuation()); + + expect(again).toEqual(first); + // The decision came back only because its execution became this + // acquisition's, so this acquisition can settle it. + const held = await on(stub, (owner) => owner.heldExecutions()); + expect(held.map((row) => row["execution_id"])).toEqual(["execution-2"]); + const root = await on(stub, (owner) => owner.currentRootId()); + const settled = await ask(stub, { + id: "settle-2", + command: "settle", + completion: { executionId: "execution-2", status: "completed" }, + expectedWorkspaceRootId: root, + }); + expect(settled["outcome"]).toBe("performed"); + + // And once settled, the same continuation is history rather than authority. + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + expect((await ask(stub, continuation()))["refusal"]).toBe("command:stale-journal"); + }); + + it("says a transfer it never received is one it needs", async () => { + const stub = executor(); + await connected(stub); + + // The destination is empty and this connection offered nothing: the final + // command's own answer, not a malformed request. + const refused = await ask(stub, commit()); + + expect(refused).toEqual({ + id: "fork-commit", + outcome: "refused", + refusal: "command:needs-transfer", + }); + expect(await on(stub, (owner) => owner.hasWorkflowSchema())).toBe(false); + }); +}); diff --git a/packages/workflow/tests/cloudflare/remote-lifecycle.vitest.ts b/packages/workflow/tests/cloudflare/remote-lifecycle.vitest.ts new file mode 100644 index 000000000..d5ffa30b6 --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-lifecycle.vitest.ts @@ -0,0 +1,515 @@ +/** + * A run's lifecycle on its real owner. + * + * The facts here are the ones only a Durable Object can settle: that a starting + * begin reaches pristine storage and makes the whole run in one transaction, + * that the acquisition which began an execution is the only one that can finish + * it, that the association outlives eviction because it was written down, and + * that a second live executor advances nothing at all. + */ + +import { env, runInDurableObject } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import type { ExecutorObject } from "./support/executor-object.ts"; +import { POLICY, RUN_ID, VALID_CLAIMS } from "./support/executor-object.ts"; +import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; + +let unique = 0; +const NOW = 1_800_000_000; +let keys: TestKeys; + +beforeAll(async () => { + keys = await generateKeys(); +}); + +function executor() { + unique += 1; + return env.EXECUTOR.get(env.EXECUTOR.idFromName(`lifecycle-${unique}-${Math.random()}`)); +} + +function on( + stub: ReturnType, + body: (instance: ExecutorObject) => T, +): Promise { + return runInDurableObject(stub, body); +} + +/** One owner with its keys configured and one admitted executor connection. */ +async function connected(stub: ReturnType): Promise { + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const token = await signToken(keys, { + ...VALID_CLAIMS, + iat: NOW - 10, + nbf: NOW - 10, + exp: NOW + 600, + }); + const admitted = await on(stub, (owner) => owner.admitConnection({ token })); + expect(admitted).toBe("admitted"); +} + +function creation(runId = RUN_ID): Record { + return { + runId, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + }; +} + +/** Send one command as the connection admitted most recently. */ +async function ask( + stub: ReturnType, + command: Record, +): Promise> { + const answered = await on(stub, (owner) => owner.sendLatest(JSON.stringify(command))); + if (answered === null || typeof answered !== "object") { + throw new Error("expected one command answer"); + } + return Object.fromEntries(Object.entries(answered)); +} + +async function started( + stub: ReturnType, + id: string, + executionId: string, +): Promise> { + return await ask(stub, { + id, + command: "begin", + runId: RUN_ID, + action: "start", + creation: creation(), + retrieval: null, + executionId, + }); +} + +describe("a run's lifecycle on its owner", () => { + it("makes the whole run on pristine storage, in one begin", async () => { + const stub = executor(); + await connected(stub); + + const before = await on(stub, (owner) => owner.objectCount()); + const answered = await started(stub, "command-1", "execution-1"); + + expect(answered["outcome"]).toBe("performed"); + // Everything a run owns appeared together: the schema, the marker, the run + // record, its starting Workspace, the first execution and `running`. + expect(before).toBe(0); + const state = await on(stub, (owner) => ({ + run: owner.runRow(), + executions: owner.executionRows(), + schema: owner.hasWorkflowSchema(), + })); + expect(state.schema).toBe(true); + expect(state.run?.["status"]).toBe("running"); + expect(state.executions).toHaveLength(1); + expect(state.executions[0]?.["execution_id"]).toBe("execution-1"); + }); + + it("refuses a second live executor, and that executor advances nothing", async () => { + const stub = executor(); + await connected(stub); + await started(stub, "command-1", "execution-1"); + const before = await on(stub, (owner) => owner.runRow()); + + const token = await signToken(keys, { + ...VALID_CLAIMS, + iat: NOW - 10, + nbf: NOW - 10, + exp: NOW + 600, + }); + const second = await on(stub, (owner) => owner.admitConnection({ token })); + + expect(second).toBe("acquisition:already-running"); + expect(await on(stub, (owner) => owner.runRow())).toEqual(before); + expect(await on(stub, (owner) => owner.holders())).toBe(1); + }); + + it("lets only the acquisition that began an execution settle it", async () => { + const stub = executor(); + await connected(stub); + await started(stub, "command-1", "execution-1"); + const root = await on(stub, (owner) => owner.currentRootId()); + + const foreign = await ask(stub, { + id: "command-2", + command: "settle", + completion: { executionId: "execution-elsewhere", status: "completed" }, + expectedWorkspaceRootId: root, + }); + expect(foreign).toEqual({ + id: "command-2", + outcome: "refused", + refusal: "command:wrong-execution", + }); + + const settled = await ask(stub, { + id: "command-3", + command: "settle", + completion: { executionId: "execution-1", status: "completed" }, + expectedWorkspaceRootId: root, + }); + expect(settled["outcome"]).toBe("performed"); + expect((await on(stub, (owner) => owner.runRow()))?.["status"]).toBe("completed"); + }); + + it("holds the association where an evicted object can still find it", async () => { + const stub = executor(); + await connected(stub); + await started(stub, "command-1", "execution-1"); + + // Nothing in memory survives eviction; what the settlement is checked + // against has to be retained, so this proves it was. + const held = await on(stub, (owner) => owner.heldExecutions()); + expect(held).toHaveLength(1); + expect(held[0]?.["execution_id"]).toBe("execution-1"); + }); + + it("begins one execution per acquisition", async () => { + const stub = executor(); + await connected(stub); + await started(stub, "command-1", "execution-1"); + + const again = await ask(stub, { + id: "command-2", + command: "begin", + runId: RUN_ID, + action: "resume", + creation: null, + retrieval: null, + executionId: "execution-2", + }); + + expect(again).toEqual({ + id: "command-2", + outcome: "refused", + refusal: "command:duplicate-conflict", + }); + expect(await on(stub, (owner) => owner.executionRows())).toHaveLength(1); + }); + + it("answers a repeated command with the decision it already made", async () => { + const stub = executor(); + await connected(stub); + const first = await started(stub, "command-1", "execution-1"); + const again = await started(stub, "command-1", "execution-1"); + + expect(again).toEqual(first); + // One execution, not two: the retry found the decision rather than + // applying it a second time. + expect(await on(stub, (owner) => owner.executionRows())).toHaveLength(1); + }); + + it("refuses a repeat that carries different content", async () => { + const stub = executor(); + await connected(stub); + await started(stub, "command-1", "execution-1"); + + const changed = await ask(stub, { + id: "command-1", + command: "begin", + runId: RUN_ID, + action: "start", + creation: creation(), + retrieval: null, + executionId: "execution-2", + }); + + expect(changed["refusal"]).toBe("command:duplicate-conflict"); + }); + + it("closes what a lost executor left, before the next one begins", async () => { + const stub = executor(); + await connected(stub); + await started(stub, "command-1", "execution-1"); + + // The connection is gone; nothing about time says so, and nothing needs to. + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + const resumed = await ask(stub, { + id: "command-2", + command: "begin", + runId: RUN_ID, + action: "resume", + creation: null, + retrieval: null, + executionId: "execution-2", + }); + + expect(resumed["outcome"]).toBe("performed"); + const executions = await on(stub, (owner) => owner.executionRows()); + expect(executions).toHaveLength(2); + // The stale one was finished as interrupted; the replacement is open. + expect(executions[0]?.["stop_status"]).toBe("interrupted"); + expect(executions[1]?.["stopped_at"]).toBe(null); + }); + + it("cancels a run without beginning anything, and stays cancelled", async () => { + const stub = executor(); + await connected(stub); + await started(stub, "command-1", "execution-1"); + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + + const cancelled = await ask(stub, { id: "command-2", command: "cancel", runId: RUN_ID }); + expect(cancelled["outcome"]).toBe("performed"); + + const state = await on(stub, (owner) => ({ + run: owner.runRow(), + executions: owner.executionRows(), + })); + expect(state.run?.["status"]).toBe("cancelled"); + // Cancelling begins nothing. The one execution is the one the lost + // executor left, closed as interrupted by the recovery that ran first — + // cancelling the run does not rewrite what that execution became. + expect(state.executions).toHaveLength(1); + expect(state.executions[0]?.["stop_status"]).toBe("interrupted"); + }); + + it("writes the retrieval a start carried, with the run it creates", async () => { + const stub = executor(); + await connected(stub); + + const answered = await ask(stub, { + id: "command-1", + command: "begin", + runId: RUN_ID, + action: "start", + creation: creation(), + retrieval: { kind: "git", remote: "origin" }, + executionId: "execution-1", + }); + + expect(answered["outcome"]).toBe("performed"); + const retrieval = await on(stub, (owner) => owner.retrieval()); + // Revision one, written in the transaction that made the run. + expect(retrieval?.["revision"]).toBe(1); + expect(JSON.parse(String(retrieval?.["metadata"]))).toEqual({ + kind: "git", + remote: "origin", + }); + }); + + it("writes no retrieval row when a start carries none", async () => { + const stub = executor(); + await connected(stub); + await started(stub, "command-1", "execution-1"); + + expect(await on(stub, (owner) => owner.retrieval())).toBe(null); + }); + + it("refuses a retrieval nothing is being created for, and one too large", async () => { + const stub = executor(); + await connected(stub); + + // A resume creates nothing, so there is nothing for a retrieval to belong + // to. Carrying one is a request this build does not answer. + const orphan = await ask(stub, { + id: "command-1", + command: "begin", + runId: RUN_ID, + action: "resume", + creation: null, + retrieval: { kind: "git" }, + executionId: "execution-1", + }); + // Refused while the command was still being read, so the answer names no + // command at all. + expect(orphan).toEqual({ id: "", outcome: "refused", refusal: "command:malformed-member" }); + + const huge = await ask(stub, { + id: "command-2", + command: "begin", + runId: RUN_ID, + action: "start", + creation: creation(), + retrieval: { remote: "x".repeat(2 * 1024 * 1024) }, + executionId: "execution-1", + }); + expect(huge["outcome"]).toBe("refused"); + + // Neither one made anything. + expect(await on(stub, (owner) => owner.hasWorkflowSchema())).toBe(false); + }); + + it("leaves an existing run's retrieval alone when it is taken up again", async () => { + const stub = executor(); + await connected(stub); + await ask(stub, { + id: "command-1", + command: "begin", + runId: RUN_ID, + action: "start", + creation: creation(), + retrieval: { kind: "git", remote: "origin" }, + executionId: "execution-1", + }); + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + + await ask(stub, { + id: "command-2", + command: "begin", + runId: RUN_ID, + action: "resume", + creation: null, + retrieval: null, + executionId: "execution-2", + }); + + const retrieval = await on(stub, (owner) => owner.retrieval()); + // Replaceable state, not identity: a resume neither compares it nor + // clears it. + expect(retrieval?.["revision"]).toBe(1); + }); + + it("hands a replacement acquisition the execution a lost answer began", async () => { + const stub = executor(); + await connected(stub); + const first = await started(stub, "command-1", "execution-1"); + expect(first["outcome"]).toBe("performed"); + + // The answer never arrived and the connection died. The replacement asks + // the same question, with the same identity. + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + const again = await started(stub, "command-1", "execution-1"); + + expect(again).toEqual(first); + // One execution, and it is this acquisition's to settle now. + expect(await on(stub, (owner) => owner.executionRows())).toHaveLength(1); + const held = await on(stub, (owner) => owner.heldExecutions()); + expect(held).toHaveLength(1); + expect(held[0]?.["execution_id"]).toBe("execution-1"); + + const root = await on(stub, (owner) => owner.currentRootId()); + const settled = await ask(stub, { + id: "command-3", + command: "settle", + completion: { executionId: "execution-1", status: "completed" }, + expectedWorkspaceRootId: root, + }); + expect(settled["outcome"]).toBe("performed"); + }); + + it("refuses a retained decision its ledger row no longer says began anything", async () => { + const stub = executor(); + await connected(stub); + expect((await started(stub, "command-1", "execution-1"))["outcome"]).toBe("performed"); + + // The retained answer grants execution authority and the row beside it + // names no execution to grant. They cannot both be right. + await on(stub, (owner) => owner.forgetRecordedExecution("command-1")); + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + const retried = await started(stub, "command-1", "execution-1"); + + expect(retried).toEqual({ + id: "command-1", + outcome: "refused", + refusal: "command:stale-journal", + }); + // Nothing was handed back and nothing was taken: no database authority, + // and no hold invented to go with it. + expect(await on(stub, (owner) => owner.heldExecutions())).toEqual([]); + }); + + it("replays a decision that began nothing without granting anything to hold", async () => { + const stub = executor(); + await connected(stub); + await started(stub, "command-1", "execution-1"); + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + expect( + (await ask(stub, { id: "command-2", command: "cancel", runId: RUN_ID }))["outcome"], + ).toBe("performed"); + + // A begin the run's own state refuses. It is an answer the owner + // performed, and it began nothing. + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + const refused = await ask(stub, { + id: "command-3", + command: "begin", + runId: RUN_ID, + action: "resume", + creation: null, + retrieval: null, + executionId: "execution-3", + }); + expect(refused["outcome"]).toBe("performed"); + expect( + await on(stub, (owner) => owner.mutationRow("command-3")?.["execution_id"] ?? null), + ).toBe(null); + + // Asked again after the answer was lost, it comes back unchanged — and + // still without a hold, because there is nothing to hold. + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + const again = await ask(stub, { + id: "command-3", + command: "begin", + runId: RUN_ID, + action: "resume", + creation: null, + retrieval: null, + executionId: "execution-3", + }); + + expect(again).toEqual(refused); + expect(await on(stub, (owner) => owner.heldExecutions())).toEqual([]); + }); + + it("refuses a retained decision the run has already moved past", async () => { + const stub = executor(); + await connected(stub); + // One executor begins and loses its answer. + expect((await started(stub, "command-1", "execution-1"))["outcome"]).toBe("performed"); + + // Another takes the run, recovers that execution and begins its own. + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + const recovered = await ask(stub, { + id: "command-2", + command: "begin", + runId: RUN_ID, + action: "resume", + creation: null, + retrieval: null, + executionId: "execution-2", + }); + expect(recovered["outcome"]).toBe("performed"); + + // The first executor retries its retained command under a later + // acquisition. Its execution is closed, so it is history rather than + // authority. + await on(stub, (owner) => owner.dropConnections()); + await connected(stub); + const retried = await started(stub, "command-1", "execution-1"); + + expect(retried).toEqual({ + id: "command-1", + outcome: "refused", + refusal: "command:stale-journal", + }); + // And it cannot settle what it did not keep. + const root = await on(stub, (owner) => owner.currentRootId()); + const settled = await ask(stub, { + id: "command-4", + command: "settle", + completion: { executionId: "execution-1", status: "completed" }, + expectedWorkspaceRootId: root, + }); + expect(settled["refusal"]).toBe("command:wrong-execution"); + // Exactly one execution is open, and it is the recovering executor's. + const executions = await on(stub, (owner) => owner.executionRows()); + expect(executions.filter((row) => row["stopped_at"] === null)).toHaveLength(1); + }); +}); diff --git a/packages/workflow/tests/cloudflare/remote-owner-routes.vitest.ts b/packages/workflow/tests/cloudflare/remote-owner-routes.vitest.ts new file mode 100644 index 000000000..7122e09d1 --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-owner-routes.vitest.ts @@ -0,0 +1,401 @@ +/** + * Tier WRH — the supported request boundary, on real workerd. + * + * The three planes stop being methods here and become requests. What that adds + * is everything a script cannot claim: the namespace arithmetic that decides + * which object answers, an actual WebSocket upgrade whose handshake a standard + * client would accept, the admission order as a caller experiences it through a + * status, and what a read or a delivery does while an executor is live. + * + * The client is the production one, configured exactly as a runner configures + * it, with its I/O pointed at the stub instead of at a network. So what is + * under test is the pair — this build's client against this build's owner — and + * not either half against a fixture of the other. + */ + +import { env, evictDurableObject, runInDurableObject } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { run, scoped, until, type Operation } from "effection"; +import { remoteOwnerClient } from "../../src/cloudflare/configured.ts"; +import type { + OwnerHttpRequest, + OwnerHttpResponse, + OwnerTransport, + OwnerUpgrade, + OwnerUpgradeRefused, +} from "../../src/cloudflare/configured.ts"; +import type { OwnerSocket, SocketListener } from "../../src/remote/client.ts"; +import { ownerRoute } from "../../src/cloudflare/gateway.ts"; +import { planePath, selectedProtocol } from "../../src/cloudflare/routes.ts"; +import type { ExecutorObject } from "./support/executor-object.ts"; +import { POLICY, RUN_ID, VALID_CLAIMS } from "./support/executor-object.ts"; +import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; + +/** The clock the owner is configured with, so a token's window is exact. */ +const NOW = 1_800_000_000; +const ENDPOINT = "https://owner.invalid/workflow"; + +let keys: TestKeys; + +beforeAll(async () => { + keys = await generateKeys(); +}); + +let unique = 0; + +/** + * One run id, so a test's owner is its own. + * + * The id is what selects the object, so a fresh id is a fresh owner — which is + * also the arithmetic under test. + */ +function runOf(): string { + unique += 1; + return `${RUN_ID.slice(0, 40)}${unique}${Math.floor(Math.random() * 1_000_000)}`; +} + +/** The object the gateway would reach for this run. */ +function stubFor(runId: string) { + return env.EXECUTOR.get(env.EXECUTOR.idFromName(runId)); +} + +function on( + stub: ReturnType, + body: (instance: ExecutorObject) => T, +): Promise> { + return runInDurableObject(stub, body) as Promise>; +} + +/** A token this owner admits. */ +async function token(): Promise { + return await signToken(keys, { ...VALID_CLAIMS, iat: NOW - 10, nbf: NOW - 10, exp: NOW + 600 }); +} + +/** + * The namespace, as the gateway addresses it. + * + * `idFromName` and `get` and nothing else: the gateway is handed exactly what + * it needs to route, so a test cannot accidentally prove that it reached for + * more. + */ +const namespace = { + idFromName: (name: string) => env.EXECUTOR.idFromName(name), + get: (id: DurableObjectId) => env.EXECUTOR.get(id), +}; + +/** + * Every request one client made, and the transport that made it. + * + * The transport routes through the gateway, exactly as a Worker would: the + * request is built from the client's own URL and headers, and nothing about it + * is adjusted on the way. + */ +function transportTo( + routed: { readonly urls: string[] }, + route: (request: Request) => Promise, +): OwnerTransport { + return { + *request(request: OwnerHttpRequest): Operation { + routed.urls.push(request.url); + const response = yield* until( + route( + new Request(request.url, { + method: "POST", + headers: request.headers, + body: request.body, + }), + ), + ); + return { status: response.status, body: yield* until(response.text()) }; + }, + + *connect(upgrade: OwnerUpgrade): Operation { + routed.urls.push(upgrade.url); + const response = yield* until( + route( + new Request(upgrade.url, { + headers: { + upgrade: "websocket", + "sec-websocket-protocol": upgrade.protocols.join(", "), + }, + }), + ), + ); + const socket = response.webSocket; + if (socket === null) { + return { refusal: yield* until(response.text()) }; + } + // Selected explicitly by the owner, which is what a standard client + // requires when it offered a subprotocol at all. + expect(response.headers.get("sec-websocket-protocol")).toBe(selectedProtocol()); + socket.accept(); + return ownerSocket(socket); + }, + }; +} + +/** + * The runtime's socket, as the client's contract sees it. + * + * A `WebSocket` here carries the runtime's own event types, and the client + * needs only the data. The same adapter a runner's host supplies. + */ +function ownerSocket(socket: WebSocket): OwnerSocket { + const listeners = new Map(); + return { + send: (data) => socket.send(data), + close: () => socket.close(), + addEventListener(type, listener) { + const forward: EventListener = (event) => { + const data: unknown = Reflect.get(event, "data"); + listener(typeof data === "string" ? { data } : {}); + }; + listeners.set(listener, forward); + socket.addEventListener(type, forward); + }, + removeEventListener(type, listener) { + const found = listeners.get(listener); + if (found !== undefined) { + socket.removeEventListener(type, found); + } + }, + }; +} + +/** One configured client for one owner, with the gateway in front of it. */ +async function client(runId: string) { + await on(stubFor(runId), (owner) => + owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW), + ); + const minted = await token(); + const routed = { urls: [] as string[] }; + return { + routed, + client: remoteOwnerClient({ + runId, + endpoint: ENDPOINT, + release: POLICY.release, + // deno-lint-ignore require-yield + *token(): Operation { + return minted; + }, + transport: transportTo(routed, (request) => ownerRoute(namespace, request)), + }), + }; +} + +describe("the owner's request boundary", () => { + it("routes one run id to one object, arithmetically", async () => { + const runId = runOf(); + // The gateway is given the namespace and the request; which object answers + // is `idFromName` and nothing else, so the same id answers twice and a + // different id answers from somewhere else. + const first = await ownerRoute( + namespace, + new Request(`${ENDPOINT}${planePath(runId, "read")}`), + ); + expect(first.status).toBe(200); + const answered = await first.json(); + // Nothing is stored under that id, and the owner says so rather than + // creating anything. + // No release travelled on this bare request, and that is the first thing + // the owner asks about — before the token, and before the run. + expect(answered).toEqual({ outcome: "refused", refusal: "release:release-absent" }); + + // A path this build does not write reaches no object at all. + expect((await ownerRoute(namespace, new Request(`${ENDPOINT}/nope`))).status).toBe(404); + // Neither does an id that cannot address one. + expect( + (await ownerRoute(namespace, new Request(`${ENDPOINT}/runs/${"x".repeat(600)}/read`))).status, + ).toBe(400); + }); + + it("upgrades the executor plane, and takes the acquisition last", async () => { + const runId = runOf(); + const built = await client(runId); + const outcome = await run(function* () { + const admitted = yield* built.client.admit(runId); + if (!admitted.ok) { + return `failed:${admitted.error.message}`; + } + if (admitted.value === "already-running") { + return "already-running"; + } + // The connection is the acquisition, and the owner sees exactly one. + expect(yield* until(on(stubFor(runId), (owner) => owner.holders()))).toBe(1); + return "admitted"; + }); + expect(outcome).toBe("admitted"); + expect(built.routed.urls).toEqual([`${ENDPOINT}/runs/${runId}/executor`]); + // The scope that admitted it has ended, so the socket has gone and the run + // has no executor. + expect(await on(stubFor(runId), (owner) => owner.holders())).toBe(0); + }); + + it("refuses an upgrade in the settled order, and takes nothing when it does", async () => { + const runId = runOf(); + await on(stubFor(runId), (owner) => + owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW), + ); + const routed = { urls: [] as string[] }; + const transport = transportTo(routed, (request) => ownerRoute(namespace, request)); + + /** One upgrade attempt with exactly these admission values. */ + async function attempt(release: string, minted: string): Promise { + return await run(function* () { + const configured = remoteOwnerClient({ + runId, + endpoint: ENDPOINT, + release, + // deno-lint-ignore require-yield + *token(): Operation { + return minted; + }, + transport, + }); + const admitted = yield* configured.admit(runId); + return admitted.ok + ? admitted.value === "already-running" + ? "already-running" + : "admitted" + : admitted.error.message; + }); + } + + // A build this owner will not talk to is refused before the token is read, + // which is why a deliberately unusable token refuses the connection all the + // same — and the refusal a caller sees names neither the build nor the + // token, because a private category is not a public contract. + expect(await attempt("another-build", "not a token")).toContain("refused the operation"); + expect(await on(stubFor(runId), (owner) => owner.holders())).toBe(0); + // An authenticated build with an unusable token is refused before the run + // is named, and still takes nothing. + expect(await attempt(POLICY.release, "not a token")).toContain("refused the operation"); + expect(await on(stubFor(runId), (owner) => owner.holders())).toBe(0); + // And the one that passes both. + expect(await attempt(POLICY.release, await token())).toBe("admitted"); + expect(await on(stubFor(runId), (owner) => owner.holders())).toBe(0); + }); + + it("keeps the upgraded socket authoritative across a real eviction", async () => { + const runId = runOf(); + const built = await client(runId); + const outcome = await run(function* (): Operation> { + return yield* scoped(function* () { + const admitted = yield* built.client.admit(runId); + if (!admitted.ok || admitted.value === "already-running") { + return { admitted: false }; + } + // The acquisition is registered on the socket's attachment, which is + // what an evicted object reads back — nothing about it is in memory. + const before = yield* until(on(stubFor(runId), (owner) => owner.holders())); + // The object is really evicted, with this client's socket still open. + // Nothing about the acquisition is in the instance that comes back: it + // is the socket's serialized attachment, which is what a hibernated + // object restores from. + yield* until(evictDurableObject(stubFor(runId))); + const evicted = yield* until(on(stubFor(runId), (owner) => owner.holders())); + // The same client, over the same link, after that eviction. + const opened = yield* admitted.value.link.open(runId, null); + return { + admitted: true, + before, + evicted, + opened: opened.ok, + after: yield* until(on(stubFor(runId), (owner) => owner.holders())), + }; + }); + }); + expect(outcome).toEqual({ + admitted: true, + before: 1, + evicted: 1, + // The run is not stored, so opening says so — over the same acquisition, + // which the owner still recognizes after the eviction. + opened: false, + after: 1, + }); + // And the acquisition ends with the scope that held it, not with the object. + expect(await on(stubFor(runId), (owner) => owner.holders())).toBe(0); + }); + + it("lets a closed socket authorize nothing, and admits the next executor", async () => { + const runId = runOf(); + const built = await client(runId); + const closed = await run(function* (): Operation> { + const first = yield* scoped(function* () { + const admitted = yield* built.client.admit(runId); + if (!admitted.ok || admitted.value === "already-running") { + return { admitted: false }; + } + // Ending the connection is how a runner stops being the executor. + yield* admitted.value.close(); + return { + admitted: true, + // Nothing may be asked of the run over a connection that is gone. + refused: !(yield* admitted.value.link.open(runId, null)).ok, + holders: yield* until(on(stubFor(runId), (owner) => owner.holders())), + }; + }); + // And the next acquisition is admitted, because the first one released + // ownership by closing rather than by any lease expiring. + const second = yield* scoped(function* () { + const admitted = yield* built.client.admit(runId); + return admitted.ok && admitted.value !== "already-running"; + }); + return { ...first, second }; + }); + expect(closed).toEqual({ admitted: true, refused: true, holders: 0, second: true }); + }); + + it("answers a read while an executor is live, and takes no acquisition", async () => { + const runId = runOf(); + const built = await client(runId); + const outcome = await run(function* () { + const admitted = yield* built.client.admit(runId); + if (!admitted.ok || admitted.value === "already-running") { + return "not-admitted"; + } + expect(yield* until(on(stubFor(runId), (owner) => owner.holders()))).toBe(1); + // The read plane, while that acquisition is held. Nothing is stored, so + // the owner answers that the run is absent — over an ordinary request, + // with no second acquisition and no effect on the first. + const plane = yield* built.client.reads(runId); + if (!plane.ok) { + return `no-plane:${plane.error.message}`; + } + const inspected = yield* plane.value.inspect(); + // The acquisition is untouched by the read, whichever way the owner + // answered it. + expect(yield* until(on(stubFor(runId), (owner) => owner.holders()))).toBe(1); + return inspected.ok ? "answered" : "refused"; + }); + // Nothing is stored, so the owner refuses rather than inventing a run. + expect(outcome).toBe("refused"); + expect(built.routed.urls).toEqual([ + `${ENDPOINT}/runs/${runId}/executor`, + `${ENDPOINT}/runs/${runId}/read`, + ]); + expect(await on(stubFor(runId), (owner) => owner.holders())).toBe(0); + }); + + it("retains a delivered value over its own request, taking no acquisition", async () => { + const runId = runOf(); + const built = await client(runId); + const outcome = await run(function* () { + // Nothing is stored, so this run is not waiting for anything — which is + // the owner's answer rather than a failure of the plane, and it is + // reached without an acquisition existing at any point. + const retained = yield* built.client.delivery.retain({ + runId, + suspensionId: "suspension-1", + answer: "answered", + secretDetection: false, + }); + return retained.ok ? "retained" : retained.error.name; + }); + expect(outcome).not.toBe("retained"); + expect(built.routed.urls).toEqual([`${ENDPOINT}/runs/${runId}/delivery`]); + expect(await on(stubFor(runId), (owner) => owner.holders())).toBe(0); + }); +}); diff --git a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts new file mode 100644 index 000000000..88f88f02c --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts @@ -0,0 +1,788 @@ +/** + * The owner's half of the private protocol, on real workerd. + * + * Almost nothing here would be worth proving against a model. Hibernation is a + * property of the runtime: the object is evicted, its fields are gone, and what + * comes back is whatever the storage and the live sockets say. Acquisition + * replacement is a property of the runtime's socket list. Transaction + * atomicity, `WITHOUT ROWID` constraints and blob round-trips are properties of + * the Durable Object's SQLite. A map standing in for any of those would prove + * that the map behaves, which is not the claim. + * + * So these run against a real namespace, real storage, real Hibernation + * WebSockets and a real `evictDurableObject()`, and the assertions are about + * what survived, what was refused, and what was left untouched. + */ + +import { env, evictDurableObject, runInDurableObject } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { MAX_COMMANDS, MAX_CONTENT_BYTES } from "../../src/cloudflare/commands.ts"; +import { encodeBase64 } from "../../src/cloudflare/encoding.ts"; +import { sha256Hex } from "../../src/workspace/sha256.ts"; +import type { ExecutorObject } from "./support/executor-object.ts"; +import { + BLOB_ID, + DOFS_MANIFEST, + FILE_BYTES, + MANIFEST_ID, + POLICY, + ROOT_ID, + ROOT_MANIFEST, + RUN_ID, + VALID_CLAIMS, +} from "./support/executor-object.ts"; +import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; +import { run } from "effection"; +import { + type OwnerSocket, + type SocketListener, + useOwnerConnection, +} from "../../src/remote/client.ts"; +import { cloudflareReadLink, cloudflareRunLink } from "../../src/cloudflare/client.ts"; + +let unique = 0; +const NEW_START = "2026-02-02T00:00:00.000Z"; +const NOW = 1_800_000_000; +let keys: TestKeys; + +beforeAll(async () => { + keys = await generateKeys(); +}); + +function executor() { + unique += 1; + return env.EXECUTOR.get(env.EXECUTOR.idFromName(`remote-${unique}-${Math.random()}`)); +} + +function on( + stub: ReturnType, + body: (instance: ExecutorObject) => T, +): Promise { + return runInDurableObject(stub, body); +} + +async function admission(stub: ReturnType): Promise { + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const token = await signToken(keys, { + ...VALID_CLAIMS, + iat: NOW - 10, + nbf: NOW - 10, + exp: NOW + 600, + }); + return await on(stub, (owner) => owner.admitConnection({ token, release: POLICY.release })); +} + +async function admit(stub: ReturnType): Promise { + expect(await admission(stub)).toBe("admitted"); +} + +async function connect(stub: ReturnType): Promise { + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const token = await signToken(keys, { + ...VALID_CLAIMS, + iat: NOW - 10, + nbf: NOW - 10, + exp: NOW + 600, + }); + const response = await stub.fetch("https://owner.invalid/executor", { + headers: { + authorization: `Bearer ${token}`, + upgrade: "websocket", + "x-release": POLICY.release, + "x-run-id": RUN_ID, + }, + }); + const socket = response.webSocket; + if (socket === null) { + throw new Error(`expected an executor WebSocket, received ${response.status}`); + } + socket.accept(); + return socket; +} + +function ask( + socket: WebSocket, + id: string, + command: Record, +): Promise> { + return askFrame(socket, JSON.stringify({ id, ...command })); +} + +function askFrame( + socket: WebSocket, + message: string | ArrayBuffer, +): Promise> { + return new Promise((resolve, reject) => { + const receive = (event: MessageEvent) => { + socket.removeEventListener("message", receive); + if (typeof event.data !== "string") { + reject(new Error("expected a text answer")); + return; + } + resolve(record(JSON.parse(event.data))); + }; + socket.addEventListener("message", receive); + socket.send(message); + }); +} + +/** + * The platform socket, as the runner's client needs it. + * + * A host binds its own socket to this interface; the runtime's event types are + * wider than the four members the client uses, so the binding is written out + * rather than asserted. + */ +function ownerSocket(socket: WebSocket, beforeSend?: (raw: string) => Promise | undefined) { + const listeners = new Map(); + const bound: OwnerSocket = { + send(data) { + // A frame may be held back before it reaches the owner, which is how a + // test puts a write between two pages of one read without reaching + // inside the client. + const waiting = beforeSend?.(data); + if (waiting === undefined) { + socket.send(data); + return; + } + void waiting.then(() => socket.send(data)); + }, + close: () => socket.close(), + addEventListener(type, listener) { + const forward: EventListener = (event) => listener(event as { data?: unknown }); + listeners.set(listener, forward); + socket.addEventListener(type, forward); + }, + removeEventListener(type, listener) { + const bound = listeners.get(listener); + const found = listeners.get(listener); + if (found !== undefined) { + socket.removeEventListener(type, found); + } + }, + }; + return bound; +} + +function record(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("expected an object answer"); + } + return Object.fromEntries(Object.entries(value)); +} + +function send( + stub: ReturnType, + id: string, + command: Record, +): Promise> { + return on(stub, (owner) => record(owner.send(1, JSON.stringify({ id, ...command })))); +} + +describe("the remote owner protocol", () => { + it("answers a binary frame once and closes the protocol", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + expect(await askFrame(socket, new Uint8Array([1]).buffer)).toEqual({ + id: "", + outcome: "refused", + refusal: "command:malformed-member", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(socket.readyState).not.toBe(WebSocket.OPEN); + }); + + it("refuses pristine, foreign, unsupported, damaged, missing, and wrong-run storage", async () => { + const cases: readonly [string, (owner: ExecutorObject) => void, string][] = [ + ["pristine", () => undefined, "storage:foreign"], + ["foreign", (owner) => owner.makeForeign(), "storage:foreign"], + [ + "unsupported", + (owner) => { + owner.initialize(); + owner.rewriteMarker(0x584d4431, 2); + }, + "storage:unsupported-version-v2", + ], + [ + "damaged", + (owner) => { + owner.initialize(); + owner.dropTable("workflow_suspension_answers"); + }, + "storage:corrupt", + ], + [ + "missing", + (owner) => { + owner.initialize(); + owner.removeWorkspaceState(); + }, + "storage:corrupt", + ], + [ + "wrong-run", + (owner) => { + owner.initialize(); + owner.rewriteRunId("somebody-else"); + }, + "storage:corrupt", + ], + ]; + for (const [name, arrange, refusal] of cases) { + const stub = executor(); + await on(stub, arrange); + const admitted = await admission(stub); + const answer = + admitted === "admitted" ? await send(stub, name, { command: "frontier" }) : admitted; + expect([name, answer]).toEqual([ + name, + typeof answer === "string" ? refusal : { id: name, outcome: "refused", refusal }, + ]); + } + }); + + it("names a refusal category and repeats nothing it was given or holds", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await on(stub, (owner) => owner.rewriteRunId("a-retained-secret-run")); + await admit(stub); + const damaged = await send(stub, "id-carrying-a-secret", { command: "frontier" }); + // The refusal names a category. It does not repeat the retained run + // identity it disagreed with, and it does not repeat the request beyond the + // correlation the runner needs to match its own question. + expect(damaged).toEqual({ + id: "id-carrying-a-secret", + outcome: "refused", + refusal: "storage:corrupt", + }); + const printed = JSON.stringify(damaged); + for (const retained of ["a-retained-secret-run", RUN_ID, ROOT_MANIFEST, "workflow_run"]) { + expect(printed).not.toContain(retained); + } + + const rejected = await send(stub, "unknown", { + command: "root", + workspaceRootId: "f".repeat(64), + somethingElse: "a value the request supplied", + }); + // Not even the correlation survives a request that never parsed: an id is + // echoed once the command has been read, and this one never was. + expect(rejected).toEqual({ id: "", outcome: "refused", refusal: "command:unknown-member" }); + expect(JSON.stringify(rejected)).not.toContain("a value the request supplied"); + }); + + it("anchors and reconstructs a journal larger than one page", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + for (let index = 0; index < 129; index += 1) { + await on(stub, (owner) => owner.appendJournal(`event-${index}`, `event ${index}`)); + } + // A real accepted connection: this reads across several object round trips + // and a pair socket does not survive the object being reset between them. + const socket = await connect(stub); + const frontier = record((await ask(socket, "frontier", { command: "frontier" }))["value"]); + expect(frontier["workspaceRootId"]).toBe(ROOT_ID); + expect(frontier["journalEventId"]).toBe("event-128"); + expect(record(frontier["record"])["runId"]).toBe(RUN_ID); + + await on(stub, (owner) => owner.appendJournal("event-later", "later")); + const first = record( + ( + await ask(socket, "journal-1", { + command: "journal", + anchorEventId: "event-128", + afterEventId: null, + }) + )["value"], + ); + expect(Array.isArray(first["entries"]) && first["entries"]).toHaveLength(128); + expect(first["done"]).toBe(false); + const second = record( + ( + await ask(socket, "journal-2", { + command: "journal", + anchorEventId: "event-128", + afterEventId: "event-127", + }) + )["value"], + ); + expect(second["entries"]).toEqual([ + expect.objectContaining({ eventId: "event-128", previousEventId: "event-127" }), + ]); + expect(second["done"]).toBe(true); + }); + + it("reads a whole retained history back through the runner's own client", async () => { + // The one test where the owner's answers and the runner's parser meet. Each + // half was already proven against a hand-built counterpart, which is + // exactly why a disagreement between them could survive: the owner may + // answer a shape no runner accepts and both halves still pass. This + // composes the real pages through the real client. + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + for (let index = 0; index < 129; index += 1) { + const id = `execution-${String(index).padStart(3, "0")}`; + await on(stub, (owner) => owner.beginExecution(id, `2026-01-01T00:00:0${index % 10}.000Z`)); + } + // Two stopped rows, so the optional members cross as well as the required + // ones. A record that only ever travelled in its shortest form would not + // prove the parser accepts the shape the owner actually builds. + await on(stub, (owner) => + owner.stopExecution("execution-001", "2026-01-01T01:00:00.000Z", "completed"), + ); + await on(stub, (owner) => + owner.stopExecution("execution-002", "2026-01-01T02:00:00.000Z", "failed", "it-stopped"), + ); + + const socket = await connect(stub); + let identifier = 0; + // 129 rows page at 128, so the read takes two requests. The later row is + // written between them: after the first page fixed the anchor, and before + // the owner is asked for the second. That is the moment the anchor exists + // to survive, and asserting it any later would prove nothing about paging. + let requests = 0; + let inserted = false; + const wire = ownerSocket(socket, (raw) => { + if (!raw.includes('"executions"')) { + return undefined; + } + requests += 1; + if (requests !== 2) { + return undefined; + } + inserted = true; + return on(stub, (owner) => owner.beginExecution("execution-later", NEW_START)); + }); + const outcome = await run(function* () { + const connection = yield* useOwnerConnection(wire); + const ids = () => `read-${(identifier += 1)}`; + const link = cloudflareRunLink(connection, ids, RUN_ID); + const first = yield* link.readExecutions(); + return { first, second: yield* link.readExecutions() }; + }); + // The write really did land between the two page requests. + expect([requests >= 2, inserted]).toEqual([true, true]); + + if (!outcome.first.ok) { + throw outcome.first.error; + } + const records = outcome.first.value; + expect(records).toHaveLength(129); + expect(records.map((held) => held.executionId)).toEqual( + Array.from({ length: 129 }, (_, index) => `execution-${String(index).padStart(3, "0")}`), + ); + expect(records[0]).toEqual({ + executionId: "execution-000", + startedAt: "2026-01-01T00:00:00.000Z", + }); + expect(records[1]).toEqual({ + executionId: "execution-001", + startedAt: "2026-01-01T00:00:01.000Z", + stoppedAt: "2026-01-01T01:00:00.000Z", + stopStatus: "completed", + }); + expect(records[2]).toEqual({ + executionId: "execution-002", + startedAt: "2026-01-01T00:00:02.000Z", + stoppedAt: "2026-01-01T02:00:00.000Z", + stopStatus: "failed", + stopReason: { kind: "host", code: "it-stopped" }, + }); + // Nothing physical crossed: the runner never sees a column name. + expect(Object.keys(records[0])).toEqual(["executionId", "startedAt"]); + + if (!outcome.second.ok) { + throw outcome.second.error; + } + // The later row is outside the first anchored snapshot and inside the next. + expect(outcome.second.value).toHaveLength(130); + expect(outcome.second.value[129]?.executionId).toBe("execution-later"); + }); + + it("ends a page on the byte bound, and refuses a record that can never fit", async () => { + // The entry bound is 128 rows; this one is reached by bytes first. Both + // ends measure the same serialized `rows` array, so what the owner decides + // fits is exactly what the runner accepts — and the whole history still + // arrives, in order, across however many pages that takes. + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const padding = "p".repeat(64 * 1024); + for (let index = 0; index < 20; index += 1) { + const id = `${String(index).padStart(2, "0")}-${padding}`; + await on(stub, (owner) => owner.beginExecution(id, "2026-01-01T00:00:00.000Z")); + } + const socket = await connect(stub); + let identifier = 0; + let requests = 0; + const wire = ownerSocket(socket, (raw) => { + if (raw.includes('"executions"')) { + requests += 1; + } + return undefined; + }); + const outcome = await run(function* () { + const connection = yield* useOwnerConnection(wire); + const ids = () => `page-${(identifier += 1)}`; + return yield* cloudflareRunLink(connection, ids, RUN_ID).readExecutions(); + }); + if (!outcome.ok) { + throw outcome.error; + } + expect(outcome.value).toHaveLength(20); + expect(outcome.value.map((held) => held.executionId.slice(0, 2))).toEqual( + Array.from({ length: 20 }, (_, index) => String(index).padStart(2, "0")), + ); + // Well under 128 entries a page, so bytes ended these pages, not the count. + expect(requests).toBeGreaterThan(1); + + // One record larger than a whole page. There is no page that could carry + // it, so the owner refuses rather than answering with something the runner + // is required to reject. + const single = executor(); + await on(single, (owner) => owner.initialize()); + const huge = "h".repeat(600 * 1024); + await on(single, (owner) => owner.beginExecution(huge, "2026-01-01T00:00:00.000Z")); + const alone = await connect(single); + let count = 0; + const refused = await run(function* () { + const connection = yield* useOwnerConnection(ownerSocket(alone)); + const ids = () => `huge-${(count += 1)}`; + return yield* cloudflareRunLink(connection, ids, RUN_ID).readExecutions(); + }); + expect(refused.ok).toBe(false); + // Provider-neutral, with no private refusal spelling in it. + expect(String(refused.ok === false && refused.error)).not.toContain("command:"); + }); + + it("returns only content referenced by one validated root", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + // A real accepted connection: a `WebSocketPair` made inside the object does + // not survive the object being reset between calls, and this test reads + // across several of them. + const socket = await connect(stub); + expect(await ask(socket, "root", { command: "root", workspaceRootId: ROOT_ID })).toEqual({ + id: "root", + outcome: "performed", + value: { workspaceRootId: ROOT_ID, manifest: ROOT_MANIFEST }, + }); + expect( + await ask(socket, "manifest", { + command: "content", + workspaceRootId: ROOT_ID, + kind: "manifest", + digest: MANIFEST_ID, + sourceManifest: null, + }), + ).toEqual({ + id: "manifest", + outcome: "performed", + value: { + kind: "manifest", + digest: MANIFEST_ID, + size: new TextEncoder().encode(DOFS_MANIFEST).length, + bytes: encodeBase64(new TextEncoder().encode(DOFS_MANIFEST)), + }, + }); + expect( + await ask(socket, "blob", { + command: "content", + workspaceRootId: ROOT_ID, + kind: "blob", + digest: BLOB_ID, + sourceManifest: MANIFEST_ID, + }), + ).toMatchObject({ outcome: "performed", value: { digest: BLOB_ID, size: FILE_BYTES.length } }); + + const orphan = await on(stub, (owner) => + owner.addUnreferencedBlob(new TextEncoder().encode("orphan")), + ); + expect( + await ask(socket, "orphan", { + command: "content", + workspaceRootId: ROOT_ID, + kind: "blob", + digest: orphan, + sourceManifest: MANIFEST_ID, + }), + ).toEqual({ id: "orphan", outcome: "refused", refusal: "storage:corrupt" }); + }); + + it("refuses a root whose content graph is incomplete, before returning one", async () => { + // A root is a starting frontier: the runner materializes it and proposes + // against it. Discovering a piece is missing when the runner asks for it + // would mean the failure arrives after the run has been told where it + // stands, so the whole graph is proved before either read answers. + const damage: Record void> = { + "a missing manifest row": (owner) => owner.removeManifestRow(), + "a manifest payload that is not its identity": (owner) => owner.damageManifestPayload(), + "a manifest size that disagrees with its chunks": (owner) => owner.damageManifestSize(), + "a missing blob reached through a manifest": (owner) => owner.removeBlobRow(), + "blob bytes that are not their identity": (owner) => owner.damageRetainedBlob(), + "a blob size that disagrees with its bytes": (owner) => owner.damageBlobSize(), + "a blob reference the manifests still name": (owner) => owner.removeBlobReference(), + "a blob reference no manifest names": (owner) => + owner.addExtraBlobReference(new TextEncoder().encode("unaccounted for")), + }; + + for (const [description, arrange] of Object.entries(damage)) { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await on(stub, arrange); + await admit(stub); + for (const command of [ + { command: "frontier" }, + { command: "root", workspaceRootId: ROOT_ID }, + ]) { + const answer = await send(stub, `${String(command.command)}`, command); + expect([description, command.command, answer]).toEqual([ + description, + command.command, + { id: command.command, outcome: "refused", refusal: "storage:corrupt" }, + ]); + } + } + }); + + it("says only that storage is damaged, and never what it read or was asked", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const unaccounted = await on(stub, (owner) => + owner.addExtraBlobReference(new TextEncoder().encode("unaccounted for")), + ); + await admit(stub); + const answer = await send(stub, "root", { command: "root", workspaceRootId: ROOT_ID }); + expect(answer).toEqual({ id: "root", outcome: "refused", refusal: "storage:corrupt" }); + const printed = JSON.stringify(answer); + for (const withheld of [ + unaccounted, + BLOB_ID, + MANIFEST_ID, + ROOT_ID, + ROOT_MANIFEST, + "workspace_root_blob_refs", + "vfs_manifests", + "/README.md", + ]) { + expect(printed).not.toContain(withheld); + } + }); + + it("refuses retained bytes whose identity or recorded size is damaged", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await on(stub, (owner) => owner.damageRetainedBlob()); + await admit(stub); + expect( + await send(stub, "blob", { + command: "content", + workspaceRootId: ROOT_ID, + kind: "blob", + digest: BLOB_ID, + sourceManifest: MANIFEST_ID, + }), + ).toEqual({ + id: "blob", + outcome: "refused", + refusal: "storage:corrupt", + }); + }); + + it("replays compatible commands and refuses conflicting reuse", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await on(stub, (owner) => owner.appendJournal("before", "before")); + const socket = await connect(stub); + const first = await ask(socket, "same", { command: "frontier" }); + await on(stub, (owner) => owner.appendJournal("after", "after")); + // The same id and the same canonical request returns the anchored frontier + // it already decided, not the later one. + expect(await ask(socket, "same", { command: "frontier" })).toEqual(first); + expect(await ask(socket, "same", { command: "root", workspaceRootId: ROOT_ID })).toEqual({ + id: "same", + outcome: "refused", + refusal: "command:duplicate-conflict", + }); + }); + + it("keeps staged bytes private, durable across eviction, and scoped to one acquisition", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + const bytes = new TextEncoder().encode("proposed content"); + const digest = sha256Hex(bytes); + const command = { command: "stage", kind: "blob", digest, bytes: encodeBase64(bytes) }; + const first = await ask(socket, "stage", command); + expect(first).toMatchObject({ outcome: "performed", value: { digest, size: bytes.length } }); + expect(await on(stub, (owner) => owner.scratch())).toEqual({ commands: 1, staged: 1 }); + + await evictDurableObject(stub); + expect(await ask(socket, "stage", command)).toEqual(first); + expect(await on(stub, (owner) => owner.scratch())).toEqual({ commands: 1, staged: 1 }); + expect( + await ask(socket, "read-stage", { + command: "content", + workspaceRootId: ROOT_ID, + kind: "blob", + digest, + sourceManifest: MANIFEST_ID, + }), + ).toEqual({ id: "read-stage", outcome: "refused", refusal: "storage:corrupt" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + await on(stub, (owner) => + owner.admitConnection({ token: "not-a-token", release: POLICY.release }), + ), + ).toBe("token:token-malformed"); + expect(await on(stub, (owner) => owner.scratch())).toEqual({ commands: 1, staged: 1 }); + const before = await on(stub, (owner) => owner.authoritative()); + const replaced = await on(stub, (owner) => owner.acquisitionId()); + // A real accepted connection for the replacement: the rest of this test + // reads across several object round trips. + const successor = await connect(stub); + // A second acquisition, and the first one's scratch is gone rather than + // inherited: it cannot be retried, adopted or read. + expect(await on(stub, (owner) => owner.acquisitionId())).not.toBe(replaced); + expect(await on(stub, (owner) => owner.scratch())).toEqual({ commands: 0, staged: 0 }); + expect(await on(stub, (owner) => owner.authoritative())).toBe(before); + // The predecessor's own command id is free again, and staging the same + // bytes writes a new row rather than finding the abandoned one. Nothing was + // inherited; it was discarded and done afresh. + expect( + await ask(successor, "stage", { + command: "stage", + kind: "blob", + digest, + bytes: encodeBase64(bytes), + }), + ).toMatchObject({ outcome: "performed", value: { digest } }); + expect(await on(stub, (owner) => owner.scratch())).toEqual({ commands: 1, staged: 1 }); + expect(await ask(successor, "frontier-new", { command: "frontier" })).toMatchObject({ + outcome: "performed", + value: { workspaceRootId: ROOT_ID }, + }); + expect(await on(stub, (owner) => owner.authoritative())).toBe(before); + }); + + it("grants a copied attachment or a foreign socket no read at all", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await admit(stub); + const request = JSON.stringify({ id: "borrowed", command: "frontier" }); + expect(await on(stub, (owner) => record(owner.sendWithCopiedAttachment(request)))).toEqual({ + id: "", + outcome: "refused", + refusal: "acquisition:foreign-connection", + }); + expect(await on(stub, (owner) => record(owner.sendAsStranger(request)))).toEqual({ + id: "", + outcome: "refused", + refusal: "acquisition:foreign-connection", + }); + expect(await on(stub, (owner) => owner.scratch())).toEqual({ commands: 0, staged: 0 }); + }); + + it("leaves no staged row when decoding or digest validation fails", async () => { + const bytes = new TextEncoder().encode("piece"); + const oversized = new Uint8Array(MAX_CONTENT_BYTES + 1); + // Each of these is a broken channel rather than an answer, so each closes + // the connection it arrived on — which is why every case gets its own. + const cases: Record, string]> = { + "bad base64": [ + { kind: "blob", digest: sha256Hex(bytes), bytes: "not base64" }, + "command:malformed-member", + ], + "a digest that is not the bytes": [ + { kind: "blob", digest: "0".repeat(64), bytes: encodeBase64(bytes) }, + "command:malformed-member", + ], + "a piece past the bound": [ + { kind: "blob", digest: sha256Hex(oversized), bytes: encodeBase64(oversized) }, + "command:too-large", + ], + }; + for (const [description, [request, refusal]] of Object.entries(cases)) { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + const answer = await ask(socket, "staged", { command: "stage", ...request }); + expect([description, answer["refusal"]]).toEqual([description, refusal]); + expect([description, await on(stub, (owner) => owner.scratch())]).toEqual([ + description, + { commands: 0, staged: 0 }, + ]); + } + }); + + it("refuses aggregate staging overflow without a partial piece or decision", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + for (let index = 0; index < 2; index += 1) { + const bytes = new Uint8Array(MAX_CONTENT_BYTES); + bytes[0] = index; + expect( + await ask(socket, `piece-${index}`, { + command: "stage", + kind: "blob", + digest: sha256Hex(bytes), + bytes: encodeBase64(bytes), + }), + ).toMatchObject({ outcome: "performed", value: { size: MAX_CONTENT_BYTES } }); + } + const overflow = new Uint8Array([3]); + expect( + await ask(socket, "overflow", { + command: "stage", + kind: "blob", + digest: sha256Hex(overflow), + bytes: encodeBase64(overflow), + }), + ).toEqual({ id: "overflow", outcome: "refused", refusal: "command:capacity" }); + expect(await on(stub, (owner) => owner.scratch())).toEqual({ commands: 2, staged: 2 }); + }); + + it("bounds the retry ledger without evicting earlier decisions", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + const first = await ask(socket, "command-0", { command: "frontier" }); + for (let index = 1; index < MAX_COMMANDS; index += 1) { + expect(await ask(socket, `command-${index}`, { command: "frontier" })).toMatchObject({ + outcome: "performed", + }); + } + expect(await ask(socket, "overflow", { command: "frontier" })).toEqual({ + id: "overflow", + outcome: "refused", + refusal: "command:capacity", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(socket.readyState).not.toBe(WebSocket.OPEN); + expect(await on(stub, (owner) => owner.scratch())).toEqual({ + commands: MAX_COMMANDS, + staged: 0, + }); + expect(first).toMatchObject({ outcome: "performed" }); + }); + + it("refuses a settlement naming an execution this run never began", async () => { + // Settlement is implemented now, so what it refuses is what it should: + // a completion addressed to an execution that is not here. The earlier + // shape of this test asserted the command was declined outright, which was + // true only while the transition was somebody else's checkpoint. + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await admit(stub); + const answer = await send(stub, "settle", { + command: "settle", + completion: { executionId: "execution", status: "completed" }, + expectedWorkspaceRootId: ROOT_ID, + }); + expect(answer["outcome"]).toBe("refused"); + // And nothing was published: the run is exactly where it was. + expect((await on(stub, (owner) => owner.published()))["events"]).toEqual([]); + }); +}); diff --git a/packages/workflow/tests/cloudflare/remote-publish.vitest.ts b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts new file mode 100644 index 000000000..c7f874190 --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts @@ -0,0 +1,924 @@ +/** + * Publishing one proposal on real workerd. + * + * This is the point where a remote run moves, and almost nothing about it is + * provable against a model. Whether content, roots, references, mappings, the + * current pointer, the journal and the retry decision commit together is a + * property of the Durable Object's own `transactionSync()`. Whether a lost + * response can be retried exactly once is a property of storage surviving + * eviction. Whether a stale socket can still write is a property of the + * runtime's socket list. + * + * So these run against a real namespace, real SQLite and real Hibernation + * WebSockets, and the assertions are about what was published, what was + * refused, and what was left exactly as it was. + */ + +import { env, evictDurableObject, runInDurableObject } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import type { ExecutorObject } from "./support/executor-object.ts"; +import type { ExecutorObject as _ExecutorObject } from "./support/executor-object.ts"; +import { + NEXT_BLOB_ID, + NEXT_BYTES, + NEXT_ROOT_ID, + nextPublication, + POLICY, + ROOT_ID, + RUN_ID, + VALID_CLAIMS, +} from "./support/executor-object.ts"; +import { encodeBase64 } from "../../src/cloudflare/encoding.ts"; +import { sha256Hex } from "../../src/workspace/sha256.ts"; +import { locatorFingerprintOf } from "../../src/composition/locator.ts"; +import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; + +let unique = 0; +const NOW = 1_800_000_000; +let keys: TestKeys; + +beforeAll(async () => { + keys = await generateKeys(); +}); + +function executor() { + unique += 1; + return env.EXECUTOR.get(env.EXECUTOR.idFromName(`publish-${unique}-${Math.random()}`)); +} + +function on(stub: ReturnType, body: (owner: ExecutorObject) => T): Promise { + return runInDurableObject(stub, body); +} + +function record(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("expected an object answer"); + } + return Object.fromEntries(Object.entries(value)); +} + +async function admit(stub: ReturnType): Promise { + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const token = await signToken(keys, { + ...VALID_CLAIMS, + iat: NOW - 10, + nbf: NOW - 10, + exp: NOW + 600, + }); + expect(await on(stub, (owner) => owner.admitConnection({ token, release: POLICY.release }))).toBe( + "admitted", + ); +} + +/** + * A real accepted connection, which is what survives eviction. + * + * A `WebSocketPair` made inside the object is gone once the object is evicted; + * only a socket the runtime accepted through a request comes back with its + * attachment. The retry claim is about exactly that, so it has to use this. + */ +async function connect(stub: ReturnType): Promise { + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const token = await signToken(keys, { + ...VALID_CLAIMS, + iat: NOW - 10, + nbf: NOW - 10, + exp: NOW + 600, + }); + const response = await stub.fetch("https://owner.invalid/executor", { + headers: { + authorization: `Bearer ${token}`, + upgrade: "websocket", + "x-release": POLICY.release, + "x-run-id": RUN_ID, + }, + }); + const socket = response.webSocket; + if (socket === null) { + throw new Error(`expected an executor WebSocket, received ${response.status}`); + } + socket.accept(); + return socket; +} + +function ask( + socket: WebSocket, + id: string, + command: Record, +): Promise> { + return new Promise((resolve, reject) => { + const receive = (message: MessageEvent) => { + socket.removeEventListener("message", receive); + if (typeof message.data !== "string") { + reject(new Error("expected a text answer")); + return; + } + resolve(record(JSON.parse(message.data))); + }; + socket.addEventListener("message", receive); + socket.send(JSON.stringify({ id, ...command })); + }); +} + +function event(name: string): string { + return serializeDurableEvent({ + type: "yield", + coroutineId: "root", + description: { type: "test", name }, + result: { status: "ok", value: name }, + }); +} + +/** The repository mapping one proposal carries alongside its bytes. */ +const LOCATOR = "https://git.example.invalid/octo/app.git"; + +const REPOSITORY = { + kind: "repository", + locator: LOCATOR, + record: { + name: "app", + locatorFingerprint: locatorFingerprintOf(LOCATOR), + requestedBase: null, + creationCommit: "9".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", + checkoutPath: "/app", + }, +}; + +/** Stage the one missing piece over an accepted connection. */ +async function stageThrough(socket: WebSocket): Promise { + await ask(socket, "stage-blob", { + command: "stage", + kind: "blob", + digest: NEXT_BLOB_ID, + bytes: encodeBase64(NEXT_BYTES), + }); + const manifest = new TextEncoder().encode( + JSON.stringify({ version: 1, chunks: [{ hash: NEXT_BLOB_ID, size: NEXT_BYTES.length }] }), + ); + await ask(socket, "stage-manifest", { + command: "stage", + kind: "manifest", + digest: sha256Hex(manifest), + bytes: encodeBase64(manifest), + }); +} + +function commit(overrides: Record = {}): Record { + return { + command: "commit", + expectedWorkspaceRootId: ROOT_ID, + expectedJournalEventId: null, + publication: nextPublication(), + mappings: [REPOSITORY], + events: [event("published")], + answer: null, + ...overrides, + }; +} + +describe("publishing one proposal", () => { + it("adopts content, root, references, mapping, pointer and journal together", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + await stageThrough(socket); + + const before = await on(stub, (owner) => owner.published()); + expect(before).toMatchObject({ currentRootId: ROOT_ID, roots: 1, events: [] }); + + const answer = await ask(socket, "publish", commit()); + expect(answer).toEqual(expect.objectContaining({ outcome: "performed" })); + const value = record(answer["value"]); + expect(value["workspaceRootId"]).toBe(NEXT_ROOT_ID); + expect(Array.isArray(value["journalEventIds"]) && value["journalEventIds"]).toHaveLength(1); + + const after = await on(stub, (owner) => owner.published()); + expect(after["currentRootId"]).toBe(NEXT_ROOT_ID); + // The old root stays retained; publication moves only the pointer. + expect(after["roots"]).toBe(2); + expect(after["repositories"]).toEqual([{ name: "app", checkout_path: "/app" }]); + // The journal row names the root this commit selected, not the one it + // started from. + expect(after["events"]).toEqual([expect.objectContaining({ workspace_root_id: NEXT_ROOT_ID })]); + // Content the owner already held was reused by identity rather than + // resent: two blobs exist, and the proposal only staged one. + expect(after["blobs"]).toBe(2); + expect(after["blobRefs"]).toBe(3); + + // And the new frontier reads back whole. + const frontier = record(record(await ask(socket, "read", { command: "frontier" }))["value"]); + expect(frontier["workspaceRootId"]).toBe(NEXT_ROOT_ID); + expect( + record(await ask(socket, "root", { command: "root", workspaceRootId: NEXT_ROOT_ID })), + ).toMatchObject({ outcome: "performed" }); + }); + + it("admits root, journal anchor and every mapping as one state", async () => { + // The invocation snapshot D3c begins from. It is one read on the owner + // because it is one fact: mappings taken from one moment and a root from + // another would let an invocation start against a Workspace its retained + // rows do not describe, and nothing later could tell. + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + + const empty = record(record(await ask(socket, "empty", { command: "mappings" }))["value"]); + expect(empty).toEqual({ + workspaceRootId: ROOT_ID, + journalEventId: null, + repositories: [], + worktrees: [], + agentSessions: [], + }); + + await stageThrough(socket); + const published = await ask(socket, "publish", commit()); + expect(published).toEqual(expect.objectContaining({ outcome: "performed" })); + + // One commit moved the pointer, retained the mapping and wrote the row. + // The next snapshot observes all of it, and observes it together. + const after = record(record(await ask(socket, "after", { command: "mappings" }))["value"]); + expect(after["workspaceRootId"]).toBe(NEXT_ROOT_ID); + expect(typeof after["journalEventId"]).toBe("string"); + expect(after["repositories"]).toEqual([{ record: REPOSITORY.record, locator: LOCATOR }]); + expect(after["worktrees"]).toEqual([]); + expect(after["agentSessions"]).toEqual([]); + + // The same anchor the frontier reports, from the same owner state. + const frontier = record( + record(await ask(socket, "frontier", { command: "frontier" }))["value"], + ); + expect([frontier["workspaceRootId"], frontier["journalEventId"]]).toEqual([ + after["workspaceRootId"], + after["journalEventId"], + ]); + }); + + it("returns no partial snapshot when more is retained than one may carry", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + // Under the ceiling the snapshot answers whole. + await on(stub, (owner) => owner.fillRepositories(0, 200)); + const held = record(record(await ask(socket, "under", { command: "mappings" }))["value"]); + expect(Array.isArray(held["repositories"]) && held["repositories"]).toHaveLength(200); + + // Over it, the answer is a refusal rather than as much as would fit. A + // partial snapshot would describe a run holding fewer Repositories than it + // does, and every reconciliation against it would be decided wrongly. + await on(stub, (owner) => owner.fillRepositories(200, 200)); + const answer = await ask(socket, "over", { command: "mappings" }); + expect(answer["outcome"]).toBe("refused"); + expect(answer["value"]).toBe(undefined); + }); + + it("keeps the expected root current for a journal-only transaction", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + const answer = await ask( + socket, + "journal-only", + commit({ publication: null, mappings: [], events: [event("noted")] }), + ); + expect(answer).toMatchObject({ outcome: "performed" }); + const after = await on(stub, (owner) => owner.published()); + expect(after["currentRootId"]).toBe(ROOT_ID); + expect(after["roots"]).toBe(1); + expect(after["events"]).toEqual([expect.objectContaining({ workspace_root_id: ROOT_ID })]); + }); + + it("commits an empty transaction without inventing a Workspace change", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + expect( + await ask(socket, "empty", commit({ publication: null, mappings: [], events: [] })), + ).toMatchObject({ outcome: "performed", value: { workspaceRootId: ROOT_ID } }); + expect(await on(stub, (owner) => owner.published())).toMatchObject({ + currentRootId: ROOT_ID, + roots: 1, + events: [], + }); + }); + + it("rolls every category back when the transaction fails after applying", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + await stageThrough(socket); + const before = await on(stub, (owner) => owner.published()); + + expect( + await on(stub, (owner) => + owner.failAfterApply(JSON.stringify({ id: "doomed", ...commit() })), + ), + ).toBe("rolled-back"); + + // Content, root, references, mapping, pointer and journal are all back + // where they were — and so is the retry decision, so the same id is free. + expect(await on(stub, (owner) => owner.published())).toEqual(before); + expect(await on(stub, (owner) => owner.scratch())).toMatchObject({ commands: 2 }); + expect(await ask(socket, "doomed", commit())).toMatchObject({ outcome: "performed" }); + }); + + it("applies a lost-response retry exactly once, across eviction", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + await ask(socket, "stage-blob", { + command: "stage", + kind: "blob", + digest: NEXT_BLOB_ID, + bytes: encodeBase64(NEXT_BYTES), + }); + const manifestBytes = new TextEncoder().encode( + JSON.stringify({ version: 1, chunks: [{ hash: NEXT_BLOB_ID, size: NEXT_BYTES.length }] }), + ); + await ask(socket, "stage-manifest", { + command: "stage", + kind: "manifest", + digest: sha256Hex(manifestBytes), + bytes: encodeBase64(manifestBytes), + }); + + const first = await ask(socket, "once", commit()); + expect(first).toMatchObject({ outcome: "performed" }); + const published = await on(stub, (owner) => owner.published()); + + // The runner never saw that answer. The object is evicted, and the same + // healthy socket asks the same question again with the same id and the same + // canonical request. + await evictDurableObject(stub); + expect(await ask(socket, "once", commit())).toEqual(first); + + // One root, one journal row, one mapping — the retry returned the decision + // rather than doing the work a second time. + expect(await on(stub, (owner) => owner.published())).toEqual(published); + + // Reusing that id for a different request is not a retry. + expect(await ask(socket, "once", commit({ events: [event("something else")] }))).toMatchObject({ + outcome: "refused", + refusal: "command:duplicate-conflict", + }); + expect(await on(stub, (owner) => owner.published())).toEqual(published); + }); + + it("changes nothing when the frontier or the proposal is not what it claims", async () => { + const cases: Record> = { + "a root the run is not at": commit({ expectedWorkspaceRootId: `f${"0".repeat(63)}` }), + "an anchor the run is not at": commit({ expectedJournalEventId: "never-happened" }), + "an identity that is not the digest of its manifest": commit({ + publication: { ...nextPublication(), proposedWorkspaceRootId: `a${"1".repeat(63)}` }, + }), + "an inventory missing a piece the root names": commit({ + publication: { + ...nextPublication(), + content: (nextPublication()["content"] as unknown[]).slice(1), + }, + }), + "an inventory naming a piece the root does not": commit({ + publication: { + ...nextPublication(), + content: [ + ...(nextPublication()["content"] as Record[]), + { kind: "blob", digest: "e".repeat(64), size: 4 }, + ], + }, + }), + }; + + for (const [description, request] of Object.entries(cases)) { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + await stageThrough(socket); + const before = await on(stub, (owner) => owner.published()); + const answer = await ask(socket, "refused", request); + expect([description, answer["outcome"]]).toEqual([description, "refused"]); + expect([description, await on(stub, (owner) => owner.published())]).toEqual([ + description, + before, + ]); + } + }); + + it("refuses content this acquisition did not stage", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + // Nothing staged: the proposal names a piece the owner neither holds nor + // was given by this connection. + const before = await on(stub, (owner) => owner.published()); + expect(await ask(socket, "unstaged", commit())).toMatchObject({ outcome: "refused" }); + expect(await on(stub, (owner) => owner.published())).toEqual(before); + }); + + it("refuses a mapping that would rewrite an established identity", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + await stageThrough(socket); + expect(await ask(socket, "first", commit())).toMatchObject({ outcome: "performed" }); + const published = await on(stub, (owner) => owner.published()); + + // The same Repository name, a different creation commit. Creation identity + // is immutable, so this is refused rather than allowed to overwrite it. + expect( + await ask( + socket, + "second", + commit({ + expectedWorkspaceRootId: NEXT_ROOT_ID, + expectedJournalEventId: String( + (published["events"] as Record[])[0]?.["event_id"], + ), + publication: null, + mappings: [ + { ...REPOSITORY, record: { ...REPOSITORY.record, creationCommit: "1".repeat(40) } }, + ], + events: [], + }), + ), + ).toMatchObject({ outcome: "refused", refusal: "command:mapping-conflict" }); + expect(await on(stub, (owner) => owner.published())).toEqual(published); + }); + + it("retains only records that are exactly what the serializer produced", async () => { + // A record the database will accept as JSON is not a durable event. One + // retained here would be history a later read cannot parse, and the run + // would become unreplayable at the moment it was told it had committed. + const valid = event("real"); + const cases: Record = { + "JSON that is not an event": "{}", + "an event without its terminating newline": valid.trimEnd(), + "a noncanonical re-encoding": `${JSON.stringify(JSON.parse(valid.trimEnd()), null, 1)}\n`, + "not JSON at all": "event-1", + }; + for (const [description, record_] of Object.entries(cases)) { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + const before = await on(stub, (owner) => owner.published()); + const answer = await ask( + socket, + "bad-event", + commit({ publication: null, mappings: [], events: [record_] }), + ); + // The id is empty because the command never finished parsing: an id is + // echoed once the request has been read, and this one was not. + expect([description, answer]).toEqual([ + description, + { id: "", outcome: "refused", refusal: "command:malformed-member" }, + ]); + expect([description, await on(stub, (owner) => owner.published())]).toEqual([ + description, + before, + ]); + // Nothing recorded a decision for work that never happened. A malformed + // member is a broken channel rather than an answer, so the connection is + // gone too — which is why the ledger is read through the object. + expect([description, await on(stub, (owner) => owner.scratch())]).toEqual([ + description, + { commands: 0, staged: 0 }, + ]); + } + }); + + it("refuses a mapping that disagrees with retained identity in any field", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + // A real accepted connection: this walks several cases and a pair socket + // does not survive the object being reset between them. + const socket = await connect(stub); + await stageThrough(socket); + expect(await ask(socket, "first", commit())).toMatchObject({ outcome: "performed" }); + const published = await on(stub, (owner) => owner.published()); + const anchor = String((published["events"] as Record[])[0]?.["event_id"]); + + // Every field that establishes creation identity, one at a time. A partial + // comparison would report performed for a proposal that disagrees with what + // an earlier execution established. + const conflicts: Record> = { + "a different locator, with its own fingerprint": { + kind: "repository", + locator: "https://git.example.invalid/other.git", + record: { + ...REPOSITORY.record, + locatorFingerprint: locatorFingerprintOf("https://git.example.invalid/other.git"), + }, + }, + "a different requested base": { + ...REPOSITORY, + record: { ...REPOSITORY.record, requestedBase: "release" }, + }, + "a different creation commit": { + ...REPOSITORY, + record: { ...REPOSITORY.record, creationCommit: "1".repeat(40) }, + }, + "a different primary branch": { + ...REPOSITORY, + record: { ...REPOSITORY.record, primaryBranch: "trunk" }, + }, + "a different checkout path": { + ...REPOSITORY, + record: { ...REPOSITORY.record, checkoutPath: "/elsewhere" }, + }, + }; + for (const [description, mapping] of Object.entries(conflicts)) { + const answer = await ask( + socket, + `conflict-${description}`, + commit({ + expectedWorkspaceRootId: NEXT_ROOT_ID, + expectedJournalEventId: anchor, + publication: null, + mappings: [mapping], + events: [], + }), + ); + expect([description, answer["refusal"]]).toEqual([description, "command:mapping-conflict"]); + expect([description, await on(stub, (owner) => owner.published())]).toEqual([ + description, + published, + ]); + } + }); + + it("refuses a new checkout mapping that no publication creates", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + const before = await on(stub, (owner) => owner.published()); + + // A mapping-only commit would retain a claim about a directory nothing put + // there, and the next execution would find the claim and not the files. + expect( + await ask( + socket, + "no-publication", + commit({ publication: null, mappings: [REPOSITORY], events: [] }), + ), + ).toMatchObject({ refusal: "command:mapping-conflict" }); + expect(await on(stub, (owner) => owner.published())).toEqual(before); + + // A Worktree whose Repository is neither retained nor proposed belongs to + // nothing. + await stageThrough(socket); + expect( + await ask( + socket, + "orphan-worktree", + commit({ + mappings: [ + { + kind: "worktree", + record: { + repositoryName: "absent", + name: "feature", + requestedBranch: "feature", + requestedBase: null, + creationCommit: "2".repeat(40), + checkoutPath: "/app", + }, + }, + ], + }), + ), + ).toMatchObject({ refusal: "command:mapping-conflict" }); + expect(await on(stub, (owner) => owner.published())).toEqual(before); + }); + + it("refuses one proposal naming one mapping twice", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + await stageThrough(socket); + const before = await on(stub, (owner) => owner.published()); + expect( + await ask(socket, "duplicate", commit({ mappings: [REPOSITORY, REPOSITORY] })), + ).toMatchObject({ refusal: "command:mapping-conflict" }); + expect(await on(stub, (owner) => owner.published())).toEqual(before); + }); + + it("refuses to append history over a current root that is damaged", async () => { + // A commit accepts its starting root as the run's frontier. One whose graph + // cannot be materialized is not a frontier, and a proposal is not a licence + // to repair it. + const damage: Record void> = { + "a blob whose bytes are not its identity": (owner) => owner.damageRetainedBlob(), + "a blob whose recorded size is wrong": (owner) => owner.damageBlobSize(), + "a manifest whose recorded size is wrong": (owner) => owner.damageManifestSize(), + "a reference no manifest names": (owner) => + owner.addExtraBlobReference(new TextEncoder().encode("unaccounted for")), + }; + for (const [description, arrange] of Object.entries(damage)) { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await on(stub, arrange); + const socket = await connect(stub); + const before = await on(stub, (owner) => owner.published()); + const answer = await ask( + socket, + "over-damage", + commit({ publication: null, mappings: [], events: [event("noted")] }), + ); + expect([description, answer["refusal"]]).toEqual([description, "storage:corrupt"]); + expect([description, await on(stub, (owner) => owner.published())]).toEqual([ + description, + before, + ]); + } + }); + + it("retains only a locator this system would hand to Git", async () => { + // A matching fingerprint says the two values agree with each other. It says + // nothing about whether the locator is one that may ever be used, and an + // authenticated proposal must not be able to retain a credential or an + // executable transport form. + const refused: Record = { + "a credential in the URL": "https://user:token@git.example.invalid/octo/app.git", + "an executable transport form": "ext::sh -c 'curl example.invalid'", + "a query that can carry a token": "https://git.example.invalid/app.git?access_token=abc", + "an unknown scheme": "javascript:alert(1)", + "a relative path": "../elsewhere", + }; + for (const [description, locator] of Object.entries(refused)) { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + await stageThrough(socket); + const before = await on(stub, (owner) => owner.published()); + const answer = await ask( + socket, + "bad-locator", + commit({ + mappings: [ + { + kind: "repository", + locator, + record: { ...REPOSITORY.record, locatorFingerprint: locatorFingerprintOf(locator) }, + }, + ], + }), + ); + expect([description, answer["refusal"]]).toEqual([description, "command:malformed-member"]); + expect([description, await on(stub, (owner) => owner.published())]).toEqual([ + description, + before, + ]); + } + }); + + it("retains the exact admitted locator, not its fingerprint", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + await stageThrough(socket); + expect(await ask(socket, "publish", commit())).toMatchObject({ outcome: "performed" }); + // The row a later restoration reads has to name the repository, not a + // digest of it. + expect(await on(stub, (owner) => owner.repositoryLocator("app"))).toBe(LOCATOR); + }); + + it("accepts a Repository and its Worktree in either order", async () => { + const worktree = { + kind: "worktree", + record: { + repositoryName: "app", + name: "feature", + requestedBranch: "feature", + requestedBase: null, + creationCommit: "2".repeat(40), + checkoutPath: "/app", + }, + }; + // Which of the two comes first in an array is not a difference between + // proposals, so both spellings of one transaction must be accepted. + for (const [description, mappings] of Object.entries({ + "parent first": [REPOSITORY, worktree], + "child first": [worktree, REPOSITORY], + })) { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + await stageThrough(socket); + const answer = await ask(socket, "both", commit({ mappings })); + expect([description, answer["outcome"]]).toEqual([description, "performed"]); + expect([description, await on(stub, (owner) => owner.published())]).toMatchObject([ + description, + { currentRootId: NEXT_ROOT_ID, repositories: [{ name: "app", checkout_path: "/app" }] }, + ]); + } + }); + + it("refuses a blob whose bytes were never retained beside its metadata", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + // A metadata row with no bytes is a half-written identity. Completing it + // from staging would repair authoritative damage as a side effect. + await on(stub, (owner) => owner.removeBlobBytesOnly(NEXT_BLOB_ID, NEXT_BYTES.length)); + const socket = await connect(stub); + await stageThrough(socket); + const before = await on(stub, (owner) => owner.published()); + expect(await ask(socket, "half", commit())).toMatchObject({ refusal: "storage:corrupt" }); + expect(await on(stub, (owner) => owner.published())).toEqual(before); + expect(await on(stub, (owner) => owner.scratch())).toMatchObject({ commands: 2 }); + }); + + it("returns the same decision to a connection that replaced the one that lost it", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + await stageThrough(socket); + + // The owner commits. The runner never sees the answer, and the connection + // that asked is gone — which is exactly the case the acquisition-scoped + // ledger cannot answer, because a replacement acquisition discards it. + const first = await ask(socket, "recovered", commit()); + expect(first).toMatchObject({ outcome: "performed" }); + const published = await on(stub, (owner) => owner.published()); + socket.close(1000, "lost"); + await new Promise((resolve) => setTimeout(resolve, 0)); + await evictDurableObject(stub); + + // A new connection, a new acquisition, the identical closed request. + const replacement = await connect(stub); + expect(await ask(replacement, "recovered", commit())).toEqual(first); + + // One root, one mapping, one set of journal rows. + expect(await on(stub, (owner) => owner.published())).toEqual(published); + + // And the identity still cannot be reused for something else. + expect( + await ask(replacement, "recovered", commit({ events: [event("different")] })), + ).toMatchObject({ outcome: "refused", refusal: "command:duplicate-conflict" }); + expect(await on(stub, (owner) => owner.published())).toEqual(published); + }); + + it("performs a proposal whose first attempt never reached the owner", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + await stageThrough(socket); + const before = await on(stub, (owner) => owner.published()); + + // The first attempt was lost on the way out, so the owner never saw it. + socket.close(1000, "lost before arriving"); + await new Promise((resolve) => setTimeout(resolve, 0)); + await evictDurableObject(stub); + + const replacement = await connect(stub); + await stageThrough(replacement); + expect(await ask(replacement, "never-arrived", commit())).toMatchObject({ + outcome: "performed", + }); + const after = await on(stub, (owner) => owner.published()); + expect(after["currentRootId"]).toBe(NEXT_ROOT_ID); + expect(after).not.toEqual(before); + }); + + it("grants a closed or foreign socket no publication", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + await stageThrough(socket); + const before = await on(stub, (owner) => owner.published()); + expect( + await on(stub, (owner) => + record(owner.sendAsStranger(JSON.stringify({ id: "foreign", ...commit() }))), + ), + ).toMatchObject({ outcome: "refused", refusal: "acquisition:foreign-connection" }); + expect(await on(stub, (owner) => owner.published())).toEqual(before); + }); +}); + +describe("the run's own records", () => { + it("counts retrieval revisions authoritatively, and clearing starts again", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + + // The run is created with a retrieval row, so the first replacement is the + // next revision rather than the first. + const before = await on(stub, (owner) => owner.retrieval()); + expect(before).not.toBe(null); + + const first = await ask(socket, "r1", { + command: "retrieval", + expectedWorkspaceRootId: ROOT_ID, + metadata: '{"locator":"https://example.invalid/a.git"}', + }); + expect(first).toMatchObject({ outcome: "performed" }); + + // Byte-identical metadata under a different id is a second replacement. + const second = await ask(socket, "r2", { + command: "retrieval", + expectedWorkspaceRootId: ROOT_ID, + metadata: '{"locator":"https://example.invalid/a.git"}', + }); + expect(second).toMatchObject({ outcome: "performed" }); + const revisions = [first, second].map((answer) => + Number(record(record(answer["value"])["retrieval"])["revision"]), + ); + expect(revisions[1]).toBe((revisions[0] ?? 0) + 1); + + // Clearing removes the row; the next replacement counts from one. + expect( + await ask(socket, "r3", { + command: "retrieval", + expectedWorkspaceRootId: ROOT_ID, + metadata: null, + }), + ).toEqual({ id: "r3", outcome: "performed", value: { retrieval: null } }); + expect(await on(stub, (owner) => owner.retrieval())).toBe(null); + const restarted = await ask(socket, "r4", { + command: "retrieval", + expectedWorkspaceRootId: ROOT_ID, + metadata: '{"locator":"https://example.invalid/b.git"}', + }); + expect(Number(record(record(restarted["value"])["retrieval"])["revision"])).toBe(1); + }); + + it("applies one retrieval replacement once across a lost answer and eviction", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + const request = { + command: "retrieval", + expectedWorkspaceRootId: ROOT_ID, + metadata: '{"locator":"https://example.invalid/once.git"}', + }; + const performed = await ask(socket, "once", request); + expect(performed).toMatchObject({ outcome: "performed" }); + const stored = await on(stub, (owner) => owner.retrieval()); + + socket.close(1000, "lost"); + await new Promise((resolve) => setTimeout(resolve, 0)); + await evictDurableObject(stub); + + const replacement = await connect(stub); + // The same invocation asked again: the retained decision answers, and the + // revision does not move. + expect(await ask(replacement, "once", request)).toEqual(performed); + expect(await on(stub, (owner) => owner.retrieval())).toEqual(stored); + + // The same identity for different content is a conflict, not a retry. + expect( + await ask(replacement, "once", { ...request, metadata: '{"locator":"other"}' }), + ).toMatchObject({ outcome: "refused", refusal: "command:duplicate-conflict" }); + expect(await on(stub, (owner) => owner.retrieval())).toEqual(stored); + }); + + it("refuses a replacement proposed against a root the run has left", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const socket = await connect(stub); + const before = await on(stub, (owner) => owner.retrieval()); + expect( + await ask(socket, "stale", { + command: "retrieval", + expectedWorkspaceRootId: `f${"0".repeat(63)}`, + metadata: '{"locator":"x"}', + }), + ).toMatchObject({ outcome: "refused", refusal: "command:stale-root" }); + expect(await on(stub, (owner) => owner.retrieval())).toEqual(before); + }); + + it("anchors a multipage execution snapshot and excludes a later one", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + for (let index = 0; index < 129; index += 1) { + await on(stub, (owner) => + owner.beginExecution( + `execution-${index}`, + `2026-09-04T00:00:${String(index % 60).padStart(2, "0")}.000Z`, + ), + ); + } + const socket = await connect(stub); + + // The first request carries no anchor; the owner chooses the terminal row + // at this moment and answers with it. + const anchored = record( + (await ask(socket, "x1", { command: "executions", anchor: null, after: null }))["value"], + ); + expect(anchored["anchor"]).toBe(129); + expect(Array.isArray(anchored["rows"]) && anchored["rows"]).toHaveLength(128); + expect(anchored["done"]).toBe(false); + + // A later execution begins while the read is in flight. + await on(stub, (owner) => owner.beginExecution("execution-later", "2026-09-04T01:00:00.000Z")); + + const second = record( + (await ask(socket, "x2", { command: "executions", anchor: 129, after: 128 }))["value"], + ); + expect(Array.isArray(second["rows"]) && second["rows"]).toHaveLength(1); + expect(second["done"]).toBe(true); + // The one begun after the anchor is not in the snapshot. + expect(record((second["rows"] as Record[])[0] ?? {})["sequence"]).toBe(129); + }); +}); diff --git a/packages/workflow/tests/cloudflare/remote-read-plane.vitest.ts b/packages/workflow/tests/cloudflare/remote-read-plane.vitest.ts new file mode 100644 index 000000000..724905899 --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-read-plane.vitest.ts @@ -0,0 +1,726 @@ +/** + * Reading a run's owner without taking it. + * + * The claim under test is one a fake cannot make: that an ordinary + * authenticated request answers from a real Durable Object's committed SQLite + * state while an executor WebSocket is live, and that asking takes no + * acquisition. Only the runtime's own acquisition set can settle that. + */ + +import { env, runInDurableObject } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { run, type Operation, until } from "effection"; +import type { ExecutorObject } from "./support/executor-object.ts"; +import { POLICY, RUN_ID, VALID_CLAIMS } from "./support/executor-object.ts"; +import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; +import { + cloudflareReadPlane, + FORK_SOURCE_ANSWER_BYTES, + type ReadTransport, +} from "../../src/cloudflare/read-client.ts"; +import { + READ_PAGE_BYTES, + READ_PAGE_ENTRIES, + READ_REQUEST_BYTES, +} from "../../src/cloudflare/read-plane.ts"; +import { cloudflareRunLink } from "../../src/cloudflare/client.ts"; +import { + useOwnerConnection, + type OwnerSocket, + type SocketListener, +} from "../../src/remote/client.ts"; +import { useRemoteRunStorage } from "../../src/remote/storage.ts"; +import { WorkflowRunStorage } from "../../src/storage/api.ts"; +import type { CreateWorkflowRunRequest } from "../../src/storage/api.ts"; + +let unique = 0; +const NOW = 1_800_000_000; +let keys: TestKeys; + +beforeAll(async () => { + keys = await generateKeys(); +}); + +function executor() { + unique += 1; + return env.EXECUTOR.get(env.EXECUTOR.idFromName(`read-${unique}-${Math.random()}`)); +} + +function on(stub: ReturnType, body: (o: ExecutorObject) => T): Promise { + return runInDurableObject(stub, body); +} + +async function token(): Promise { + return await signToken(keys, { ...VALID_CLAIMS, iat: NOW - 10, nbf: NOW - 10, exp: NOW + 600 }); +} + +async function connect(stub: ReturnType): Promise { + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const response = await stub.fetch("https://owner.invalid/executor", { + headers: { + authorization: `Bearer ${await token()}`, + upgrade: "websocket", + "x-release": POLICY.release, + "x-run-id": RUN_ID, + }, + }); + const socket = response.webSocket; + if (socket === null) { + throw new Error(`expected an executor WebSocket, received ${response.status}`); + } + socket.accept(); + return socket; +} + +function ownerSocket(socket: WebSocket): OwnerSocket { + const listeners = new Map(); + return { + send: (data) => socket.send(data), + close: () => socket.close(), + addEventListener(type, listener) { + const forward: EventListener = (event) => { + const data: unknown = Reflect.get(event, "data"); + listener(typeof data === "string" ? { data } : {}); + }; + listeners.set(listener, forward); + socket.addEventListener(type, forward); + }, + removeEventListener(type, listener) { + const found = listeners.get(listener); + if (found !== undefined) { + socket.removeEventListener(type, found); + } + }, + }; +} + +/** One ordinary read request, answered by the object itself. */ +async function ask( + stub: ReturnType, + admission: { release: string | null; token: string | null; runId: string | null }, + body: string, +): Promise { + return await on(stub, (owner) => owner.readRequest(admission, body)); +} + +/** The read plane's transport: an ordinary request, and no socket at all. */ +function transportTo(stub: ReturnType): ReadTransport { + return { + *send(admission, body: string): Operation { + // Through the object itself, which is what makes "reading took no + // acquisition" observable rather than asserted. + return yield* until(ask(stub, admission, body)); + }, + }; +} + +function creation(): CreateWorkflowRunRequest { + return { + runId: RUN_ID, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + }; +} + +describe("reading a run's owner without taking it", () => { + it("answers while an executor is live, and takes no acquisition", async () => { + const stub = executor(); + const socket = await connect(stub); + const bearer = await token(); + const held = (state: string) => + on(stub, (owner) => ({ + state, + holders: owner.holders(), + acquisition: owner.acquisitionId(), + run: owner.runRow(), + })); + + // Everything inside one scope, because the connection *is* the + // acquisition: reading after it closed would prove nothing about + // coexisting with a live executor. + const seen = await run(function* () { + const connection = yield* useOwnerConnection(ownerSocket(socket)); + let identifier = 0; + yield* useRemoteRunStorage( + cloudflareRunLink(connection, () => `open-${(identifier += 1)}`, RUN_ID), + ); + yield* WorkflowRunStorage.operations.create(creation()); + + const before = yield* until(held("before")); + const answered = JSON.parse( + yield* until( + ask( + stub, + { release: POLICY.release, token: bearer, runId: RUN_ID }, + JSON.stringify({ operation: "inspect" }), + ), + ), + ); + return { before, answered, after: yield* until(held("after")) }; + }); + + expect(seen.before.holders).toBe(1); + expect(seen.answered["outcome"]).toBe("performed"); + // The exact holder before and after, no second holder, and no retained row + // changed by reading. + expect(seen.after).toEqual({ ...seen.before, state: "after" }); + }); + + it("refuses a wrong release before it verifies anything", async () => { + const stub = executor(); + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + // A body this build could not parse at all. If the release were compared + // after parsing, this would refuse as malformed instead. + const answered = JSON.parse( + await ask( + stub, + { release: "some-other-release", token: "not-a-token", runId: RUN_ID }, + "{ this is not JSON at all", + ), + ); + expect(answered["outcome"]).toBe("refused"); + expect(String(answered["refusal"])).toContain("release:"); + // Nothing was created, and nobody holds anything. + expect(await on(stub, (owner) => owner.objectCount())).toBe(0); + expect(await on(stub, (owner) => owner.holders())).toBe(0); + }); + + it("refuses an unauthenticated read without touching the run", async () => { + const stub = executor(); + const socket = await connect(stub); + await run(function* () { + const connection = yield* useOwnerConnection(ownerSocket(socket)); + let identifier = 0; + yield* useRemoteRunStorage( + cloudflareRunLink(connection, () => `open-${(identifier += 1)}`, RUN_ID), + ); + return yield* WorkflowRunStorage.operations.create(creation()); + }); + const before = await on(stub, (owner) => owner.runRow()); + + // Again with a body nothing could parse: authentication decides first. + const answered = JSON.parse( + await ask( + stub, + { release: POLICY.release, token: "not-a-token", runId: RUN_ID }, + "{ this is not JSON at all", + ), + ); + expect(answered["outcome"]).toBe("refused"); + // Not the release, and not a parse of the body either: authentication is + // what stopped it. + expect(String(answered["refusal"])).toContain("token:"); + // Whether an executor happened to be live is the coexistence test's claim; + // this one is that a refused read leaves the run exactly as it was. + expect(await on(stub, (owner) => owner.runRow())).toEqual(before); + }); + + it("answers one coherent inspection from one committed reading", async () => { + const stub = executor(); + const socket = await connect(stub); + await run(function* () { + const connection = yield* useOwnerConnection(ownerSocket(socket)); + let identifier = 0; + yield* useRemoteRunStorage( + cloudflareRunLink(connection, () => `open-${(identifier += 1)}`, RUN_ID), + ); + return yield* WorkflowRunStorage.operations.create(creation()); + }); + + const bearer = await token(); + const answered = JSON.parse( + await run(() => + transportTo(stub).send( + { release: POLICY.release, token: bearer, runId: RUN_ID }, + JSON.stringify({ operation: "inspect" }), + ), + ), + ); + expect(answered["outcome"]).toBe("performed"); + const value = answered["value"]; + // The run, its executions, the frontier, the current root and the lineage + // all describe the same committed moment. + expect(value["record"]["runId"]).toBe(RUN_ID); + expect(value["record"]["status"]).toBe("running"); + expect(value["executions"]).toEqual([]); + expect(value["journalFrontier"]).toBe(null); + expect(value["lineage"]).toBe(null); + expect(typeof value["currentWorkspaceRootId"]).toBe("string"); + // It agrees with what the object actually selects. + expect(value["currentWorkspaceRootId"]).toBe( + (await on(stub, (owner) => owner.published()))["currentRootId"], + ); + }); + + it("refuses a read addressed to another run, and writes nothing", async () => { + const stub = executor(); + const socket = await connect(stub); + await run(function* () { + const connection = yield* useOwnerConnection(ownerSocket(socket)); + let identifier = 0; + yield* useRemoteRunStorage( + cloudflareRunLink(connection, () => `open-${(identifier += 1)}`, RUN_ID), + ); + return yield* WorkflowRunStorage.operations.create(creation()); + }); + const before = await on(stub, (owner) => owner.runRow()); + + const bearer = await token(); + const answered = JSON.parse( + await run(() => + transportTo(stub).send( + { + release: POLICY.release, + token: bearer, + runId: "6dktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa", + }, + JSON.stringify({ operation: "inspect" }), + ), + ), + ); + expect(answered["outcome"]).toBe("refused"); + expect(await on(stub, (owner) => owner.runRow())).toEqual(before); + }); + + it("hands a fork the whole selection, and nothing the source keeps", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await on(stub, (owner) => owner.appendForkableHistory()); + const socket = await connect(stub); + const bearer = await token(); + + const seen = await run(function* () { + // The executor stays live for the whole read, so this is also the proof + // that selecting a source takes nothing from it. + const connection = yield* useOwnerConnection(ownerSocket(socket)); + yield* connection.ask("keep-alive", { command: "frontier" }, (value) => value); + const before = yield* until( + on(stub, (owner) => ({ holders: owner.holders(), run: owner.runRow() })), + ); + + const plane = cloudflareReadPlane( + transportTo(stub), + POLICY.release, + // deno-lint-ignore require-yield + function* () { + return bearer; + }, + RUN_ID, + ); + const source = yield* plane.forkSource("event-work"); + // Appended after the checkpoint was selected, while the read is done. + yield* until(on(stub, (owner) => owner.appendJournal("event-later", "later"))); + const again = yield* plane.forkSource("event-work"); + return { + before, + source, + again, + after: yield* until( + on(stub, (owner) => ({ holders: owner.holders(), run: owner.runRow() })), + ), + }; + }); + + expect(seen.source.ok).toBe(true); + if (!seen.source.ok) { + throw seen.source.error; + } + const source = seen.source.value; + // The prefix without the two rows the fork writes for itself. + expect(source.inherited.map((row) => row.eventId)).toEqual(["event-work"]); + expect(source.checkpointEventId).toBe("event-work"); + expect(source.checkpointWorkspaceRootId).toBe(source.runRecordWorkspaceRootId); + // Everything the destination must own independently came with it: the + // roots the prefix names, and the content those roots close over. + expect(source.roots.map((root) => root.rootId)).toEqual([source.checkpointWorkspaceRootId]); + expect(source.manifests.length).toBeGreaterThan(0); + expect(source.blobs.length).toBeGreaterThan(0); + for (const root of source.roots) { + for (const hash of root.manifestHashes) { + expect(source.manifests.some((manifest) => manifest.hash === hash)).toBe(true); + } + for (const hash of root.blobHashes) { + expect(source.blobs.some((blob) => blob.hash === hash)).toBe(true); + } + } + + // An append after the checkpoint does not enter the selection, however + // long the sequence took. + expect(seen.again.ok).toBe(true); + if (seen.again.ok) { + expect(seen.again.value.inherited.map((row) => row.eventId)).toEqual(["event-work"]); + } + // The executor still holds the run, and nothing about it changed. + expect(seen.before.holders).toBe(1); + expect(seen.after).toEqual(seen.before); + }); + + it("carries a multi-page selection in one order, with the records as retained", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await on(stub, (owner) => owner.appendForkableHistory()); + // More rows than one page may carry, so the selection is a sequence the + // client has to hold together rather than a single answer. + await on(stub, (owner) => owner.fillJournal(200)); + await on(stub, (owner) => owner.retainQualifyingRepository("alpha")); + await on(stub, (owner) => owner.retainQualifyingWorktree("alpha", "topic")); + const retained = await on(stub, (owner) => owner.journalRecords()); + const socket = await connect(stub); + const bearer = await token(); + + const seen = await run(function* () { + const connection = yield* useOwnerConnection(ownerSocket(socket)); + yield* connection.ask("keep-alive", { command: "frontier" }, (value) => value); + const before = yield* until( + on(stub, (owner) => ({ holders: owner.holders(), run: owner.runRow() })), + ); + const plane = cloudflareReadPlane( + transportTo(stub), + POLICY.release, + // deno-lint-ignore require-yield + function* () { + return bearer; + }, + RUN_ID, + ); + const source = yield* plane.forkSource("event-0199"); + return { + before, + source, + after: yield* until( + on(stub, (owner) => ({ holders: owner.holders(), run: owner.runRow() })), + ), + }; + }); + + expect(seen.source.ok).toBe(true); + if (!seen.source.ok) { + throw seen.source.error; + } + const source = seen.source.value; + // The prefix in journal order, without the two rows a fork writes for + // itself, and every record exactly as this owner retained it. + const inherited = retained.filter( + (row) => row.eventId !== "event-run" && row.eventId !== "event-import", + ); + expect(source.inherited.length).toBeGreaterThan(READ_PAGE_ENTRIES); + expect(source.inherited.map((row) => row.eventId)).toEqual(inherited.map((row) => row.eventId)); + expect(source.inherited.map((row) => row.record)).toEqual(inherited.map((row) => row.record)); + + // All three heads a destination writes against came with the selection. + const carried = source.roots.map((root) => root.rootId); + expect(carried).toContain(source.checkpointWorkspaceRootId); + expect(carried).toContain(source.runRecordWorkspaceRootId); + expect(carried).toContain(source.rootImportWorkspaceRootId); + + // The checkout graph, whole: the Repository and the Worktree that names + // it, each in a directory the checkpoint's Workspace holds. + expect(source.checkouts.map((checkout) => checkout.checkoutPath)).toEqual(["/", "/work"]); + const worktrees = source.checkouts.filter((checkout) => checkout.kind === "worktree"); + expect(worktrees.map((checkout) => checkout.repositoryName)).toEqual(["alpha"]); + expect( + source.checkouts.filter((checkout) => checkout.kind === "repository").map((one) => one.name), + ).toEqual(["alpha"]); + + // The executor held the run for the whole sequence, and nothing moved. + expect(seen.before.holders).toBe(1); + expect(seen.after).toEqual(seen.before); + }); + + it("continues a page of names JSON has to escape, and asks for the next in bytes", async () => { + // The shape a cursor made of names cannot survive. A retained name is text + // the schema bounds only by being non-empty, and a backslash is one + // character that JSON writes as two — twice over, if a cursor spelled out + // of names is then carried inside a request. These rows are near the + // largest a page admits, so nothing but a position could ask for the next + // one. + const escaping = (count: number, tail: string) => `${"\\".repeat(count)}"«${tail}»`; + const repositoryName = escaping(150_000, "repository"); + const first = escaping(80_000, "a"); + const second = escaping(80_000, "b"); + const stub = executor(); + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + await on(stub, (owner) => owner.initialize()); + await on(stub, (owner) => owner.appendForkableHistory()); + await on(stub, (owner) => owner.retainRepositoryAt(repositoryName, "/checkouts")); + await on(stub, (owner) => owner.retainWorktreeAt(repositoryName, first, "/work")); + await on(stub, (owner) => owner.retainWorktreeAt(repositoryName, second, "/")); + const bearer = await token(); + + const bytes = (text: string) => new TextEncoder().encode(text).length; + const asked: { section: string; request: number; answer: number }[] = []; + const measuring: ReadTransport = { + *send(admission, body: string): Operation { + const answer = yield* until(ask(stub, admission, body)); + const value: unknown = JSON.parse(body); + const section = + value !== null && typeof value === "object" ? String(Reflect.get(value, "section")) : ""; + asked.push({ section, request: bytes(body), answer: bytes(answer) }); + return answer; + }, + }; + + const outcome = await run(function* () { + const plane = cloudflareReadPlane( + measuring, + POLICY.release, + // deno-lint-ignore require-yield + function* () { + return bearer; + }, + RUN_ID, + ); + return yield* plane.forkSource("event-work"); + }); + + expect(outcome.ok).toBe(true); + if (!outcome.ok) { + throw outcome.error; + } + // Three checkouts, each near the largest member a page admits, so the + // section took three pages and two of them were continuations. + const pages = asked.filter((one) => one.section === "checkouts"); + expect(pages.length).toBe(3); + for (const checkout of outcome.value.checkouts) { + expect(bytes(JSON.stringify(checkout))).toBeLessThanOrEqual(READ_PAGE_BYTES); + } + expect(bytes(JSON.stringify(outcome.value.checkouts[1]))).toBeGreaterThan(400_000); + + // Every request the sequence needed fits the parser's bound, because a + // position is the same size whatever it points at. A cursor spelled out of + // these names would have been larger than the member itself. + for (const page of asked) { + expect(page.request).toBeLessThanOrEqual(READ_REQUEST_BYTES); + expect(page.answer).toBeLessThanOrEqual(FORK_SOURCE_ANSWER_BYTES); + } + expect(Math.max(...pages.map((one) => one.request))).toBeLessThan(1024); + expect(Math.max(...pages.map((one) => one.answer))).toBeGreaterThan(400_000); + + // The owner resolved each continuation to exactly one further member, and + // the composite identities came back distinct and whole. + expect(outcome.value.checkouts.map((one) => one.checkoutPath)).toEqual([ + "/checkouts", + "/work", + "/", + ]); + expect( + outcome.value.checkouts + .filter((one) => one.kind === "worktree") + .map((one) => [one.repositoryName, one.name]), + ).toEqual([ + [repositoryName, first], + [repositoryName, second], + ]); + }); + + it("refuses a continuation that names no member of the anchored selection", async () => { + const stub = executor(); + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + await on(stub, (owner) => owner.initialize()); + await on(stub, (owner) => owner.appendForkableHistory()); + const bearer = await token(); + const admission = { release: POLICY.release, token: bearer, runId: RUN_ID }; + const read = async (after: unknown, overrides: Record = {}) => + JSON.parse( + await ask( + stub, + admission, + JSON.stringify({ + operation: "fork-source", + checkpointEventId: "event-work", + section: "roots", + anchor: null, + after: null, + ...overrides, + ...(after === undefined ? {} : { after }), + }), + ), + ); + + const opening = await read(undefined); + expect(opening["outcome"]).toBe("performed"); + const anchor: unknown = opening["value"]["anchor"]; + const total: unknown = opening["value"]["total"]; + expect(total).toBe(1); + + // A position past the end of the section it names. + expect(await read(1, { anchor })).toEqual({ + outcome: "refused", + refusal: "command:stale-journal", + }); + // A position that is not one: a name, a fraction, a negative. + for (const malformed of ["0", 0.5, -1]) { + expect((await read(malformed, { anchor }))["outcome"]).toBe("refused"); + } + // A position with nothing pinning the selection it counts into. + expect((await read(0))["outcome"]).toBe("refused"); + // A section this build does not answer, and a checkpoint this run does not + // hold, are refused whether or not a position accompanies them. + expect((await read(0, { anchor, section: "elsewhere" }))["outcome"]).toBe("refused"); + expect((await read(0, { anchor, checkpointEventId: "event-nowhere" }))["outcome"]).toBe( + "refused", + ); + expect((await read(0, { anchor: "b".repeat(64) }))["outcome"]).toBe("refused"); + }); + + it("refuses a checkpoint this run does not hold, and a prefix with no run", async () => { + const stub = executor(); + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + await on(stub, (owner) => owner.initialize()); + const bearer = await token(); + const plane = () => + cloudflareReadPlane( + transportTo(stub), + POLICY.release, + // deno-lint-ignore require-yield + function* () { + return bearer; + }, + RUN_ID, + ); + + // Nothing retained at all: no checkpoint to select. + const missing = await run(() => plane().forkSource("event-nowhere")); + expect(missing.ok).toBe(false); + expect(String(missing.ok === false && missing.error)).not.toContain("command:"); + + // A prefix that records no run of its own is not one a fork could inherit. + await on(stub, (owner) => owner.appendJournal("event-alone", "alone")); + const unforkable = await run(() => plane().forkSource("event-alone")); + expect(unforkable.ok).toBe(false); + expect(String(unforkable.ok === false && unforkable.error)).not.toContain("command:"); + // Neither refusal wrote anything. + expect((await on(stub, (owner) => owner.published()))["events"]).toHaveLength(1); + }); + + it("changes the selection anchor when a qualifying checkout is added", async () => { + // The case the anchor exists for. Retained mappings are appendable, and a + // qualifying one added between sections would otherwise let a client join + // earlier pages to checkouts from a different committed state. + const stub = executor(); + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + await on(stub, (owner) => owner.initialize()); + await on(stub, (owner) => owner.appendForkableHistory()); + const bearer = await token(); + const anchorNow = async (): Promise => { + const answered = JSON.parse( + await ask( + stub, + { release: POLICY.release, token: bearer, runId: RUN_ID }, + JSON.stringify({ + operation: "fork-source", + checkpointEventId: "event-work", + section: "inherited", + anchor: null, + after: null, + }), + ), + ); + expect(answered["outcome"]).toBe("performed"); + return answered["value"]["anchor"]; + }; + + const before = await anchorNow(); + expect(typeof before).toBe("string"); + // The same selection, read twice, is the same selection. + expect(await anchorNow()).toBe(before); + + await on(stub, (owner) => owner.retainQualifyingRepository("added-between")); + // A checkout the destination would copy changed, so the selection did. + expect(await anchorNow()).not.toBe(before); + }); + + it("refuses a fork-source page from a selection that has moved", async () => { + const stub = executor(); + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + await on(stub, (owner) => owner.initialize()); + await on(stub, (owner) => owner.appendForkableHistory()); + const bearer = await token(); + + const answered = JSON.parse( + await ask( + stub, + { release: POLICY.release, token: bearer, runId: RUN_ID }, + JSON.stringify({ + operation: "fork-source", + checkpointEventId: "event-work", + // An anchor from a selection this owner never produced. + section: "roots", + anchor: "c".repeat(64), + after: null, + }), + ), + ); + expect(answered["outcome"]).toBe("refused"); + // And nothing was written on the way to refusing. + expect((await on(stub, (owner) => owner.published()))["events"]).toHaveLength(3); + }); + + it("changes the anchor when copied metadata moves and content does not", async () => { + // A watermark is copied into destination storage but is not implied by any + // content identity, so a digest cannot stand for it. + const stub = executor(); + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + await on(stub, (owner) => owner.initialize()); + await on(stub, (owner) => owner.appendForkableHistory()); + const bearer = await token(); + const anchorNow = async (): Promise => { + const answered = JSON.parse( + await ask( + stub, + { release: POLICY.release, token: bearer, runId: RUN_ID }, + JSON.stringify({ + operation: "fork-source", + checkpointEventId: "event-work", + section: "blobs", + anchor: null, + after: null, + }), + ), + ); + expect(answered["outcome"]).toBe("performed"); + return answered["value"]["anchor"]; + }; + + const before = await anchorNow(); + expect(typeof before).toBe("string"); + await on(stub, (owner) => owner.touchBlobWatermark()); + // Identical content, different metadata: a different selection. + expect(await anchorNow()).not.toBe(before); + }); + + it("refuses a retained value of the wrong type rather than converting it", async () => { + const stub = executor(); + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + await on(stub, (owner) => owner.initialize()); + await on(stub, (owner) => owner.appendForkableHistory()); + await on(stub, (owner) => owner.damageRetainedWatermark()); + const bearer = await token(); + + const answered = JSON.parse( + await ask( + stub, + { release: POLICY.release, token: bearer, runId: RUN_ID }, + JSON.stringify({ + operation: "fork-source", + checkpointEventId: "event-work", + section: "blobs", + anchor: null, + after: null, + }), + ), + ); + // Damage, not a zero. A reader that coerced would have answered with one. + expect(answered["outcome"]).toBe("refused"); + expect(String(answered["refusal"])).toContain("storage:corrupt"); + // And nothing of the retained value crossed with it. + expect(JSON.stringify(answered)).not.toContain("not a number"); + }); +}); diff --git a/packages/workflow/tests/cloudflare/remote-replay.vitest.ts b/packages/workflow/tests/cloudflare/remote-replay.vitest.ts new file mode 100644 index 000000000..e3524bc96 --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-replay.vitest.ts @@ -0,0 +1,1084 @@ +/** + * Tier WRH12 — a completed run replayed through its real durable owner. + * + * The rule this file exists for has two halves that pull in opposite + * directions. A completed replay must reach *no* external-effect provider; and + * it must reach the run's durable owner, because that is where the retained + * result is and an ephemeral runner holds no copy of it. Reading its own + * history is not attaching a provider, and proving that distinction needs the + * real thing: a real Durable Object, its own SQLite storage, a real accepted + * Hibernation WebSocket, and the production executor connection over it. + * + * What runs on this side is the production decision — `retainedReplay()` over + * the frontier the owner answered with — handed to canonical + * `executeInstalled()`. The shared CLI assembles exactly these two around the + * same values; it is not itself importable here, because it resolves a terminal + * renderer a Worker has no use for. That orchestration is proved in + * `packages/cli/tests/workflow-replay.test.ts` against the local host. + * + * Nothing in this file imports `@executablemd/workflow/deno`. It could not: the + * adapter behind that specifier reaches `node:sqlite`, which workerd does not + * have. A completed replay that runs here is a completed replay that needed + * none of it. + */ + +import { env, runInDurableObject } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { run, until, type Operation, type Result, Ok, scoped } from "effection"; +import { executeInstalled, retainedSource } from "@executablemd/core/host"; +import type { ExecutionInstallation, RetainedRootDocument } from "@executablemd/core/host"; +import type { Json } from "@executablemd/durable-streams"; +import type { ExecutorObject } from "./support/executor-object.ts"; +import { POLICY, RUN_ID, VALID_CLAIMS } from "./support/executor-object.ts"; +import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; +import { type OwnerSocket, type SocketListener } from "../../src/remote/client.ts"; +import { useExecutorConnection } from "../../src/cloudflare/executor-connection.ts"; +import { useRemoteLifecycle } from "../../src/remote/lifecycle.ts"; +import type { RemoteLifecycleHost } from "../../src/remote/lifecycle.ts"; +import type { RemoteExecutorConnection } from "../../src/remote/lifecycle-link.ts"; +import type { RemoteReadPlane } from "../../src/remote/read.ts"; +import type { WorkflowRunDatabase } from "../../src/storage/api.ts"; +import type { WorkflowRunStatus } from "../../src/storage/record.ts"; +import { WorkflowLifecycle } from "../../src/lifecycle/api.ts"; +import type { + WorkflowExecutionTransitions, + WorkflowRunCreation, +} from "../../src/lifecycle/execution.ts"; +import { retainedReplay } from "../../src/replay.ts"; +import { DOCUMENT_FAILED } from "../../src/lifecycle/policy.ts"; +import { retainedWorkflowInstallation } from "../../src/run.ts"; +import { workflowBundleInstallation } from "../../src/bundle.ts"; +import { gitBlobId } from "../../src/git-blob.ts"; + +let unique = 0; +const NOW = 1_800_000_000; +const COMMIT = "0".repeat(40); +const DOCUMENT = "# Remote\n\nthe owner recorded this line.\n"; +/** A document that fails: the name resolves to nothing, and nothing is searched. */ +const FAILING = "# Remote\n\npartial line.\n\n\n"; +let keys: TestKeys; + +beforeAll(async () => { + keys = await generateKeys(); +}); + +function executor() { + unique += 1; + return env.EXECUTOR.get(env.EXECUTOR.idFromName(`replay-${unique}-${Math.random()}`)); +} + +function on( + stub: ReturnType, + body: (instance: ExecutorObject) => T, +): Promise { + return runInDurableObject(stub, body); +} + +/** One real accepted executor WebSocket, admitted the way a runner's is. */ +async function connect(stub: ReturnType): Promise { + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const token = await signToken(keys, { + ...VALID_CLAIMS, + iat: NOW - 10, + nbf: NOW - 10, + exp: NOW + 600, + }); + const response = await stub.fetch("https://owner.invalid/executor", { + headers: { + authorization: `Bearer ${token}`, + upgrade: "websocket", + "x-release": POLICY.release, + "x-run-id": RUN_ID, + }, + }); + const socket = response.webSocket; + if (socket === null) { + throw new Error(`expected an executor WebSocket, received ${response.status}`); + } + socket.accept(); + return socket; +} + +/** The platform socket, bound to the four members the client uses. */ +function ownerSocket(socket: WebSocket): OwnerSocket { + const listeners = new Map(); + return { + send: (data) => socket.send(data), + close: () => socket.close(), + addEventListener(type, listener) { + const forward: EventListener = (event) => { + const data: unknown = Reflect.get(event, "data"); + listener(typeof data === "string" ? { data } : {}); + }; + listeners.set(listener, forward); + socket.addEventListener(type, forward); + }, + removeEventListener(type, listener) { + const found = listeners.get(listener); + if (found !== undefined) { + socket.removeEventListener(type, found); + } + }, + }; +} + +/** + * The provider's host, reaching this exact owner. + * + * Two of its four members refuse. The read plane and fork staging are the + * planes a replay has no business on, so entering either is a planted failure + * rather than something an assertion has to notice afterwards. + */ +function lifecycleHost(stub: ReturnType): RemoteLifecycleHost { + let executions = 0; + let commands = 0; + const next = () => `command-${(commands += 1)}`; + return { + *admit(runId: string): Operation> { + return yield* useExecutorConnection( + { + *open(): Operation> { + return Ok(ownerSocket(yield* until(connect(stub)))); + }, + ids: () => next, + }, + runId, + ); + }, + // deno-lint-ignore require-yield + *source(): Operation> { + throw new Error("PLANTED-READ-PLANE-REACHED"); + }, + // deno-lint-ignore require-yield + *stage(): Operation> { + throw new Error("PLANTED-STAGING-REACHED"); + }, + ids: { execution: () => `execution-${(executions += 1)}`, command: next }, + }; +} + +const CREATION: WorkflowRunCreation = { + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: COMMIT, + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, +}; + +/** One component the definition is closed over, named the way Git names it. */ +const STAGE = { name: "Stage", path: "Stage.md", content: "staged.\n" }; +const STAGE_HASH = gitBlobId(STAGE.content, "sha1"); +const BUNDLED_DOCUMENT = "# Remote\n\n\n"; + +const BUNDLED: WorkflowRunCreation = { + definition: { + ...CREATION.definition, + components: [{ name: STAGE.name, path: STAGE.path, sourceHash: STAGE_HASH }], + }, + base: "main", + props: {}, +}; + +/** The run contract every execution of this run installs. */ +function runContract(): ExecutionInstallation { + return retainedWorkflowInstallation({ + runId: RUN_ID, + base: CREATION.base, + pinnedCommit: COMMIT, + }); +} + +/** What one document execution rendered, and how it ended. */ +interface Rendered { + readonly output: string; + readonly result: Result; +} + +/** Take the acquisition this provider issues, or say why there is none. */ +function* acquired(): Operation<{ runId: string }> { + const taken = yield* WorkflowLifecycle.operations.acquireExecutor(RUN_ID); + if (!taken.ok) { + throw taken.error; + } + if (taken.value.kind !== "acquired") { + throw new Error("expected this connection to be the run's executor"); + } + return taken.value.lock; +} + +/** One canonical execution over the owner-backed journal, rendered in full. */ +function* canonical( + database: WorkflowRunDatabase, + root: RetainedRootDocument, + installations: readonly ExecutionInstallation[], +): Operation { + const running = yield* executeInstalled( + { ...root, stream: database.journal, componentDirs: [] }, + installations, + ); + const subscription = yield* running.output; + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + return { output: next.value, result: yield* running }; +} + +/** Everything about this owner a replay must leave alone. */ +function ownerState(stub: ReturnType) { + return on(stub, (owner) => ({ + run: owner.runRow(), + executions: owner.executionRows().length, + currentRootId: owner.currentRootId(), + journal: owner.journalRecords(), + published: owner.published(), + answers: owner.retainedAnswers(), + })); +} + +describe("a completed run replayed through its own owner", () => { + it("restores the retained result, and moves nothing but its own envelope", async () => { + const stub = executor(); + const host = lifecycleHost(stub); + + // The run, made and completed the way a runner makes one: one admitted + // connection, one begin, canonical execution over the owner's journal, one + // settlement. + const live = await run(function* (): Operation { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + if (!begun.ok) { + throw begun.error; + } + const rendered = yield* canonical( + begun.value.database, + retainedSource("README.md", DOCUMENT), + [ + retainedWorkflowInstallation({ + runId: RUN_ID, + base: CREATION.base, + pinnedCommit: COMMIT, + }), + ], + ); + const settled = yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status: "completed", + }); + if (!settled.ok) { + throw settled.error; + } + return rendered; + }); + }); + + expect(live.result.ok).toBe(true); + const before = await ownerState(stub); + expect(before.run?.["status"]).toBe("completed"); + expect(before.journal.length).toBeGreaterThan(0); + // The acquisition ended with its scope, so nothing holds the run. + expect(await on(stub, (owner) => owner.holders())).toBe(0); + + // A second connection, a resume, and the replay: everything it is held to + // comes from the frontier this owner answers with. + const replayed = await run(function* (): Operation { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + if (!begun.ok) { + throw begun.error; + } + // The run kept the outcome that already won. + expect(begun.value.replay).toBe(true); + const database = begun.value.database; + const frontier = yield* database.readJournalEntries(); + if (!frontier.ok) { + throw frontier.error; + } + const prepared = retainedReplay(begun.value.record, frontier.value); + if (!prepared.ok) { + throw prepared.error; + } + // The document canonical execution is handed comes out of the owner's + // own history, reported by the path the run record names. + expect(prepared.value.root).toEqual({ + path: "README.md", + source: DOCUMENT, + retained: true, + }); + const rendered = yield* canonical(database, prepared.value.root, [ + ...prepared.value.installations, + ]); + const settled = yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status: "completed", + }); + if (!settled.ok) { + throw settled.error; + } + return rendered; + }); + }); + + // Byte for byte, and the same result — reconstructed from the owner's own + // history, with no definition, checkout, Workspace or provider anywhere. + expect(replayed.output).toBe(live.output); + expect(replayed.result.ok).toBe(true); + expect(replayed.result.ok === true && replayed.result.value).toEqual( + live.result.ok === true ? live.result.value : undefined, + ); + + const after = await ownerState(stub); + // Every retained row, under the same identity, in the same order. + expect(after.journal).toEqual(before.journal); + expect(after.currentRootId).toBe(before.currentRootId); + expect(after.published).toEqual(before.published); + expect(after.answers).toEqual(before.answers); + expect(after.run?.["status"]).toBe("completed"); + // The one durable change: the lifecycle envelope this invocation recorded. + expect(after.executions).toBe(before.executions + 1); + // And it is closed, so the run is not left looking live. + expect(await on(stub, (owner) => owner.holders())).toBe(0); + }); + + it("replays a completed run its owner is asked to start again", async () => { + const stub = executor(); + const host = lifecycleHost(stub); + + const live = await run(function* (): Operation { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + if (!begun.ok) { + throw begun.error; + } + const rendered = yield* canonical( + begun.value.database, + retainedSource("README.md", DOCUMENT), + [runContract()], + ); + const settled = yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status: "completed", + }); + if (!settled.ok) { + throw settled.error; + } + return rendered; + }); + }); + + const before = await ownerState(stub); + expect(before.run?.["status"]).toBe("completed"); + + // The same creation named at the same run. The owner compares it with the + // immutable record it already holds and answers `replay`, exactly as it + // does for a resume — a caller supplying a candidate definition proves the + // two runs are the same run; it does not make the run live again. + const replayed = await run(function* (): Operation { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + if (!begun.ok) { + throw begun.error; + } + expect(begun.value.replay).toBe(true); + const frontier = yield* begun.value.database.readJournalEntries(); + if (!frontier.ok) { + throw frontier.error; + } + const prepared = retainedReplay(begun.value.record, frontier.value); + if (!prepared.ok) { + throw prepared.error; + } + const rendered = yield* canonical(begun.value.database, prepared.value.root, [ + ...prepared.value.installations, + ]); + const settled = yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status: "completed", + }); + if (!settled.ok) { + throw settled.error; + } + return rendered; + }); + }); + + expect(replayed.output).toBe(live.output); + expect(replayed.result.ok).toBe(true); + + const after = await ownerState(stub); + expect(after.journal).toEqual(before.journal); + expect(after.currentRootId).toBe(before.currentRootId); + expect(after.published).toEqual(before.published); + expect(after.answers).toEqual(before.answers); + expect(after.run?.["status"]).toBe("completed"); + expect(after.executions).toBe(before.executions + 1); + expect(await on(stub, (owner) => owner.holders())).toBe(0); + }); + + it("recovers a bundled run whose result committed and whose settlement did not", async () => { + const stub = executor(); + const host = lifecycleHost(stub); + + // The runner committed the document's result and its connection went + // before it settled. Nothing about time says so; the socket closing does. + const live = await run(function* (): Operation { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: BUNDLED, + }); + if (!begun.ok) { + throw begun.error; + } + return yield* canonical( + begun.value.database, + retainedSource("README.md", BUNDLED_DOCUMENT), + [runContract(), workflowBundleInstallation([{ ...STAGE, sourceHash: STAGE_HASH }])], + ); + }); + }); + + expect(live.result.ok).toBe(true); + expect(live.output).toContain("staged."); + const before = await ownerState(stub); + // The crash window: the outcome is committed and the lifecycle row is not. + expect(before.run?.["status"]).toBe("running"); + expect(before.executions).toBe(1); + expect(await on(stub, (owner) => owner.holders())).toBe(0); + + const replayed = await run(function* (): Operation { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + if (!begun.ok) { + throw begun.error; + } + // The owner's own transaction recognized the retained result, closed + // the stale execution and published the terminal it implies. + expect(begun.value.replay).toBe(true); + expect(begun.value.record.status).toBe("completed"); + const frontier = yield* begun.value.database.readJournalEntries(); + if (!frontier.ok) { + throw frontier.error; + } + const prepared = retainedReplay(begun.value.record, frontier.value); + if (!prepared.ok) { + throw prepared.error; + } + const rendered = yield* canonical(begun.value.database, prepared.value.root, [ + ...prepared.value.installations, + ]); + const settled = yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status: "completed", + }); + if (!settled.ok) { + throw settled.error; + } + return rendered; + }); + }); + + // Byte for byte, including the bundled component, from history alone. + expect(replayed.output).toBe(live.output); + expect(replayed.result.ok).toBe(true); + + const after = await ownerState(stub); + expect(after.journal).toEqual(before.journal); + expect(after.currentRootId).toBe(before.currentRootId); + expect(after.published).toEqual(before.published); + expect(after.answers).toEqual(before.answers); + expect(after.run?.["status"]).toBe("completed"); + // The stale envelope closed and one replay envelope opened and closed. + expect(after.executions).toBe(2); + expect(await on(stub, (owner) => owner.holders())).toBe(0); + }); + + it("recovers a run whose committed document result is a failure to failed", async () => { + const stub = executor(); + const host = lifecycleHost(stub); + + // The document failed and the connection went before anything settled. The + // coroutine returned, so its own settlement is `ok`; what it returned says + // the document failed, and that is what the run is. + const live = await run(function* (): Operation { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + if (!begun.ok) { + throw begun.error; + } + return yield* canonical(begun.value.database, retainedSource("README.md", FAILING), [ + runContract(), + ]); + }); + }); + + expect(live.result.ok).toBe(false); + expect(live.output).toContain("partial line."); + const before = await ownerState(stub); + expect(before.run?.["status"]).toBe("running"); + + const replayed = await run(function* (): Operation { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + // A resume is what the settled lifecycle refuses for a failed run, so + // the run is named again by the compatible start it was created with. + const begun = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + if (!begun.ok) { + throw begun.error; + } + // The owner's own recovery read the document's result, not the + // coroutine's settlement. + expect(begun.value.record.status).toBe("failed"); + expect(begun.value.replay).toBe(true); + const frontier = yield* begun.value.database.readJournalEntries(); + if (!frontier.ok) { + throw frontier.error; + } + const prepared = retainedReplay(begun.value.record, frontier.value); + if (!prepared.ok) { + throw prepared.error; + } + const rendered = yield* canonical(begun.value.database, prepared.value.root, [ + ...prepared.value.installations, + ]); + const settled = yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status: "failed", + reason: { kind: "host", code: DOCUMENT_FAILED }, + }); + if (!settled.ok) { + throw settled.error; + } + return rendered; + }); + }); + + // The same failure and the same partial output, from history alone. + expect(replayed.result.ok).toBe(false); + expect(replayed.output).toBe(live.output); + + const recovered = await ownerState(stub); + expect(recovered.run?.["status"]).toBe("failed"); + expect(recovered.journal).toEqual(before.journal); + expect(recovered.currentRootId).toBe(before.currentRootId); + expect(recovered.published).toEqual(before.published); + expect(recovered.answers).toEqual(before.answers); + expect(await on(stub, (owner) => owner.holders())).toBe(0); + }); + + it("refuses a lifecycle row its retained result contradicts, and moves nothing", async () => { + const stub = executor(); + const host = lifecycleHost(stub); + + // A run whose journal records that its root ended by raising, settled as + // though it had completed. The two cannot both be this run's outcome. + await run(function* () { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + if (!begun.ok) { + throw begun.error; + } + const { database } = begun.value; + const appended = yield* database.transact(function* (transaction) { + yield* transaction.journal.append({ + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { + status: "ok", + value: { kind: "repository", path: "README.md", content: DOCUMENT }, + }, + }); + // The members in the order the protocol's own parser rebuilds them. + // The owner requires a proposed event to serialize back to the exact + // bytes it was sent as, so a differently ordered record is refused + // while the command is still being read. + yield* transaction.journal.append({ + type: "close", + coroutineId: "root", + result: { status: "err", error: { message: "the executor died", name: "Error" } }, + }); + }); + if (!appended.ok) { + throw appended.error; + } + const settled = yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status: "completed", + }); + if (!settled.ok) { + throw settled.error; + } + }); + }); + + const before = await ownerState(stub); + expect(before.run?.["status"]).toBe("completed"); + + const refusal = await run(function* (): Operation { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + if (!begun.ok) { + throw begun.error; + } + const frontier = yield* begun.value.database.readJournalEntries(); + if (!frontier.ok) { + throw frontier.error; + } + const prepared = retainedReplay(begun.value.record, frontier.value); + if (prepared.ok) { + throw new Error("expected the contradictory retained state to be refused"); + } + return prepared.error.message; + }); + }); + + expect(refusal).toContain("describe different outcomes"); + // Nothing this run holds moved, and no replacement outcome was published: + // the one difference is the envelope the begin boundary had to insert, and + // the settled recovery closes exactly that. + const after = await ownerState(stub); + expect(after.journal).toEqual(before.journal); + expect(after.currentRootId).toBe(before.currentRootId); + expect(after.published).toEqual(before.published); + expect(after.answers).toEqual(before.answers); + expect(after.run?.["status"]).toBe("completed"); + expect(after.executions).toBe(before.executions + 1); + expect(await on(stub, (owner) => owner.holders())).toBe(0); + }); + + it("refuses every action over a terminal it cannot read, and moves nothing", async () => { + const stub = executor(); + const host = lifecycleHost(stub); + + // A run left `running`, with an execution nobody closed, over a root result + // this build cannot read. + await run(function* () { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + if (!begun.ok) { + throw begun.error; + } + const appended = yield* begun.value.database.transact(function* (transaction) { + yield* transaction.journal.append({ + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { + status: "ok", + value: { kind: "repository", path: "README.md", content: DOCUMENT }, + }, + }); + yield* transaction.journal.append({ + type: "close", + coroutineId: "root", + result: { status: "ok", value: { status: "err" } }, + }); + }); + if (!appended.ok) { + throw appended.error; + } + }); + }); + + const before = await ownerState(stub); + expect(before.run?.["status"]).toBe("running"); + expect(before.executions).toBe(1); + const heldBefore = await on(stub, (owner) => owner.executionRows()); + expect(heldBefore[0]?.["stopped_at"]).toBe(null); + + // Every action the owner offers over this run, refused by the owner itself. + // Cancellation first: it takes an acquisition of its own, so it cannot ask + // while another connection holds the run. + expect(await on(stub, (owner) => owner.holders())).toBe(0); + const cancelled = await run(function* (): Operation { + return yield* scoped(function* () { + yield* useRemoteLifecycle(host); + const outcome = yield* WorkflowLifecycle.operations.cancel(RUN_ID); + return outcome.ok ? "cancelled" : outcome.error.message; + }); + }); + const refusals = await run(function* (): Operation> { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const started = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + const resumed = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + return { + started: started.ok ? "admitted" : started.error.message, + resumed: resumed.ok ? "admitted" : resumed.error.message, + }; + }); + }); + + for (const said of [refusals["started"], refusals["resumed"], cancelled]) { + expect(String(said)).toContain("cannot read"); + } + + // Nothing at all changed: not the run row, not the journal, not the + // Workspace state, and not the execution the previous executor left open. + const after = await ownerState(stub); + expect(after).toEqual(before); + expect(await on(stub, (owner) => owner.executionRows())).toEqual(heldBefore); + expect(await on(stub, (owner) => owner.holders())).toBe(0); + }); + + it("refuses a recorded selection the retained document does not bear out", async () => { + /** Selections that are well formed and that no execution could have made. */ + const forged: Record = { + absentTarget: { + kind: "repository", + path: "README.md", + content: DOCUMENT, + target: "Missing", + }, + selectorOnly: { + kind: "target-failure", + path: "README.md", + content: DOCUMENT, + failure: { selector: "Missing" }, + }, + }; + + for (const [says, selection] of Object.entries(forged)) { + const stub = executor(); + const host = lifecycleHost(stub); + + await run(function* () { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + if (!begun.ok) { + throw begun.error; + } + const appended = yield* begun.value.database.transact(function* (transaction) { + yield* transaction.journal.append({ + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { status: "ok", value: selection }, + }); + yield* transaction.journal.append({ + type: "close", + coroutineId: "root", + result: { + status: "ok", + value: { status: "ok", output: "done.\n", value: "done.\n" }, + }, + }); + }); + if (!appended.ok) { + throw appended.error; + } + }); + }); + + const before = await ownerState(stub); + expect([says, before.run?.["status"]]).toEqual([says, "running"]); + const heldBefore = await on(stub, (owner) => owner.executionRows()); + + const refusals = await run(function* (): Operation> { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const started = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + const resumed = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + return { + started: started.ok ? "admitted" : started.error.message, + resumed: resumed.ok ? "admitted" : resumed.error.message, + }; + }); + }); + + for (const said of Object.values(refusals)) { + expect([says, String(said).includes("cannot read")]).toEqual([says, true]); + } + + // Recovery published nothing and no envelope was inserted for a run whose + // own record does not say what it selected. + expect([says, await ownerState(stub)]).toEqual([says, before]); + expect([says, await on(stub, (owner) => owner.executionRows())]).toEqual([says, heldBefore]); + expect([says, await on(stub, (owner) => owner.holders())]).toEqual([says, 0]); + } + }); + + it("refuses a root import no single execution recorded, and moves nothing", async () => { + const stub = executor(); + const host = lifecycleHost(stub); + + // A settled root, over a history in which the run's own import is claimed + // twice: once by the root, once by a child that could not have run it. + await run(function* () { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + if (!begun.ok) { + throw begun.error; + } + const appended = yield* begun.value.database.transact(function* (transaction) { + for (const coroutineId of ["root", "child"]) { + yield* transaction.journal.append({ + type: "yield", + coroutineId, + description: { type: "import_component", name: "__root__" }, + result: { + status: "ok", + value: { kind: "repository", path: "README.md", content: DOCUMENT }, + }, + }); + } + yield* transaction.journal.append({ + type: "close", + coroutineId: "root", + result: { + status: "ok", + value: { status: "ok", output: "done.\n", value: "done.\n" }, + }, + }); + }); + if (!appended.ok) { + throw appended.error; + } + }); + }); + + const before = await ownerState(stub); + expect(before.run?.["status"]).toBe("running"); + const heldBefore = await on(stub, (owner) => owner.executionRows()); + + // The terminal reads, and the history it settled cannot say what ran. + const refusals = await run(function* (): Operation> { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const started = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + const resumed = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + return { + started: started.ok ? "admitted" : started.error.message, + resumed: resumed.ok ? "admitted" : resumed.error.message, + }; + }); + }); + + for (const said of Object.values(refusals)) { + expect(String(said)).toContain("cannot read"); + } + + // Neither account of the import was chosen, and recovery published nothing. + const after = await ownerState(stub); + expect(after).toEqual(before); + expect(await on(stub, (owner) => owner.executionRows())).toEqual(heldBefore); + expect(await on(stub, (owner) => owner.holders())).toBe(0); + }); + + it("refuses a terminal row whose own journal it cannot read, and moves nothing", async () => { + const ended: readonly WorkflowRunStatus[] = ["completed", "failed"]; + for (const status of ended) { + const stub = executor(); + const host = lifecycleHost(stub); + + // A row that already says the run ended, over a result nothing can read. + await run(function* () { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + if (!begun.ok) { + throw begun.error; + } + const appended = yield* begun.value.database.transact(function* (transaction) { + yield* transaction.journal.append({ + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { + status: "ok", + value: { kind: "repository", path: "README.md", content: DOCUMENT }, + }, + }); + yield* transaction.journal.append({ + type: "close", + coroutineId: "root", + result: { status: "ok", value: { status: "err" } }, + }); + }); + if (!appended.ok) { + throw appended.error; + } + const settled = yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status, + ...(status === "failed" ? { reason: { kind: "host", code: DOCUMENT_FAILED } } : {}), + }); + if (!settled.ok) { + throw settled.error; + } + }); + }); + + const before = await ownerState(stub); + expect(before.run?.["status"]).toBe(status); + + const refusals = await run(function* (): Operation> { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const started = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + const resumed = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + return { + started: started.ok ? "admitted" : started.error.message, + resumed: resumed.ok ? "admitted" : resumed.error.message, + }; + }); + }); + + expect(String(refusals["started"])).toContain("cannot read"); + expect(String(refusals["resumed"])).toContain("cannot read"); + // A terminal row does not vouch for the journal beneath it: no replay + // envelope was inserted and nothing was published. + expect(await ownerState(stub)).toEqual(before); + expect(await on(stub, (owner) => owner.holders())).toBe(0); + } + }); + + it("hands the run to the next connection when the replay's own ends", async () => { + const stub = executor(); + const host = lifecycleHost(stub); + + await run(function* () { + return yield* scoped(function* () { + const transitions: WorkflowExecutionTransitions = yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "start", + creation: CREATION, + }); + if (!begun.ok) { + throw begun.error; + } + yield* canonical(begun.value.database, retainedSource("README.md", DOCUMENT), [ + retainedWorkflowInstallation({ + runId: RUN_ID, + base: CREATION.base, + pinnedCommit: COMMIT, + }), + ]); + const settled = yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status: "completed", + }); + if (!settled.ok) { + throw settled.error; + } + }); + }); + + const outcome = await run(function* () { + const first = yield* scoped(function* () { + yield* useRemoteLifecycle(host); + const lock = yield* acquired(); + return lock.runId; + }); + // The first acquisition is over. A replacement takes the run, which is + // what "the connection is the acquisition" means when the replay ends. + const second = yield* scoped(function* () { + yield* useRemoteLifecycle(host); + const taken = yield* WorkflowLifecycle.operations.acquireExecutor(RUN_ID); + return taken.ok ? taken.value.kind : "refused"; + }); + return { first, second }; + }); + + expect(outcome.first).toBe(RUN_ID); + expect(outcome.second).toBe("acquired"); + // Nothing the first connection did outlives it, and the run is still the + // completed run both acquisitions found. + expect((await on(stub, (owner) => owner.runRow()))?.["status"]).toBe("completed"); + expect(await on(stub, (owner) => owner.holders())).toBe(0); + }); +}); diff --git a/packages/workflow/tests/cloudflare/remote-storage.vitest.ts b/packages/workflow/tests/cloudflare/remote-storage.vitest.ts new file mode 100644 index 000000000..30f916d32 --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-storage.vitest.ts @@ -0,0 +1,367 @@ +/** + * Finding and creating a run on its own owner. + * + * Against a real Durable Object, because what is being claimed is that a + * creation either commits whole or leaves the object exactly as it was — and + * "whole" here means the schema, the run record, the starting Workspace and the + * pointer that selects it, written in one transaction the runtime either + * applies or does not. + */ + +import { env, runInDurableObject } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { call, run, type Operation } from "effection"; +import type { ExecutorObject } from "./support/executor-object.ts"; +import { POLICY, RUN_ID, VALID_CLAIMS } from "./support/executor-object.ts"; +import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; +import { cloudflareRunLink } from "../../src/cloudflare/client.ts"; +import { + type OwnerSocket, + type SocketListener, + useOwnerConnection, +} from "../../src/remote/client.ts"; +import type { CreateWorkflowRunRequest, WorkflowRunDatabase } from "../../src/storage/api.ts"; +import { WorkflowRunStorage } from "../../src/storage/api.ts"; +import { useRemoteRunStorage } from "../../src/remote/storage.ts"; +import type { RemoteWorkspaceLink } from "../../src/remote/database.ts"; +import { + WorkflowDatabaseCorruptError, + WorkflowRunConflictError, + WorkflowRunIdMismatchError, + WorkflowRunNotFoundError, +} from "../../src/storage/errors.ts"; +import type { Result } from "effection"; + +let unique = 0; +const NOW = 1_800_000_000; +let keys: TestKeys; + +beforeAll(async () => { + keys = await generateKeys(); +}); + +function executor() { + unique += 1; + return env.EXECUTOR.get(env.EXECUTOR.idFromName(`storage-${unique}-${Math.random()}`)); +} + +function on( + stub: ReturnType, + body: (instance: ExecutorObject) => T, +): Promise { + return runInDurableObject(stub, body); +} + +async function connect(stub: ReturnType): Promise { + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const token = await signToken(keys, { + ...VALID_CLAIMS, + iat: NOW - 10, + nbf: NOW - 10, + exp: NOW + 600, + }); + const response = await stub.fetch("https://owner.invalid/executor", { + headers: { + authorization: `Bearer ${token}`, + upgrade: "websocket", + "x-release": POLICY.release, + "x-run-id": RUN_ID, + }, + }); + const socket = response.webSocket; + if (socket === null) { + throw new Error(`expected an executor WebSocket, received ${response.status}`); + } + socket.accept(); + return socket; +} + +function ownerSocket(socket: WebSocket): OwnerSocket { + const listeners = new Map(); + return { + send: (data) => socket.send(data), + close: () => socket.close(), + addEventListener(type, listener) { + // The event's data is read out and handed over as the small shape the + // listener declares, rather than the runtime event being renamed into + // it: a message carries text, and every other kind carries nothing. + const forward: EventListener = (event) => { + const data: unknown = Reflect.get(event, "data"); + listener(typeof data === "string" ? { data } : {}); + }; + listeners.set(listener, forward); + socket.addEventListener(type, forward); + }, + removeEventListener(type, listener) { + const found = listeners.get(listener); + if (found !== undefined) { + socket.removeEventListener(type, found); + } + }, + }; +} + +function creation(overrides: Partial = {}): CreateWorkflowRunRequest { + return { + runId: RUN_ID, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + ...overrides, + }; +} + +/** + * The storage provider, installed the way a host installs one. + * + * Through `WorkflowRunStorage.operations` rather than by calling the adapter + * directly: what is under test is the provider a caller reaches, including + * that every failure comes back inside `Result` rather than escaping. + * + * One connection for the whole body. The connection owns the socket and closes + * it when its scope ends, so a second one over the same socket would be asking + * through a channel the first already ended. + */ +function installed(socket: WebSocket, body: () => Operation): Promise { + return run(function* () { + const connection = yield* useOwnerConnection(ownerSocket(socket)); + let identifier = 0; + yield* useRemoteRunStorage( + cloudflareRunLink(connection, () => `open-${(identifier += 1)}`, RUN_ID), + ); + return yield* body(); + }); +} + +function create(request: CreateWorkflowRunRequest): Operation> { + return WorkflowRunStorage.operations.create(request); +} + +function lookup(runId: string): Operation> { + return WorkflowRunStorage.operations.lookup(runId); +} + +/** + * The provider takes one value, and that value is the link. + * + * A type-level assertion because that is where the property lives: giving + * `useRemoteRunStorage` a second parameter — the opener/link pair this + * correction removed — stops this file compiling. A runtime check could not + * say anything about a shape that no longer exists. + */ +type OneCapability = + Parameters extends [RemoteWorkspaceLink] ? true : never; +const ONE_CAPABILITY: OneCapability = true; + +/** The error a refused result carries, having proved it refused at all. */ +function failure(result: Result): Error { + if (result.ok) { + throw new Error("expected a refused result"); + } + return result.error; +} + +describe("opening a run on its owner", () => { + it("creates once, and answers the same run for a compatible repeat", async () => { + const stub = executor(); + const socket = await connect(stub); + + const outcome = await installed(socket, function* () { + const first = yield* create(creation()); + // The same request again is the same run, not a second one. + const again = yield* create(creation()); + return { first: first.ok, again: again.ok }; + }); + expect(outcome).toEqual({ first: true, again: true }); + + const after = await on(stub, (owner) => owner.published()); + // The schema, the run, the starting Workspace and its pointer, together. + expect(after["roots"]).toBe(1); + expect(after["events"]).toEqual([]); + expect(await on(stub, (owner) => owner.runRow())).not.toBe(null); + }); + + it("names the exact immutable fields that differ, and none of their values", async () => { + const stub = executor(); + const socket = await connect(stub); + + const refusals = await installed(socket, function* () { + yield* create(creation()); + const reported: Error[] = []; + for (const differing of [ + creation({ base: "other-base-entirely" }), + creation({ props: { secret: "do-not-echo-me" } }), + creation({ + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "1".repeat(40), + rootDocumentPath: "README.md", + }, + }), + // Two at once: the list is what differs, in the order they are read. + creation({ base: "other-base-entirely", props: { secret: "do-not-echo-me" } }), + ]) { + reported.push(failure(yield* create(differing))); + } + return reported.map((error) => ({ + conflict: error instanceof WorkflowRunConflictError, + fields: error instanceof WorkflowRunConflictError ? error.fields : undefined, + text: String(error), + })); + }); + + expect(refusals.map((entry) => entry.conflict)).toEqual([true, true, true, true]); + expect(refusals.map((entry) => entry.fields)).toEqual([ + ["base"], + ["props"], + ["definition"], + ["base", "props"], + ]); + for (const entry of refusals) { + // What differs, never what it differs to, and nothing of the protocol. + expect(entry.text).not.toContain("do-not-echo-me"); + expect(entry.text).not.toContain("other-base-entirely"); + expect(entry.text).not.toContain("command:"); + } + // One run, exactly as the compatible creation left it. + expect((await on(stub, (owner) => owner.published()))["roots"]).toBe(1); + }); + + it("looks up without creating, and says so when there is nothing", async () => { + const stub = executor(); + const socket = await connect(stub); + + const seen = await installed(socket, function* () { + const absent = failure(yield* lookup(RUN_ID)); + // Asked before anything else, so what it reports is about an owner that + // has never held a run. + const pristine = yield* call(() => on(stub, (owner) => owner.objectCount())); + yield* create(creation()); + const found = yield* lookup(RUN_ID); + return { + absent: { missing: absent instanceof WorkflowRunNotFoundError, text: String(absent) }, + pristine, + found: found.ok ? found.value.record.status : "refused", + }; + }); + + expect(seen.absent.missing).toBe(true); + // Nothing of the private protocol crossed with it. + expect(seen.absent.text).not.toContain("command:"); + // A lookup that missed created nothing. + expect(seen.pristine).toBe(0); + expect(seen.found).toBe("running"); + }); + + it("says a run stored here is another run, without saying which", async () => { + const stub = executor(); + const socket = await connect(stub); + await installed(socket, () => create(creation())); + // Intact storage, holding somebody else's run. + const other = "6dktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; + await on(stub, (owner) => owner.retainAnotherRun(other)); + const before = await on(stub, (owner) => owner.runRow()); + + const again = await connect(stub); + const seen = await installed(again, function* () { + const looked = failure(yield* lookup(RUN_ID)); + const created = failure(yield* create(creation())); + return [looked, created].map((error) => ({ + mismatch: error instanceof WorkflowRunIdMismatchError, + damaged: error instanceof WorkflowDatabaseCorruptError, + text: String(error), + })); + }); + + for (const entry of seen) { + // Not this run, and not damage. + expect([entry.mismatch, entry.damaged]).toEqual([true, false]); + // The retained id belongs to that other run and does not travel. + expect(entry.text).not.toContain(other); + expect(entry.text).not.toContain("command:"); + } + // Nothing was written on the way to either answer. + expect(await on(stub, (owner) => owner.runRow())).toEqual(before); + }); + + it("initializes nothing into storage that is not this build's", async () => { + const stub = executor(); + await on(stub, (owner) => owner.holdForeignObject()); + const socket = await connect(stub); + const refused = await installed(socket, function* () { + const outcome = yield* create(creation()); + return outcome.ok ? "it was allowed" : String(outcome.error); + }); + // A storage condition, inside `Result`, with nothing private in it. + expect(refused).not.toBe("it was allowed"); + expect(refused).not.toContain("storage:"); + // Foreign, and left exactly as it was found. + expect(await on(stub, (owner) => owner.hasWorkflowSchema())).toBe(false); + }); + + it("refuses every operation once the provider scope has closed", async () => { + const stub = executor(); + const socket = await connect(stub); + const held = await installed(socket, function* () { + const opened = yield* create(creation()); + return opened.ok ? opened.value : undefined; + }); + if (held === undefined) { + throw new Error("expected a database"); + } + // The handle outlived the scope that opened it; what it names did not, and + // it does not reconnect. + const late = await run(() => held.replaceRetrievalMetadata({ a: 1 })); + expect(late.ok).toBe(false); + }); + + it("cannot open through one owner and answer with the other", async () => { + expect(ONE_CAPABILITY).toBe(true); + // Two owners that look alike: same public run id, same compatible + // creation, identical retained state. Only the objects differ. + const first = executor(); + const second = executor(); + const socketA = await connect(first); + const socketB = await connect(second); + await installed(socketA, () => create(creation())); + await installed(socketB, () => create(creation())); + + // There is one value to install, and it came from one connection. A + // provider built on A's link reads and commits through A, whatever B + // holds — there is no second argument to give it B's. + const again = await connect(first); + const seen = await run(function* () { + const connection = yield* useOwnerConnection(ownerSocket(again)); + let identifier = 0; + yield* useRemoteRunStorage( + cloudflareRunLink(connection, () => `pair-${(identifier += 1)}`, RUN_ID), + ); + const opened = yield* lookup(RUN_ID); + if (!opened.ok) { + return "refused"; + } + const written = yield* opened.value.transact(function* (transaction) { + yield* transaction.journal.append({ + type: "yield", + coroutineId: "root", + description: { type: "test", name: "written" }, + result: { status: "ok", value: "written" }, + }); + }); + return written.ok ? "appended" : "refused"; + }); + expect(seen).toBe("appended"); + + // The append landed on the owner whose link was installed, and nowhere else. + expect((await on(first, (owner) => owner.published()))["events"]).toHaveLength(1); + expect((await on(second, (owner) => owner.published()))["events"]).toEqual([]); + }); +}); diff --git a/packages/workflow/tests/cloudflare/remote-workspace.vitest.ts b/packages/workflow/tests/cloudflare/remote-workspace.vitest.ts new file mode 100644 index 000000000..3106b56b0 --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-workspace.vitest.ts @@ -0,0 +1,279 @@ +/** + * The runner's Workspace coordinator, against a real owner. + * + * Everything on the owner's side is real here: a real Durable Object, its own + * SQLite storage, a real accepted Hibernation WebSocket, and the production + * client, database handle, run binding and coordinator on the other end of it. + * What runs is `createRemoteWorkspaceEffect()` through + * `withRemoteWorkspaceEffects()`, so the admission read, the attempt, the + * anchor check, the mapping staging, the enlistment, the journal route and + * D3a's atomic commit are all the production path. + * + * The one stand-in is the runner's host filesystem: workerd has none, and the + * vendored DOFS cannot set a modification time, so it cannot reproduce a + * retained mtime — which is the thing materialization refuses a host for. The + * native adapter it stands in for is proved against real files in + * `packages/workflow/tests/remote-workspace-files.test.ts`. + */ + +import { env, runInDurableObject } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { type Workflow, type Json } from "@executablemd/durable-streams"; +import { run, type Operation } from "effection"; +import type { ExecutorObject } from "./support/executor-object.ts"; +import { POLICY, RUN_ID, VALID_CLAIMS } from "./support/executor-object.ts"; +import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; +import { createWorkerFiles } from "./support/worker-files.ts"; +import { cloudflareReadLink, cloudflareRunLink } from "../../src/cloudflare/client.ts"; +import { + type OwnerSocket, + type SocketListener, + useOwnerConnection, +} from "../../src/remote/client.ts"; +import { + createRemoteWorkspaceEffect, + type RemoteRun, + useRemoteRun, + useRemoteWorkspaceEffects, + withRemoteWorkspaceEffects, +} from "../../src/remote/workspace.ts"; +import { durableRun } from "@executablemd/durable-streams"; +import { locatorFingerprintOf } from "../../src/composition/locator.ts"; +import { useMaterialization } from "../../src/remote/invocation.ts"; +import { JournaledEffectFailure } from "../../src/workspace/failure.ts"; + +let unique = 0; +const NOW = 1_800_000_000; +const LOCATOR = "https://git.example.invalid/octo/app.git"; +let keys: TestKeys; + +beforeAll(async () => { + keys = await generateKeys(); +}); + +function executor() { + unique += 1; + return env.EXECUTOR.get(env.EXECUTOR.idFromName(`coordinated-${unique}-${Math.random()}`)); +} + +function on( + stub: ReturnType, + body: (instance: ExecutorObject) => T, +): Promise { + return runInDurableObject(stub, body); +} + +async function connect(stub: ReturnType): Promise { + await on(stub, (owner) => owner.configure([{ kid: keys.kid, jwk: keys.publicJwk }], NOW)); + const token = await signToken(keys, { + ...VALID_CLAIMS, + iat: NOW - 10, + nbf: NOW - 10, + exp: NOW + 600, + }); + const response = await stub.fetch("https://owner.invalid/executor", { + headers: { + authorization: `Bearer ${token}`, + upgrade: "websocket", + "x-release": POLICY.release, + "x-run-id": RUN_ID, + }, + }); + const socket = response.webSocket; + if (socket === null) { + throw new Error(`expected an executor WebSocket, received ${response.status}`); + } + socket.accept(); + return socket; +} + +/** The platform socket, bound to the four members the client uses. */ +function ownerSocket(socket: WebSocket): OwnerSocket { + const listeners = new Map(); + return { + send: (data) => socket.send(data), + close: () => socket.close(), + addEventListener(type, listener) { + const forward: EventListener = (event) => listener(event as { data?: unknown }); + listeners.set(listener, forward); + socket.addEventListener(type, forward); + }, + removeEventListener(type, listener) { + const found = listeners.get(listener); + if (found !== undefined) { + socket.removeEventListener(type, found); + } + }, + }; +} + +function repository() { + return { + record: { + name: "app", + locatorFingerprint: locatorFingerprintOf(LOCATOR), + requestedBase: null, + creationCommit: "9".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + checkoutPath: "/app", + }, + locator: LOCATOR, + }; +} + +/** + * Open one production run binding over a real accepted socket. + * + * Everything the coordinator will use comes from here, together: the client, + * the handle, the runtime and the routed journal. + */ +function* opened(socket: WebSocket): Operation { + const connection = yield* useOwnerConnection(ownerSocket(socket)); + let identifier = 0; + const next = () => `coordinated-${(identifier += 1)}`; + const host = createWorkerFiles(); + return yield* useRemoteRun({ + link: cloudflareRunLink(connection, next, RUN_ID), + files: host.files, + trees: host.trees, + createFilesystem: (at) => host.workspace(at("/")), + }); +} + +describe("the coordinator against a real owner", () => { + it("publishes Files, one mapping and the filtered result as one commit", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const before = await on(stub, (owner) => owner.published()); + const socket = await connect(stub); + + const outcome = await run(function* () { + const opening = yield* opened(socket); + yield* useRemoteWorkspaceEffects(opening); + const effect = createRemoteWorkspaceEffect( + opening, + { type: "workspace", name: "write" }, + function* (filesystem, metadata): Operation { + yield* filesystem.writeFile("/NOTES.md", "written by the effect\n", 0o644); + // The checkout the Repository record names has to be in the Workspace + // this proposal publishes; the owner refuses a mapping to a place the + // root does not contain. + yield* filesystem.mkdir("/app", { mode: 0o755 }); + metadata.insertRepository(repository()); + return "published"; + }, + ); + function* workflow(): Workflow { + yield effect; + } + yield* withRemoteWorkspaceEffects(opening, durableRun(workflow, { stream: opening.journal })); + return yield* opening.journal.readAll(); + }); + // The result travelled inside the commit rather than beside it: the run's + // journal — which is this owner's — holds the effect's row exactly once, + // and the owner's own row below shows it was written by the transaction + // that moved the root. + expect(outcome.filter((event) => event.type === "yield")).toHaveLength(1); + + const after = await on(stub, (owner) => owner.published()); + // Content, root, references, mapping, pointer and the journal row moved + // together, and the pointer is no longer where it started. + expect(after["currentRootId"]).not.toBe(before["currentRootId"]); + expect(after["roots"]).toBe(2); + expect(after["repositories"]).toEqual([{ name: "app", checkout_path: "/app" }]); + // Both rows the run wrote name the published root: the effect's own result, + // committed by the transaction that moved the pointer, and the terminal the + // completed run appended afterwards against the root it ended on. + expect(after["events"]).toEqual([ + expect.objectContaining({ workspace_root_id: after["currentRootId"] }), + expect.objectContaining({ workspace_root_id: after["currentRootId"] }), + ]); + + // A fresh admitted invocation, through the production read link: the owner + // answers with the new root, the new anchor and the retained mapping + // together, and that root materializes to the bytes the effect wrote. + const second = await connect(stub); + const observed = await run(function* () { + const connection = yield* useOwnerConnection(ownerSocket(second)); + let identifier = 0; + const next = () => `observe-${(identifier += 1)}`; + const reads = cloudflareReadLink(connection, next, RUN_ID); + const snapshot = yield* reads.invocationSnapshot(); + const host = createWorkerFiles(); + const materialization = yield* useMaterialization( + host.files, + host.trees, + reads, + snapshot.workspaceRootId, + (reason) => { + throw new Error(reason); + }, + ); + const workspace = host.workspace(materialization.at("/")); + return { + workspaceRootId: snapshot.workspaceRootId, + journalEventId: snapshot.journalEventId, + repositories: snapshot.repositories.map((stored) => stored.record.name), + notes: yield* workspace.readTextFile("/NOTES.md"), + }; + }); + expect(observed.workspaceRootId).toBe(after["currentRootId"]); + expect(typeof observed.journalEventId).toBe("string"); + expect(observed.repositories).toEqual(["app"]); + expect(observed.notes).toBe("written by the effect\n"); + }); + + it("commits only the filtered failed result, and moves nothing else", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + const before = await on(stub, (owner) => owner.published()); + const socket = await connect(stub); + + await run(function* () { + const opening = yield* opened(socket); + yield* useRemoteWorkspaceEffects(opening); + const effect = createRemoteWorkspaceEffect( + opening, + { type: "workspace", name: "refuse" }, + function* (filesystem, metadata): Operation { + yield* filesystem.writeFile("/SCRATCH.md", "discarded\n", 0o644); + yield* filesystem.mkdir("/app", { mode: 0o755 }); + metadata.insertRepository(repository()); + throw new DocumentedFailure("this Workspace effect refused"); + }, + ); + function* workflow(): Workflow { + yield effect; + } + try { + yield* withRemoteWorkspaceEffects( + opening, + durableRun(workflow, { stream: opening.journal }), + ); + } catch { + // The documented failure is the run's outcome; what it left behind is + // the claim being made. + } + }); + + const after = await on(stub, (owner) => owner.published()); + // The pointer did not move, no second root was retained, and the mapping + // the effect staged never became one. + expect(after["currentRootId"]).toBe(before["currentRootId"]); + expect(after["roots"]).toBe(before["roots"]); + expect(after["repositories"]).toEqual([]); + // Two rows, and both name the root the run is still on: the effect's own + // filtered failure, committed in its transaction, and the terminal the + // failed run appended afterwards. Neither moved the Workspace. + expect(after["events"]).toEqual([ + expect.objectContaining({ workspace_root_id: before["currentRootId"] }), + expect.objectContaining({ workspace_root_id: before["currentRootId"] }), + ]); + }); +}); + +/** A refusal the effect publishes rather than raises, as a document's would be. */ +class DocumentedFailure extends JournaledEffectFailure { + override name = "DocumentedFailure"; +} diff --git a/packages/workflow/tests/cloudflare/settle-parser.vitest.ts b/packages/workflow/tests/cloudflare/settle-parser.vitest.ts new file mode 100644 index 000000000..d8b7954ef --- /dev/null +++ b/packages/workflow/tests/cloudflare/settle-parser.vitest.ts @@ -0,0 +1,169 @@ +/** + * The settle request, parsed inside a real Worker. + * + * The point of running this on workerd rather than portably is the import + * graph. `parseDocumentExecutionCompletion()` is shared code that reaches + * `canonicalize` and two spelling predicates in `@executablemd/core`, and until + * those were published from node-free subpaths that graph pulled `node:crypto` + * and `node:process` — which typechecks anywhere except the runtime that has to + * run it. A test that only proved the parser worked would have proved nothing + * about that; this one loads it where a Node builtin is genuinely absent. + * + * The owner's revalidation of acquisition, root and execution belongs to the + * checkpoint where a lifecycle transaction exists. What is asserted here is the + * private request contract: what a settle command has to be to be read at all. + */ + +import { describe, expect, it } from "vitest"; +import { CommandError, parseCommand } from "../../src/cloudflare/commands.ts"; + +const ROOT = "9f2c4b6a8d0e1f23456789abcdef0123456789abcdef0123456789abcdef0123"; + +function settle(overrides: Record = {}): string { + return JSON.stringify({ + id: "s1", + command: "settle", + completion: { executionId: "execution-1", status: "completed" }, + expectedWorkspaceRootId: ROOT, + ...overrides, + }); +} + +/** The refusal category, or the command name when it was read. */ +function read(raw: string): string { + try { + return parseCommand(raw).command; + } catch (error) { + return error instanceof CommandError ? error.refusal : "unexpected"; + } +} + +describe("a settle command", () => { + it("reads a complete completion through the shared parser", () => { + const command = parseCommand(settle()); + expect(command.command).toBe("settle"); + if (command.command !== "settle") { + throw new Error("expected a settle command"); + } + expect(command.completion).toEqual({ executionId: "execution-1", status: "completed" }); + expect(command.expectedWorkspaceRootId).toBe(ROOT); + }); + + it("reads a completion carrying a stop reason", () => { + const command = parseCommand( + settle({ + completion: { + executionId: "execution-1", + status: "failed", + reason: { kind: "host", code: "settlement-refused" }, + }, + }), + ); + if (command.command !== "settle") { + throw new Error("expected a settle command"); + } + expect(command.completion.reason).toEqual({ kind: "host", code: "settlement-refused" }); + }); + + it("refuses a completion the shared parser will not read", () => { + // Each of these is refused by the shared contract rather than by a private + // approximation of it, and each becomes this transport's own closed + // refusal rather than carrying the parser's message onto the wire. + expect(read(settle({ completion: { status: "completed" } }))).toBe("malformed-member"); + expect(read(settle({ completion: { executionId: "", status: "completed" } }))).toBe( + "malformed-member", + ); + expect(read(settle({ completion: { executionId: "e", status: "invented" } }))).toBe( + "malformed-member", + ); + expect(read(settle({ completion: { executionId: "e", status: "failed", reason: 7 } }))).toBe( + "malformed-member", + ); + expect(read(settle({ completion: "not an object" }))).toBe("malformed-member"); + expect(read(settle({ completion: undefined }))).toBe("malformed-member"); + }); + + it("refuses a missing or malformed expected root", () => { + expect(read(settle({ expectedWorkspaceRootId: undefined }))).toBe("malformed-member"); + expect(read(settle({ expectedWorkspaceRootId: "" }))).toBe("malformed-member"); + expect(read(settle({ expectedWorkspaceRootId: 1 }))).toBe("malformed-member"); + }); + it("refuses a member the command does not declare", () => { + expect(read(settle({ status: "completed" }))).toBe("unknown-member"); + expect(read(settle({ somethingElse: true }))).toBe("unknown-member"); + }); +}); + +/** + * A retained Workspace root is a content identity, and every command that names + * one names the same thing. A command shape that admitted "any non-empty text" + * would let a request select a root by a spelling the store can never hold, and + * would let two commands disagree about what a root is. + * + * Shape only. Whether a well-spelled root is the one this run is actually at is + * the owner's revalidation, in the checkpoint that has a lifecycle to check it + * against. + */ +describe("a root identity in a command", () => { + const wrong: Record = { + "one character short": ROOT.slice(1), + "one character long": `${ROOT}0`, + "uppercase hexadecimal": ROOT.toUpperCase(), + "hexadecimal with a non-hexadecimal letter": `${ROOT.slice(0, 63)}z`, + "a plausible-looking name": "root-a", + "not text at all": 7, + }; + + /** Every root field in the private command shapes, by the request it sits in. */ + const fields: Record string> = { + "root.workspaceRootId": (root) => + JSON.stringify({ id: "m1", command: "root", workspaceRootId: root }), + "content.workspaceRootId": (root) => + JSON.stringify({ + id: "r1", + command: "content", + workspaceRootId: root, + kind: "blob", + digest: ROOT, + sourceManifest: ROOT, + }), + "commit.expectedWorkspaceRootId": (root) => commit({ expectedWorkspaceRootId: root }), + "commit.publication.proposedWorkspaceRootId": (root) => + commit({ + publication: { proposedWorkspaceRootId: root, proposedManifest: "{}", content: [] }, + }), + "settle.expectedWorkspaceRootId": (root) => settle({ expectedWorkspaceRootId: root }), + }; + + function commit(overrides: Record): string { + return JSON.stringify({ + id: "c1", + command: "commit", + expectedWorkspaceRootId: ROOT, + expectedJournalEventId: null, + publication: { proposedWorkspaceRootId: ROOT, proposedManifest: "{}", content: [] }, + mappings: [], + events: [], + answer: null, + ...overrides, + }); + } + + it("reads the canonical spelling in every command that names one", () => { + for (const [field, request] of Object.entries(fields)) { + expect([field, read(request(ROOT))]).toEqual([field, field.split(".")[0]]); + } + }); + + it("refuses anything that is not the canonical spelling", () => { + for (const [field, request] of Object.entries(fields)) { + for (const [description, root] of Object.entries(wrong)) { + expect([field, description, read(request(root))]).toEqual([ + field, + description, + "malformed-member", + ]); + } + } + }); +}); diff --git a/packages/workflow/tests/cloudflare/storage-capabilities.vitest.ts b/packages/workflow/tests/cloudflare/storage-capabilities.vitest.ts new file mode 100644 index 000000000..ddbe22c9c --- /dev/null +++ b/packages/workflow/tests/cloudflare/storage-capabilities.vitest.ts @@ -0,0 +1,70 @@ +/** + * What a Durable Object's SQLite actually permits. + * + * The Cloudflare owner is built on these answers: the schema marker exists + * because the pragmas are refused, and the owner opens exactly one real + * transaction and enlists DOFS directly inside it because a reentrant + * transaction is refused. Both are properties of the runtime rather than of any + * model of it, so they are asserted here against real workerd — a platform + * change that moved either one should fail this suite rather than be discovered + * as a corrupted run. + * + * The assertions match categories, not the platform's wording: the exact + * sentence a runtime uses to refuse is not a contract, and pinning it would + * make this fail for a rephrasing. + */ + +import { env, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import type { StorageProbeObject } from "./support/probe-object.ts"; + +function capabilities() { + const stub = env.STORAGE_PROBE.get(env.STORAGE_PROBE.idFromName("capabilities")); + return runInDurableObject(stub, (instance: StorageProbeObject) => instance.capabilities()); +} + +/** A refusal, whatever the runtime called it. */ +function refused(answer: string): boolean { + return answer.startsWith("refused:"); +} + +/** A refusal the runtime attributed to its authorization layer. */ +function unauthorized(answer: string): boolean { + return refused(answer) && answer.includes("SQLITE_AUTH"); +} + +/** A refusal directing the caller to the storage transaction API. */ +function transactionApiRequired(answer: string): boolean { + return refused(answer) && answer.includes("transactionSync"); +} + +describe("Durable Object SQLite storage", () => { + it("refuses the pragmas the Deno host carries its schema identity in", async () => { + const found = await capabilities(); + expect(unauthorized(found.applicationIdRead)).toBe(true); + expect(unauthorized(found.applicationIdWrite)).toBe(true); + expect(unauthorized(found.userVersionRead)).toBe(true); + expect(unauthorized(found.userVersionWrite)).toBe(true); + }); + + it("refuses SQL transaction statements, directly and through a nested wrapper", async () => { + const found = await capabilities(); + expect(transactionApiRequired(found.savepointDirect)).toBe(true); + expect(transactionApiRequired(found.nestedTransaction)).toBe(true); + // The one that decides the owner's commit shape: the vendored DOFS opens a + // transaction of its own for a filesystem write, so calling it inside an + // owner transaction is a reentrant call and is refused. + expect(transactionApiRequired(found.filesystemInsideTransaction)).toBe(true); + }); + + it("accepts what the owner is built on instead", async () => { + const found = await capabilities(); + expect(refused(found.schemaObjects)).toBe(false); + expect(refused(found.outerTransaction)).toBe(false); + expect(refused(found.xmdTableDdl)).toBe(false); + expect(refused(found.dofsSchema)).toBe(false); + expect(refused(found.dofsFilesystem)).toBe(false); + // A strict metadata table is what carries the identity the pragmas cannot. + expect(found.metadataTable).toContain("application_id"); + }); +}); diff --git a/packages/workflow/tests/cloudflare/support/executor-object.ts b/packages/workflow/tests/cloudflare/support/executor-object.ts new file mode 100644 index 000000000..467e4d250 --- /dev/null +++ b/packages/workflow/tests/cloudflare/support/executor-object.ts @@ -0,0 +1,936 @@ +/** + * A concrete owner, so admission and acquisition can be exercised end to end. + * + * It supplies the two things `WorkflowOwnerObject` leaves abstract — a policy + * and a `perform` — and nothing else. `perform` answers with the command it was + * given rather than doing durable work: what these tests are about is who is + * allowed to send one, not what each one means. + */ + +import { run } from "effection"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import { acquisitionHolders } from "../../../src/cloudflare/acquisition.ts"; +import { WorkflowOwnerObject } from "../../../src/cloudflare/owner.ts"; +import type { AdmissionRequest, OwnerConfiguration } from "../../../src/cloudflare/owner.ts"; +import type { AdmissionPolicy } from "../../../src/cloudflare/admission.ts"; +import type { TokenVerification, VerificationKey } from "../../../src/cloudflare/token.ts"; +import { refusalOf } from "../../../src/cloudflare/owner.ts"; +import { sha256Hex } from "../../../src/workspace/sha256.ts"; +import { WORKSPACE_ROOT_DOMAIN } from "../../../src/workspace/root-manifest.ts"; +import { + COMMAND_TABLE, + FORK_TABLE, + HOLD_TABLE, + MUTATION_TABLE, + STAGING_TABLE, +} from "../../../src/cloudflare/private-schema.ts"; +import { MARKER_TABLE } from "../../../src/cloudflare/marker.ts"; +import { routeOf } from "../../../src/cloudflare/routes.ts"; + +/** The identities this owner is configured to admit. */ +export const POLICY: AdmissionPolicy = { + issuer: "https://token.actions.githubusercontent.com", + audience: "https://factory.example", + repositoryId: "123456", + repositoryOwnerId: "654321", + eventName: "repository_dispatch", + workflowRef: "octo/repo/.github/workflows/factory.yml@refs/heads/main", + workflowSha: "0f2c9a1b3d4e5f60718293a4b5c6d7e8f9012345", + jobWorkflowRef: "octo/repo/.github/workflows/factory.yml@refs/heads/main", + release: "factory-2026.09.02-abcdef", +}; + +/** The claims a correctly issued token carries for the policy above. */ +export const VALID_CLAIMS: Record = { + iss: POLICY.issuer, + aud: POLICY.audience, + repository_id: POLICY.repositoryId, + repository_owner_id: POLICY.repositoryOwnerId, + event_name: POLICY.eventName, + workflow_ref: POLICY.workflowRef, + workflow_sha: POLICY.workflowSha, + job_workflow_ref: POLICY.jobWorkflowRef, +}; + +export const RUN_ID = "5cktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; +export const FILE_BYTES = new TextEncoder().encode("hello from the retained Workspace"); +export const BLOB_ID = sha256Hex(FILE_BYTES); +export const DOFS_MANIFEST = JSON.stringify({ + version: 1, + chunks: [{ hash: BLOB_ID, size: FILE_BYTES.length }], +}); +export const MANIFEST_ID = sha256Hex(new TextEncoder().encode(DOFS_MANIFEST)); +export const ROOT_MANIFEST = JSON.stringify({ + format: 1, + entries: [ + { path: "/", kind: "directory", mode: 493, mtime: 0 }, + { + path: "/README.md", + kind: "file", + mode: 420, + mtime: 0, + size: FILE_BYTES.length, + manifest: MANIFEST_ID, + hardlink: null, + }, + // Two directories a checkout can live in, so a retained Repository and a + // retained Worktree can qualify without sharing one. + { path: "/checkouts", kind: "directory", mode: 493, mtime: 0 }, + { path: "/work", kind: "directory", mode: 493, mtime: 0 }, + ], +}); +export const ROOT_ID = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${ROOT_MANIFEST}`); + +/** A second root: the same tree with one more file, as a proposal would be. */ +export const NEXT_BYTES = new TextEncoder().encode("published by the runner"); +export const NEXT_BLOB_ID = sha256Hex(NEXT_BYTES); +export const NEXT_DOFS_MANIFEST = JSON.stringify({ + version: 1, + chunks: [{ hash: NEXT_BLOB_ID, size: NEXT_BYTES.length }], +}); +export const NEXT_MANIFEST_ID = sha256Hex(new TextEncoder().encode(NEXT_DOFS_MANIFEST)); +export const NEXT_ROOT_MANIFEST = JSON.stringify({ + format: 1, + entries: [ + { path: "/", kind: "directory", mode: 493, mtime: 0 }, + { + path: "/NOTES.md", + kind: "file", + mode: 420, + mtime: 0, + size: NEXT_BYTES.length, + manifest: NEXT_MANIFEST_ID, + hardlink: null, + }, + { + path: "/README.md", + kind: "file", + mode: 420, + mtime: 0, + size: FILE_BYTES.length, + manifest: MANIFEST_ID, + hardlink: null, + }, + // The checkout a Repository mapping claims, in canonical byte order — a + // mapping whose directory the proposed root does not contain is a record + // about files nobody wrote. + { path: "/app", kind: "directory", mode: 493, mtime: 0 }, + ], +}); +export const NEXT_ROOT_ID = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${NEXT_ROOT_MANIFEST}`); + +/** + * The proposal that publishes `NEXT_ROOT_ID`. + * + * Its inventory is the exact closure of the proposed manifest: both file + * manifests and both blobs, once each, in canonical order. One of each is + * already authoritative, which is what proves the owner reuses retained content + * by identity rather than requiring it to be sent again. + */ +export function nextPublication(): Record { + const content: { kind: string; digest: string; size: number }[] = [ + { kind: "blob", digest: BLOB_ID, size: FILE_BYTES.length }, + { kind: "blob", digest: NEXT_BLOB_ID, size: NEXT_BYTES.length }, + { kind: "manifest", digest: MANIFEST_ID, size: DOFS_MANIFEST.length }, + { kind: "manifest", digest: NEXT_MANIFEST_ID, size: NEXT_DOFS_MANIFEST.length }, + ]; + content.sort((left, right) => + `${left.kind}:${left.digest}` < `${right.kind}:${right.digest}` ? -1 : 1, + ); + return { + proposedWorkspaceRootId: NEXT_ROOT_ID, + proposedManifest: NEXT_ROOT_MANIFEST, + content, + }; +} +const CREATED_AT = "2026-09-03T00:00:00.000Z"; + +export class ExecutorObject extends WorkflowOwnerObject { + /** + * The verification material this owner is configured with. + * + * Installed by a test before it connects, exactly as a deployment would + * install a fetched JWKS. It is closure state on the object, never something + * an admission request can name. + */ + #keys: VerificationKey[] = []; + /** Client ends of the admitted pairs, held so they are not collected. */ + readonly #clients: WebSocket[] = []; + #now = 1_800_000_000; + #skew = 0; + + configure(keys: VerificationKey[], now?: number, skew?: number): void { + this.#keys = keys; + if (now !== undefined) { + this.#now = now; + } + if (skew !== undefined) { + this.#skew = skew; + } + } + + protected configuration(): OwnerConfiguration { + const verification: TokenVerification = { + keys: this.#keys, + skewSeconds: this.#skew, + now: () => this.#now, + }; + return { policy: POLICY, verification }; + } + + initialize(): void { + this.open(RUN_ID, () => { + const blob = hexBytes(BLOB_ID); + const manifest = hexBytes(MANIFEST_ID); + this.ctx.storage.sql.exec( + "INSERT INTO vfs_blobs (hash, size, last_seen) VALUES (?, ?, 0)", + blob, + FILE_BYTES.length, + ); + this.ctx.storage.sql.exec( + "INSERT INTO vfs_blob_bytes (hash, bytes) VALUES (?, ?)", + blob, + new Uint8Array(FILE_BYTES), + ); + this.ctx.storage.sql.exec( + "INSERT INTO vfs_manifests (hash, size, encoded, last_seen) VALUES (?, ?, ?, 0)", + manifest, + FILE_BYTES.length, + new TextEncoder().encode(DOFS_MANIFEST), + ); + this.ctx.storage.sql.exec( + "INSERT INTO workspace_roots (root_id, format_version, manifest) VALUES (?, 1, ?)", + ROOT_ID, + ROOT_MANIFEST, + ); + this.ctx.storage.sql.exec( + "INSERT INTO workspace_root_manifest_refs (root_id, manifest_hash) VALUES (?, ?)", + ROOT_ID, + manifest, + ); + this.ctx.storage.sql.exec( + "INSERT INTO workspace_root_blob_refs (root_id, blob_hash) VALUES (?, ?)", + ROOT_ID, + blob, + ); + this.ctx.storage.sql.exec( + "INSERT INTO workspace_state (singleton_id, current_root_id) VALUES (1, ?)", + ROOT_ID, + ); + this.ctx.storage.sql.exec( + `INSERT INTO workflow_run + (id, run_id, definition, base, props, status, created_at, updated_at) + VALUES (1, ?, ?, ?, ?, 'running', ?, ?)`, + RUN_ID, + JSON.stringify({ + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }), + "main", + "{}", + CREATED_AT, + CREATED_AT, + ); + this.ctx.storage.sql.exec( + "INSERT INTO definition_retrieval (id, metadata, revision, updated_at) VALUES (1, ?, 1, ?)", + JSON.stringify({ locator: "https://example.invalid/repository.git" }), + CREATED_AT, + ); + }); + } + + appendJournal(eventId: string, name: string): void { + this.ctx.storage.sql.exec( + "INSERT INTO journal_events (event_id, record, workspace_root_id) VALUES (?, ?, ?)", + eventId, + serializeDurableEvent({ + type: "yield", + coroutineId: "root", + description: { type: "test", name }, + result: { status: "ok", value: name }, + }), + ROOT_ID, + ); + } + + scratch(): { commands: number; staged: number } { + const commands = this.ctx.storage.sql + .exec(`SELECT count(*) AS count FROM ${COMMAND_TABLE}`) + .toArray()[0]?.["count"]; + const staged = this.ctx.storage.sql + .exec(`SELECT count(*) AS count FROM ${STAGING_TABLE}`) + .toArray()[0]?.["count"]; + return { + commands: typeof commands === "number" ? commands : -1, + staged: typeof staged === "number" ? staged : -1, + }; + } + + /** + * Everything this run authoritatively holds, as one comparable value. + * + * Row-for-row rather than a count: cleanup that deleted a journal event and + * inserted another would keep every count identical, and the claim being + * checked is that acquisition cleanup touched none of this. + */ + authoritative(): string { + const tables = [ + "SELECT id, run_id, definition, base, props, status, created_at, updated_at FROM workflow_run ORDER BY id", + "SELECT root_id, format_version, manifest FROM workspace_roots ORDER BY root_id", + "SELECT singleton_id, current_root_id FROM workspace_state ORDER BY singleton_id", + "SELECT sequence, event_id, record, workspace_root_id FROM journal_events ORDER BY sequence", + "SELECT root_id, lower(hex(manifest_hash)) AS h FROM workspace_root_manifest_refs ORDER BY root_id, h", + "SELECT root_id, lower(hex(blob_hash)) AS h FROM workspace_root_blob_refs ORDER BY root_id, h", + "SELECT lower(hex(hash)) AS h, lower(hex(bytes)) AS b FROM vfs_blob_bytes ORDER BY h", + "SELECT lower(hex(hash)) AS h, size, lower(hex(encoded)) AS e FROM vfs_manifests ORDER BY h", + ]; + return sha256Hex( + JSON.stringify(tables.map((query) => this.ctx.storage.sql.exec(query).toArray())), + ); + } + + /** + * Fail inside the owner transaction, after every category has been written. + * + * Injected rather than simulated: the claim is that the runtime's own + * transaction rolls content, roots, references, mappings, the pointer, the + * journal and the retry decision back together, and only a real failure + * inside a real `transactionSync()` can show that. + */ + failAfterApply(raw: string): string { + try { + return String( + this.transactions.run(this.ctx.storage, () => { + const socket = this.ctx.getWebSockets("executor")[0]; + if (socket === undefined) { + throw new Error("no live acquisition"); + } + const answer = this.onRunnerMessage(socket, RUN_ID, raw); + throw new Error(`forced failure after ${JSON.stringify(answer)}`); + }), + ); + } catch (error) { + return error instanceof Error && error.message.startsWith("forced failure") + ? "rolled-back" + : `threw:${String(error)}`; + } + } + + /** Everything a reader could observe about the published frontier. */ + /** The one run row, as it is stored, or nothing when there is none. */ + runRow(): Record | null { + return this.ctx.storage.sql.exec("SELECT * FROM workflow_run").toArray()[0] ?? null; + } + + /** Every document execution, in the order the run recorded them. */ + executionRows(): Record[] { + return this.ctx.storage.sql + .exec("SELECT * FROM document_executions ORDER BY sequence") + .toArray(); + } + + /** The Workspace root this run currently stands on. */ + currentRootId(): string { + const row = this.ctx.storage.sql + .exec("SELECT current_root_id FROM workspace_state WHERE singleton_id = 1") + .toArray()[0]; + return String(row?.["current_root_id"] ?? ""); + } + + /** + * Which execution each acquisition began, as the owner retained it. + * + * Read from storage rather than from a field, which is the point: an evicted + * object keeps its sockets and forgets everything else. + */ + heldExecutions(): Record[] { + return this.ctx.storage.sql.exec(`SELECT * FROM ${HOLD_TABLE}`).toArray(); + } + + /** + * Forget which execution one retained decision began. + * + * The ledger row and the answer it retains are written together, so this is + * a store they cannot both be right about: an answer that granted execution + * authority beside a row that names no execution to grant. + */ + forgetRecordedExecution(commandId: string): void { + this.ctx.storage.sql.exec( + `UPDATE ${MUTATION_TABLE} SET execution_id = NULL WHERE command_id = ?`, + commandId, + ); + } + + /** What the mutation ledger recorded for one decision. */ + mutationRow(commandId: string): Record | undefined { + return this.ctx.storage.sql + .exec(`SELECT * FROM ${MUTATION_TABLE} WHERE command_id = ?`, commandId) + .toArray()[0]; + } + + /** The watermarks retained beside this run's copied content. */ + contentWatermarks(): { manifests: number[]; blobs: number[] } { + const read = (table: string) => + this.ctx.storage.sql + .exec(`SELECT last_seen FROM ${table} ORDER BY last_seen`) + .toArray() + .map((row) => Number(row["last_seen"])); + return { manifests: read("vfs_manifests"), blobs: read("vfs_blobs") }; + } + + /** Every fork part this owner is holding for anyone. */ + forkParts(): Record[] { + return this.ctx.storage.sql + .exec( + `SELECT acquisition_id, section, position FROM ${FORK_TABLE} ORDER BY section, position`, + ) + .toArray(); + } + + /** + * Point one head row at a different root this store already retains. + * + * A root that is here and valid, and not the one the fork committed against. + * What this makes is a destination whose head association is no longer its + * own — which a continuation has to notice. + */ + reassociateHead(head: string): void { + const other = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${NEXT_ROOT_MANIFEST}`); + this.ctx.storage.sql.exec( + "INSERT INTO workspace_roots (root_id, format_version, manifest) VALUES (?, 1, ?) " + + "ON CONFLICT(root_id) DO NOTHING", + other, + NEXT_ROOT_MANIFEST, + ); + const sequence = head === "run_record" ? 1 : 2; + this.ctx.storage.sql.exec( + "UPDATE journal_events SET workspace_root_id = ? WHERE sequence = ?", + other, + sequence, + ); + } + + /** Close every admitted connection, as a lost executor leaves them. */ + dropConnections(): void { + for (const socket of this.ctx.getWebSockets("executor")) { + socket.close(1000, "gone"); + this.webSocketClose(socket); + } + } + + /** How many objects this storage declares at all. */ + objectCount(): number { + return this.ctx.storage.sql + .exec("SELECT name FROM sqlite_master WHERE name NOT LIKE '_cf_%'") + .toArray().length; + } + + /** Whether this build's schema is here. */ + hasWorkflowSchema(): boolean { + return ( + this.ctx.storage.sql + .exec("SELECT name FROM sqlite_master WHERE name = 'workflow_run'") + .toArray().length > 0 + ); + } + + /** + * Retain a different run here, intact. + * + * The record still parses and every reference still holds; it simply names + * another run. That is a different condition from damage, and a store that + * conflated them would send an operator looking for a backup. + */ + retainAnotherRun(runId: string): void { + this.ctx.storage.sql.exec("UPDATE workflow_run SET run_id = ? WHERE id = 1", runId); + } + + /** Put something here that is not a workflow run, and never was one. */ + holdForeignObject(): void { + this.ctx.storage.sql.exec("CREATE TABLE somebody_else (id INTEGER PRIMARY KEY)"); + } + + published(): Record { + const state = this.ctx.storage.sql + .exec("SELECT current_root_id FROM workspace_state WHERE singleton_id = 1") + .toArray()[0]; + const roots = this.ctx.storage.sql + .exec("SELECT count(*) AS found FROM workspace_roots") + .toArray()[0]; + const events = this.ctx.storage.sql + .exec("SELECT event_id, workspace_root_id FROM journal_events ORDER BY sequence") + .toArray(); + const repositories = this.ctx.storage.sql + .exec("SELECT name, checkout_path FROM workspace_repositories ORDER BY name") + .toArray(); + const blobs = this.ctx.storage.sql + .exec("SELECT count(*) AS found FROM vfs_blob_bytes") + .toArray()[0]; + const refs = this.ctx.storage.sql + .exec("SELECT count(*) AS found FROM workspace_root_blob_refs") + .toArray()[0]; + return { + currentRootId: state?.["current_root_id"] ?? null, + roots: Number(roots?.["found"] ?? -1), + events, + repositories, + blobs: Number(blobs?.["found"] ?? -1), + blobRefs: Number(refs?.["found"] ?? -1), + }; + } + + /** The exact locator a retained Repository row holds. */ + repositoryLocator(name: string): string { + const row = this.ctx.storage.sql + .exec("SELECT locator FROM workspace_repositories WHERE name = ?", name) + .toArray()[0]; + return row === undefined ? "" : String(row["locator"]); + } + + /** A blob's metadata with no bytes beside it: a half-written identity. */ + removeBlobBytesOnly(digest: string, size: number): void { + this.ctx.storage.sql.exec( + "INSERT INTO vfs_blobs (hash, size, last_seen) VALUES (?, ?, 0) ON CONFLICT(hash) DO NOTHING", + hexBytes(digest), + size, + ); + } + + /** Begin one document execution, as a lifecycle transition would. */ + beginExecution(executionId: string, startedAt: string): void { + this.ctx.storage.sql.exec( + "INSERT INTO document_executions (execution_id, started_at) VALUES (?, ?)", + executionId, + startedAt, + ); + } + + /** Stop one document execution, as the matching transition would. */ + stopExecution(executionId: string, stoppedAt: string, status: string, code?: string): void { + this.ctx.storage.sql.exec( + "UPDATE document_executions SET stopped_at = ?, stop_status = ?, stop_reason_kind = ?, stop_reason_code = ? WHERE execution_id = ?", + stoppedAt, + status, + code === undefined ? null : "host", + code ?? null, + executionId, + ); + } + + /** What the retrieval row holds right now. */ + retrieval(): Record | null { + const row = this.ctx.storage.sql + .exec("SELECT metadata, revision, updated_at FROM definition_retrieval WHERE id = 1") + .toArray()[0]; + return row === undefined ? null : row; + } + + /** + * Retain one Repository whose checkout the starting Workspace holds. + * + * `/` is a directory in `ROOT_MANIFEST`, so this one qualifies for a fork + * source selected at that root — which is what makes it usable for proving + * that adding a qualifying mapping changes the selection's anchor. + */ + retainQualifyingRepository(name: string): void { + this.retainRepositoryAt(name, "/"); + } + + /** Retain one Repository checked out in a directory this test names. */ + retainRepositoryAt(name: string, checkoutPath: string): void { + this.ctx.storage.sql.exec( + `INSERT INTO workspace_repositories (name, locator, locator_fingerprint, requested_base, + creation_commit, primary_branch, object_format, checkout_path) + VALUES (?, ?, ?, NULL, ?, 'main', 'sha1', ?)`, + name, + "https://git.example.invalid/one.git", + "b".repeat(64), + "9".repeat(40), + checkoutPath, + ); + } + + /** + * Retain one Worktree of a retained Repository, in its own directory. + * + * `/work` is a directory in `ROOT_MANIFEST` and no Repository holds it, so + * the pair is a checkout graph a fork could restore as it stands. + */ + retainQualifyingWorktree(repositoryName: string, name: string): void { + this.retainWorktreeAt(repositoryName, name, "/work"); + } + + /** Retain one Worktree checked out in a directory this test names. */ + retainWorktreeAt(repositoryName: string, name: string, checkoutPath: string): void { + this.ctx.storage.sql.exec( + `INSERT INTO workspace_worktrees (repository_name, name, requested_branch, requested_base, + creation_commit, checkout_path) + VALUES (?, ?, 'topic', NULL, ?, ?)`, + repositoryName, + name, + "9".repeat(40), + checkoutPath, + ); + } + + /** Append enough journal events that one section cannot be read in one page. */ + fillJournal(count: number): void { + for (let index = 0; index < count; index += 1) { + this.appendJournal(`event-${String(index).padStart(4, "0")}`, `step ${index}`); + } + } + + /** Every retained journal record, in the order the journal holds them. */ + journalRecords(): { eventId: string; record: string }[] { + return this.ctx.storage.sql + .exec("SELECT event_id, record FROM journal_events ORDER BY sequence") + .toArray() + .map((row) => ({ + eventId: String(row["event_id"]), + record: String(row["record"]), + })); + } + + /** Move one retained blob's watermark, leaving its content identical. */ + touchBlobWatermark(): void { + this.ctx.storage.sql.exec("UPDATE vfs_blobs SET last_seen = last_seen + 1"); + } + + /** + * Give one retained row a value of the wrong SQLite type. + * + * `STRICT` tables refuse most of these, so the damaged column is one the + * schema declares loosely enough to hold it — which is exactly the case a + * reader that coerced would turn into a plausible value. + */ + damageRetainedWatermark(): void { + // INTEGER affinity converts what it can; text that names no number stays + // text, which is the value a reader that coerced would turn into zero. + this.ctx.storage.sql.exec("UPDATE vfs_blobs SET last_seen = 'not a number'"); + } + + /** Retain more Repository rows than one admitted snapshot may carry. */ + fillRepositories(from: number, count: number): void { + for (let index = from; index < from + count; index += 1) { + const name = `repo-${String(index).padStart(4, "0")}`; + this.ctx.storage.sql.exec( + `INSERT INTO workspace_repositories (name, locator, locator_fingerprint, requested_base, + creation_commit, primary_branch, object_format, checkout_path) + VALUES (?, ?, ?, NULL, ?, 'main', 'sha1', ?)`, + name, + `https://git.example.invalid/${name}.git`, + "a".repeat(64), + "9".repeat(40), + `/${name}`, + ); + } + } + + damageRetainedBlob(): void { + this.ctx.storage.sql.exec( + "UPDATE vfs_blob_bytes SET bytes = ?", + new TextEncoder().encode("bad"), + ); + } + + /** + * Collect the DOFS manifest a retained file entry still names. + * + * The reference row goes first because the schema will not let it go second: + * `ON DELETE RESTRICT` is what stops content vanishing from under a root that + * references it. What this reproduces is the state that restriction cannot + * prevent — a root whose manifest still names content the store no longer + * keeps, with the reference collected alongside it. + */ + removeManifestRow(): void { + this.ctx.storage.sql.exec( + "DELETE FROM workspace_root_manifest_refs WHERE lower(hex(manifest_hash)) = ?", + MANIFEST_ID, + ); + this.ctx.storage.sql.exec("DELETE FROM vfs_manifests WHERE lower(hex(hash)) = ?", MANIFEST_ID); + } + + /** Keep the manifest row, change the bytes it is identified by. */ + damageManifestPayload(): void { + this.ctx.storage.sql.exec( + "UPDATE vfs_manifests SET encoded = ? WHERE lower(hex(hash)) = ?", + new TextEncoder().encode('{"version":1,"chunks":[]}'), + MANIFEST_ID, + ); + } + + /** Keep identity and payload, disagree about how many bytes they describe. */ + damageManifestSize(): void { + this.ctx.storage.sql.exec( + "UPDATE vfs_manifests SET size = size + 1 WHERE lower(hex(hash)) = ?", + MANIFEST_ID, + ); + } + + /** Collect the blob a referenced manifest chunk still names, reference first. */ + removeBlobRow(): void { + this.removeBlobReference(); + this.ctx.storage.sql.exec("DELETE FROM vfs_blob_bytes WHERE lower(hex(hash)) = ?", BLOB_ID); + this.ctx.storage.sql.exec("DELETE FROM vfs_blobs WHERE lower(hex(hash)) = ?", BLOB_ID); + } + + /** Keep the blob and its bytes, disagree about its recorded size. */ + damageBlobSize(): void { + this.ctx.storage.sql.exec( + "UPDATE vfs_blobs SET size = size + 1 WHERE lower(hex(hash)) = ?", + BLOB_ID, + ); + } + + /** Drop the root's reference to a blob its manifests still name. */ + removeBlobReference(): void { + this.ctx.storage.sql.exec( + "DELETE FROM workspace_root_blob_refs WHERE root_id = ? AND lower(hex(blob_hash)) = ?", + ROOT_ID, + BLOB_ID, + ); + } + + /** Reference content from the root that none of its manifests names. */ + addExtraBlobReference(bytes: Uint8Array): string { + const digest = this.addUnreferencedBlob(bytes); + this.ctx.storage.sql.exec( + "INSERT INTO workspace_root_blob_refs (root_id, blob_hash) VALUES (?, ?)", + ROOT_ID, + hexBytes(digest), + ); + return digest; + } + + makeForeign(): void { + this.ctx.storage.sql.exec("CREATE TABLE foreign_state (id INTEGER PRIMARY KEY)"); + } + + rewriteMarker(applicationId: number, schemaVersion: number): void { + this.ctx.storage.sql.exec( + `UPDATE ${MARKER_TABLE} SET application_id = ?, schema_version = ? WHERE id = 1`, + applicationId, + schemaVersion, + ); + } + + dropTable(name: string): void { + this.ctx.storage.sql.exec(`DROP TABLE ${name}`); + } + + rewriteRunId(runId: string): void { + this.ctx.storage.sql.exec("UPDATE workflow_run SET run_id = ? WHERE id = 1", runId); + } + + removeWorkspaceState(): void { + this.ctx.storage.sql.exec("DELETE FROM workspace_state"); + } + + addUnreferencedBlob(bytes: Uint8Array): string { + const digest = sha256Hex(bytes); + const hash = hexBytes(digest); + this.ctx.storage.sql.exec( + "INSERT INTO vfs_blobs (hash, size, last_seen) VALUES (?, ?, 0)", + hash, + bytes.length, + ); + this.ctx.storage.sql.exec( + "INSERT INTO vfs_blob_bytes (hash, bytes) VALUES (?, ?)", + hash, + new Uint8Array(bytes), + ); + return digest; + } + + /** + * Admit one connection, answering what happened rather than raising. + * + * The server half of the pair is what the object admitted. Verification is + * asynchronous, so this drives the admission operation through one Effection + * scope — the runtime callback boundary this host adapts at. + */ + async admitConnection(request: Partial): Promise { + const pair = new WebSocketPair(); + const server = pair[1]; + // The client end is kept for as long as this object lives. Nothing reads + // it, but a pair whose other end is collected closes the end the owner + // accepted, and a test that then sends has no connection through no fault + // of the code under test. + this.#clients.push(pair[0]); + const presented: AdmissionRequest = { + runId: "runId" in request ? request.runId : RUN_ID, + release: "release" in request ? request.release : POLICY.release, + token: "token" in request ? request.token : undefined, + }; + try { + await run(() => this.admit(presented, server)); + return "admitted"; + } catch (error) { + return refusalOf(error); + } + } + + /** + * The legacy upgrade these suites were written against, and the real one. + * + * A request on one of the supported routes goes to the base object, which is + * where the production boundary lives. Everything else keeps the header shape + * the suites here already use — they are about admission and acquisition + * rather than about how a request is addressed. + */ + override async fetch(request: Request): Promise { + if (routeOf(new URL(request.url).pathname) !== undefined) { + return await super.fetch(request); + } + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + try { + await run(() => + this.admit( + { + runId: request.headers.get("x-run-id"), + release: request.headers.get("x-release"), + token: request.headers.get("authorization")?.replace(/^Bearer /, ""), + }, + server, + ), + ); + return new Response(null, { status: 101, webSocket: client }); + } catch (error) { + return new Response(refusalOf(error), { status: 403 }); + } + } + + /** + * Answer one ordinary read, the way a host's request route would. + * + * Deliberately not the WebSocket path: no socket, no upgrade, no + * acquisition. What a test proves through this is that reading takes nothing. + */ + async readRequest( + admission: { release: string | null; token: string | null; runId: string | null }, + body: string, + ): Promise { + return JSON.stringify(await run(() => this.read(admission, body))); + } + + /** + * Answer one typed delivery, the way a host's request route would. + * + * The same shape the read route has and the same absence of a socket: what a + * test proves through this is that answering a wait takes no acquisition. + */ + async deliverRequest( + admission: { release: string | null; token: string | null; runId: string | null }, + body: string, + ): Promise { + return JSON.stringify(await run(() => this.deliver(admission, body))); + } + + /** Every answer this run retains, as rows. */ + retainedAnswers(): Record[] { + return this.ctx.storage.sql + .exec("SELECT * FROM workflow_suspension_answers ORDER BY suspension_id") + .toArray(); + } + + /** + * Append the two rows a fork writes for itself, and one ordinary row. + * + * The run record and the root import are what a destination replaces, so a + * fork-source selection must exclude exactly them and keep the rest. + */ + appendForkableHistory(): void { + const write = (eventId: string, type: string, name: string) => { + const record: DurableEvent = { + type: "yield", + coroutineId: "root", + description: { type, name }, + result: { status: "ok", value: name }, + }; + this.ctx.storage.sql.exec( + "INSERT INTO journal_events (event_id, record, workspace_root_id) VALUES (?, ?, ?)", + eventId, + serializeDurableEvent(record), + ROOT_ID, + ); + }; + write("event-run", "workflow_run", "workflow_run"); + write("event-import", "import_component", "__root__"); + this.appendJournal("event-work", "work"); + } + + /** The correlation the live acquisition is partitioned by. */ + acquisitionId(): string { + const held = acquisitionHolders(this.ctx)[0]; + return held === undefined ? "" : held.held.acquisitionId; + } + + /** How many live connections currently hold this run's executor. */ + holders(): number { + return acquisitionHolders(this.ctx).length; + } + + /** Send one message as the connection admitted at `index` (1-based). */ + send(index: number, raw: string): unknown { + const socket = this.ctx.getWebSockets("executor")[index - 1]; + if (socket === undefined) { + return { id: "", outcome: "refused", refusal: "no-such-connection" }; + } + return this.onRunnerMessage(socket, RUN_ID, raw); + } + + /** + * Send as the connection admitted most recently. + * + * A replacement acquisition is a different socket, and the earlier one may + * still be listed; addressing by position would send as the connection that + * is gone. + */ + sendLatest(raw: string): unknown { + const sockets = this.ctx.getWebSockets("executor"); + const socket = sockets[sockets.length - 1]; + if (socket === undefined) { + return { id: "", outcome: "refused", refusal: "no-such-connection" }; + } + return this.onRunnerMessage(socket, RUN_ID, raw); + } + + /** Send as a socket this object never admitted. */ + sendAsStranger(raw: string): unknown { + const pair = new WebSocketPair(); + return this.onRunnerMessage(pair[1], RUN_ID, raw); + } + + sendWithCopiedAttachment(raw: string): unknown { + const live = this.ctx.getWebSockets("executor")[0]; + if (live === undefined) { + return { id: "", outcome: "refused", refusal: "no-such-connection" }; + } + const pair = new WebSocketPair(); + pair[1].serializeAttachment(live.deserializeAttachment()); + return this.onRunnerMessage(pair[1], RUN_ID, raw); + } + + /** Close the connection admitted at `index`, releasing its acquisition. */ + closeConnection(index: number): void { + const socket = this.ctx.getWebSockets("executor")[index - 1]; + if (socket !== undefined) { + socket.close(1000, "done"); + this.webSocketClose(socket); + } + } +} + +function hexBytes(value: string): Uint8Array { + const bytes = new Uint8Array(value.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} diff --git a/packages/workflow/tests/cloudflare/support/owner-object.ts b/packages/workflow/tests/cloudflare/support/owner-object.ts new file mode 100644 index 000000000..b66641914 --- /dev/null +++ b/packages/workflow/tests/cloudflare/support/owner-object.ts @@ -0,0 +1,202 @@ +/** + * A Durable Object that exercises the owner's storage paths on real workerd. + * + * It is deliberately thin: each method does one thing the owner does — create + * the schema, recognize it again, commit a mixed change, or fail partway + * through one — so a test can assert the outcome rather than a model of it. + */ + +import { DurableObject } from "cloudflare:workers"; +import { mkdir as mkdirPath } from "../../../vendor/cloudflare-computer-dofs/generated/fs/mkdir.js"; +import { writeFileSync } from "../../../vendor/cloudflare-computer-dofs/generated/fs/writeFile.js"; +import { + initializeObject, + recognizeObject, + WorkflowObjectStorageError, +} from "../../../src/cloudflare/recognition.ts"; +import { MARKER_TABLE } from "../../../src/cloudflare/marker.ts"; +import { + OwnerTransactionNestedError, + OwnerTransactions, +} from "../../../src/cloudflare/owner-transaction.ts"; +import type { OwnerStorage } from "../../../src/cloudflare/storage.ts"; + +/** One run row, so initialization writes what a real run would. */ +const RUN_ID = "run-under-test"; + +export class OwnerObject extends DurableObject { + readonly #transactions = new OwnerTransactions(); + + /** Create the schema, DOFS schema, an empty root and the run row, then mark it. */ + initialize(): string { + try { + initializeObject(this.ctx.storage, this.#transactions, () => { + this.ctx.storage.sql.exec( + "INSERT INTO workflow_run (run_id, definition, base, props, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + RUN_ID, + JSON.stringify({ version: 1 }), + "main", + "{}", + "running", + 0, + 0, + ); + }); + return "initialized"; + } catch (error) { + return describe(error); + } + } + + /** Read the storage back as a version-1 workflow run. */ + recognize(): string { + try { + recognizeObject(this.ctx.storage); + return "recognized"; + } catch (error) { + return describe(error); + } + } + + /** What the marker holds right now. */ + marker(): Record[] { + return this.ctx.storage.sql + .exec(`SELECT application_id, schema_version FROM ${MARKER_TABLE}`) + .toArray(); + } + + /** Drop one declared object, so recognition sees a shape that disagrees. */ + damage(table: string): void { + this.ctx.storage.sql.exec(`DROP TABLE ${table}`); + } + + /** Write an unrelated object, so pristine detection sees a foreign store. */ + addForeignObject(): void { + this.ctx.storage.sql.exec("CREATE TABLE somebody_elses (id INTEGER PRIMARY KEY)"); + } + + /** Replace the marker's identity with another application's. */ + rewriteMarker(applicationId: number, schemaVersion: number): void { + this.ctx.storage.sql.exec( + `UPDATE ${MARKER_TABLE} SET application_id = ?, schema_version = ? WHERE id = 1`, + applicationId, + schemaVersion, + ); + } + + /** + * Change DOFS content and a WorkflowRun row in one transaction. + * + * `fail` throws after both have been changed, which is the case that decides + * whether the two categories really share a transaction. + */ + commitMixedChange(fail: boolean): string { + try { + this.#transactions.run(this.ctx.storage, ({ dofs }) => { + mkdirPath(dofs, "/published", { recursive: true }, () => 0); + // oxlint-disable-next-line local/no-sync-filesystem + writeFileSync( + dofs, + "/published/root.txt", + new TextEncoder().encode("frontier"), + {}, + () => 0, + ); + this.ctx.storage.sql.exec( + "UPDATE workflow_run SET status = ?, updated_at = ? WHERE run_id = ?", + "suspended", + 1, + RUN_ID, + ); + if (fail) { + throw new Error("forced failure after both categories changed"); + } + }); + return "committed"; + } catch (error) { + return describe(error); + } + } + + /** + * Open an owner transaction inside one, on this object's own storage. + * + * The runtime admits exactly one, so this must be refused before it reaches + * the transaction API rather than by the runtime rejecting a savepoint. + */ + nestOnSameStorage(): string { + try { + this.#transactions.run(this.ctx.storage, () => { + this.#transactions.run(this.ctx.storage, () => undefined); + }); + return "nested"; + } catch (error) { + return error instanceof OwnerTransactionNestedError ? "refused:nested" : describe(error); + } + } + + /** + * Hold a transaction on this object's real storage and open another on a + * different storage at the same time. + * + * The second storage is a local stand-in rather than another object's: the + * runtime forbids touching another Durable Object's I/O, which is exactly why + * the guard has to be keyed by storage instance rather than shared. What is + * being proved is that holding one does not block the other. + */ + transactOnADifferentStorage(): string { + const other = standInStorage(); + try { + // A second gate stands for a second Durable Object: what must not happen + // is one object's open transaction refusing another object's. + const otherObject = new OwnerTransactions(); + return this.#transactions.run(this.ctx.storage, () => + otherObject.run(other, () => "committed while another storage transacted"), + ); + } catch (error) { + return describe(error); + } + } + + /** What the run row and the DOFS filesystem hold, read outside any transaction. */ + frontier(): { status: string; publishedPaths: number } { + const runRows = this.ctx.storage.sql + .exec("SELECT status FROM workflow_run WHERE run_id = ?", RUN_ID) + .toArray(); + const first = runRows[0]; + const paths = this.ctx.storage.sql + .exec("SELECT count(*) AS found FROM vfs_dirents WHERE name = ?", "root.txt") + .toArray(); + const found = paths[0]; + return { + status: first === undefined ? "absent" : String(first["status"]), + publishedPaths: found === undefined ? -1 : Number(found["found"]), + }; + } +} + +function describe(error: unknown): string { + if (error instanceof WorkflowObjectStorageError) { + return `refused:${error.failure.kind}`; + } + return `threw:${error instanceof Error ? error.message : String(error)}`; +} + +/** + * A second storage that is not this object's. + * + * It answers nothing useful — the transaction opened on it does no SQL — so it + * is only ever asked whether it is a different key than the real one. + */ +function standInStorage(): OwnerStorage { + return { + sql: { + exec(): { toArray(): Record[] } { + return { toArray: () => [] }; + }, + }, + transactionSync(closure: () => T): T { + return closure(); + }, + }; +} diff --git a/packages/workflow/tests/cloudflare/support/probe-object.ts b/packages/workflow/tests/cloudflare/support/probe-object.ts new file mode 100644 index 000000000..1daf1072e --- /dev/null +++ b/packages/workflow/tests/cloudflare/support/probe-object.ts @@ -0,0 +1,144 @@ +/** + * A Durable Object that answers what its own SQLite storage can actually do. + * + * The version-1 schema is recognized through `PRAGMA application_id` and + * `PRAGMA user_version`, and the vendored DOFS `Database` opens reentrant + * transactions with `SAVEPOINT` through `sql.exec`. Cloudflare's own + * documentation says `sql.exec()` cannot execute transaction statements and + * says nothing about those two pragmas, so neither assumption can be settled + * from prose — the owner either has the same recognition contract the Deno host + * has, or it does not, and that decides how §4 is written rather than being a + * detail inside it. + * + * So this object exists to be asked, on real workerd. It lives in test support + * rather than in production source: it measures the runtime, and the answers it + * gives are asserted by `storage-capabilities.vitest.ts` so a platform change + * that moved any of them would fail rather than pass quietly. + */ + +import { DurableObject } from "cloudflare:workers"; +import { dofsStorage } from "../../../src/cloudflare/storage.ts"; +import { Database as DofsDatabase } from "../../../vendor/cloudflare-computer-dofs/generated/storage.js"; +import { initializeSchema as initializeDofsSchema } from "../../../vendor/cloudflare-computer-dofs/generated/schema/index.js"; +import { mkdir as mkdirPath } from "../../../vendor/cloudflare-computer-dofs/generated/fs/mkdir.js"; +import { writeFileSync } from "../../../vendor/cloudflare-computer-dofs/generated/fs/writeFile.js"; + +export interface StorageCapabilities { + readonly applicationIdRead: string; + readonly applicationIdWrite: string; + readonly userVersionRead: string; + readonly userVersionWrite: string; + readonly schemaObjects: string; + readonly outerTransaction: string; + readonly nestedTransaction: string; + readonly savepointDirect: string; + readonly dofsSchema: string; + readonly dofsFilesystem: string; + readonly xmdTableDdl: string; + readonly metadataTable: string; + readonly filesystemInsideTransaction: string; +} + +/** Run `body`, reporting what it answered or how it refused, never throwing. */ +function attempt(body: () => unknown): string { + try { + const value = body(); + return `ok:${JSON.stringify(value ?? null)}`; + } catch (error) { + return `refused:${error instanceof Error ? error.message : String(error)}`; + } +} + +export class StorageProbeObject extends DurableObject { + capabilities(): StorageCapabilities { + const sql = this.ctx.storage.sql; + const dofs = new DofsDatabase(dofsStorage(this.ctx.storage)); + return { + applicationIdWrite: attempt(() => { + sql.exec("PRAGMA application_id = 1701078349"); + return "written"; + }), + applicationIdRead: attempt(() => sql.exec("PRAGMA application_id").toArray()), + userVersionWrite: attempt(() => { + sql.exec("PRAGMA user_version = 1"); + return "written"; + }), + userVersionRead: attempt(() => sql.exec("PRAGMA user_version").toArray()), + schemaObjects: attempt(() => + sql.exec("SELECT type, name FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'").toArray(), + ), + outerTransaction: attempt(() => { + dofs.transactionSync(() => { + sql.exec("CREATE TABLE IF NOT EXISTS probe_outer (id INTEGER PRIMARY KEY)"); + }); + return "committed"; + }), + // The one the documentation forbids: a reentrant transactionSync issues + // SAVEPOINT through sql.exec while the outer transaction is open. + nestedTransaction: attempt(() => { + dofs.transactionSync(() => { + dofs.transactionSync(() => { + sql.exec("CREATE TABLE IF NOT EXISTS probe_nested (id INTEGER PRIMARY KEY)"); + }); + }); + return "committed"; + }), + savepointDirect: attempt(() => { + sql.exec("SAVEPOINT probe_sp"); + sql.exec("RELEASE probe_sp"); + return "accepted"; + }), + // Does the vendored DOFS install its own schema against real storage, + // and does doing so nest a transaction on the way? + dofsSchema: attempt(() => { + initializeDofsSchema(dofs, () => 0); + return "initialized"; + }), + // And does its filesystem work afterwards — the operation the owner would + // perform for every Workspace mutation. + dofsFilesystem: attempt(() => { + mkdirPath(dofs, "/probe", { recursive: true }, () => 0); + // The probe measures what this exact synchronous primitive does on real + // storage, so the asynchronous alternative would answer a different + // question than the one being asked. + // oxlint-disable-next-line local/no-sync-filesystem + writeFileSync(dofs, "/probe/one.txt", new TextEncoder().encode("hello"), {}, () => 0); + return "written"; + }), + // An ordinary XMD table, to show plain DDL is not what is refused. + xmdTableDdl: attempt(() => { + sql.exec("CREATE TABLE IF NOT EXISTS workflow_run (run_id TEXT PRIMARY KEY NOT NULL)"); + sql.exec("INSERT OR REPLACE INTO workflow_run (run_id) VALUES (?)", "probe"); + return sql.exec("SELECT run_id FROM workflow_run").toArray(); + }), + // The exact shape §4 mandates for an owner commit: DOFS filesystem work + // inside one `transactionSync`. If DOFS opens a transaction of its own on + // that path it becomes a reentrant call, which the runtime refuses. + filesystemInsideTransaction: attempt(() => { + dofs.transactionSync(() => { + mkdirPath(dofs, "/inside", { recursive: true }, () => 0); + // oxlint-disable-next-line local/no-sync-filesystem + writeFileSync( + dofs, + "/inside/two.txt", + new TextEncoder().encode("committed"), + {}, + () => 0, + ); + }); + return "committed"; + }), + // The shape a replacement for the pragmas would have to take. + metadataTable: attempt(() => { + sql.exec( + "CREATE TABLE IF NOT EXISTS xmd_schema (key TEXT PRIMARY KEY NOT NULL, value INTEGER NOT NULL)", + ); + sql.exec( + "INSERT OR REPLACE INTO xmd_schema (key, value) VALUES ('application_id', ?)", + 1701078349, + ); + return sql.exec("SELECT key, value FROM xmd_schema").toArray(); + }), + }; + } +} diff --git a/packages/workflow/tests/cloudflare/support/tokens.ts b/packages/workflow/tests/cloudflare/support/tokens.ts new file mode 100644 index 000000000..7436edc99 --- /dev/null +++ b/packages/workflow/tests/cloudflare/support/tokens.ts @@ -0,0 +1,68 @@ +/** + * Signing tokens for the admission tests, with keys generated here. + * + * Real signatures against a real key pair, so the assertions are about + * verification rather than about a stub that agreed to say yes. The key never + * leaves this process and is generated per run. + */ + +/** One generated key pair, and the JWK a verifier is configured with. */ +export interface TestKeys { + readonly signing: CryptoKey; + readonly publicJwk: JsonWebKey; + readonly kid: string; +} + +function base64url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +function encodeSegment(value: unknown): string { + return base64url(new TextEncoder().encode(JSON.stringify(value))); +} + +export async function generateKeys(kid = "test-key"): Promise { + const generated = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + // `generateKey` is typed as either a key or a pair; an RSA signing algorithm + // always answers with a pair, and reading it as one is what proves that here. + if (!("privateKey" in generated) || !("publicKey" in generated)) { + throw new Error("expected an RSA key pair"); + } + const exported = await crypto.subtle.exportKey("jwk", generated.publicKey); + if (exported instanceof ArrayBuffer) { + throw new Error("expected a JWK export"); + } + return { signing: generated.privateKey, publicJwk: exported, kid }; +} + +/** Sign one compact JWS over `claims`. */ +export async function signToken( + keys: TestKeys, + claims: Record, + header: Record = {}, +): Promise { + const encodedHeader = encodeSegment({ alg: "RS256", typ: "JWT", kid: keys.kid, ...header }); + const encodedPayload = encodeSegment(claims); + const signed = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`); + const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", keys.signing, signed); + return `${encodedHeader}.${encodedPayload}.${base64url(new Uint8Array(signature))}`; +} + +/** A token whose payload was edited after it was signed. */ +export function tamper(token: string, claims: Record): string { + const parts = token.split("."); + return `${parts[0]}.${encodeSegment(claims)}.${parts[2]}`; +} diff --git a/packages/workflow/tests/cloudflare/support/worker-files.ts b/packages/workflow/tests/cloudflare/support/worker-files.ts new file mode 100644 index 000000000..d44b2a1ba --- /dev/null +++ b/packages/workflow/tests/cloudflare/support/worker-files.ts @@ -0,0 +1,304 @@ +/** + * A filesystem for the runner half, inside the worker. + * + * The owner in these tests is real: a real Durable Object, real SQLite, a real + * accepted WebSocket, and the production client and coordinator on the other + * end of it. The runner's *host filesystem* cannot be. workerd has no native + * filesystem, and the vendored DOFS cannot set a modification time — so it + * cannot reproduce a retained mtime, which is exactly what materialization + * refuses a host for. + * + * So this stands in for the one thing the runtime cannot provide, and nothing + * else. It is not a model of the owner, and it is not a model of the + * coordinator: it stores modes, whole-second times, symbolic links and hardlink + * identity the way a filesystem does, and the production + * `materializeWorkspaceRoot`, `captureWorkspace` and coordinator run against it + * unchanged. The native adapter this stands in for is proved against real files + * in `packages/workflow/tests/remote-workspace-files.test.ts`. + */ + +import { type Operation } from "effection"; +import type { RunnerFiles, RunnerNode } from "../../../src/remote/materialize.ts"; +import type { TemporaryTrees } from "../../../src/remote/invocation.ts"; +import type { + WorkspaceEntry, + WorkspaceFilesystem, + WorkspaceStat, +} from "../../../src/workspace/filesystem.ts"; + +/** One file's bytes, shared by every path hardlinked to it. */ +interface Content { + bytes: Uint8Array; + readonly identity: string; +} + +interface Node { + kind: "directory" | "file" | "symlink"; + mode: number; + mtime: number; + content?: Content; + target?: string; +} + +function failure(code: string): Error { + const error = new Error(`the Workspace operation failed (${code})`); + error.name = "WorkspaceFsError"; + Reflect.set(error, "code", code); + return error; +} + +function parentOf(path: string): string { + const at = path.lastIndexOf("/"); + return at <= 0 ? "/" : path.slice(0, at); +} + +/** One tree, addressed by absolute path. */ +export function createWorkerFiles(): { + files: RunnerFiles; + trees: TemporaryTrees; + workspace(root: string): WorkspaceFilesystem; +} { + // The tree's own root exists from the start, the way a filesystem's does. + const nodes = new Map([["/", { kind: "directory", mode: 0o755, mtime: 0 }]]); + let identities = 0; + let roots = 0; + let clock = 1_700_000_000; + + function node(path: string): Node { + const found = nodes.get(path); + if (found === undefined) { + throw failure("ENOENT"); + } + return found; + } + + function requireParent(path: string): void { + const parent = nodes.get(parentOf(path)); + if (parent === undefined || parent.kind !== "directory") { + throw failure("ENOENT"); + } + } + + function children(path: string): string[] { + const prefix = path === "/" ? "/" : `${path}/`; + return [...nodes.keys()].filter( + (candidate) => + candidate !== path && + candidate.startsWith(prefix) && + !candidate.slice(prefix.length).includes("/"), + ); + } + + function describe(path: string, name: string): RunnerNode { + const held = node(path); + return { + name, + kind: held.kind, + mode: held.mode, + mtime: held.mtime, + size: held.content?.bytes.length ?? held.target?.length ?? 0, + identity: held.content?.identity, + target: held.target, + }; + } + + function nameOf(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); + } + + const files: RunnerFiles = { + // deno-lint-ignore require-yield + *makeDirectory(path, mode): Operation { + if (nodes.has(path)) { + throw failure("EEXIST"); + } + if (path !== "/") { + requireParent(path); + } + nodes.set(path, { kind: "directory", mode, mtime: (clock += 1) }); + }, + + // deno-lint-ignore require-yield + *removeTree(path): Operation { + // Every node at or under this path, so what a savepoint restores into is + // an empty directory rather than one holding part of a failed attempt. + for (const held of Array.from(nodes.keys())) { + if (held === path || held.startsWith(`${path}/`)) { + nodes.delete(held); + } + } + }, + + // deno-lint-ignore require-yield + *writeFile(path, bytes, mode): Operation { + requireParent(path); + identities += 1; + nodes.set(path, { + kind: "file", + mode, + mtime: (clock += 1), + content: { bytes: new Uint8Array(bytes), identity: `content-${identities}` }, + }); + }, + + // deno-lint-ignore require-yield + *makeSymlink(target, path): Operation { + requireParent(path); + nodes.set(path, { kind: "symlink", mode: 0o777, mtime: (clock += 1), target }); + }, + + // deno-lint-ignore require-yield + *makeHardlink(existing, path): Operation { + requireParent(path); + const source = node(existing); + if (source.content === undefined) { + throw failure("EPERM"); + } + // The same content, so both paths are one file and capture sees it. + nodes.set(path, { + kind: "file", + mode: source.mode, + mtime: source.mtime, + content: source.content, + }); + }, + + // deno-lint-ignore require-yield + *setMode(path, mode): Operation { + node(path).mode = mode; + }, + + // deno-lint-ignore require-yield + *setModifiedAt(path, mtime): Operation { + node(path).mtime = mtime; + }, + + setLinkModifiedAt: function* (path, mtime): Operation { + node(path).mtime = mtime; + }, + + setLinkMode: function* (path, mode): Operation { + node(path).mode = mode; + }, + + // deno-lint-ignore require-yield + *readFile(path): Operation { + const held = node(path); + if (held.content === undefined) { + throw failure("EISDIR"); + } + return new Uint8Array(held.content.bytes); + }, + + // deno-lint-ignore require-yield + *list(path): Operation { + const held = node(path); + if (held.kind !== "directory") { + throw failure("ENOTDIR"); + } + return children(path).map((child) => describe(child, nameOf(child))); + }, + + // deno-lint-ignore require-yield + *describe(path): Operation { + return describe(path, nameOf(path)); + }, + }; + + const trees: TemporaryTrees = { + *create(purpose): Operation { + roots += 1; + const root = `/${purpose}-${roots}`; + yield* files.makeDirectory(root, 0o755); + return root; + }, + + // deno-lint-ignore require-yield + *remove(path): Operation { + for (const candidate of [...nodes.keys()]) { + if (candidate === path || candidate.startsWith(`${path}/`)) { + nodes.delete(candidate); + } + } + }, + }; + + /** + * The Workspace filesystem over one tree in it. + * + * Containment is the native adapter's subject and is proved there; what this + * needs to be is a filesystem the coordinator can really change, so a commit + * carries bytes a document actually wrote. + */ + function workspace(root: string): WorkspaceFilesystem { + const at = (logical: string) => (logical === "/" ? root : `${root}${logical}`); + function stat(path: string): WorkspaceStat { + const held = node(path); + return { + kind: held.kind, + mode: held.mode, + mtime: held.mtime, + size: held.content?.bytes.length ?? 0, + }; + } + return { + *readFile(path): Operation { + return yield* files.readFile(at(path)); + }, + *readTextFile(path): Operation { + return new TextDecoder().decode(yield* files.readFile(at(path))); + }, + // deno-lint-ignore require-yield + *stat(path): Operation { + return stat(at(path)); + }, + // deno-lint-ignore require-yield + *lstat(path): Operation { + return stat(at(path)); + }, + // deno-lint-ignore require-yield + *readlink(path): Operation { + const target = node(at(path)).target; + if (target === undefined) { + throw failure("EINVAL"); + } + return target; + }, + // deno-lint-ignore require-yield + *readdir(path): Operation { + return children(at(path)).map((child) => ({ + name: nameOf(child), + kind: node(child).kind, + })); + }, + *writeFile(path, content, mode): Operation { + const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content; + yield* files.writeFile(at(path), bytes, mode ?? 0o644); + }, + *mkdir(path, options = {}): Operation { + yield* files.makeDirectory(at(path), options.mode ?? 0o755); + }, + *remove(path): Operation { + yield* trees.remove(at(path)); + }, + // deno-lint-ignore require-yield + *rename(from, to): Operation { + const held = node(at(from)); + nodes.delete(at(from)); + nodes.set(at(to), held); + }, + // deno-lint-ignore require-yield + *chmod(path, mode): Operation { + node(at(path)).mode = mode; + }, + *symlink(target, path): Operation { + yield* files.makeSymlink(target, at(path)); + }, + *link(existing, path): Operation { + yield* files.makeHardlink(at(existing), at(path)); + }, + }; + } + + return { files, trees, workspace }; +} diff --git a/packages/workflow/tests/cloudflare/worker.ts b/packages/workflow/tests/cloudflare/worker.ts new file mode 100644 index 000000000..1da99ee72 --- /dev/null +++ b/packages/workflow/tests/cloudflare/worker.ts @@ -0,0 +1,17 @@ +/** + * The Worker the workerd suite runs against. + * + * It exists to publish the Durable Object classes under test and nothing else: + * the tests reach those objects through `runInDurableObject()` and their own + * stubs, so this handler answers no request a test depends on. + */ + +export { StorageProbeObject } from "./support/probe-object.ts"; +export { OwnerObject } from "./support/owner-object.ts"; +export { ExecutorObject } from "./support/executor-object.ts"; + +export default { + fetch(): Response { + return new Response("workflow owner test worker", { status: 200 }); + }, +}; diff --git a/packages/workflow/tests/cloudflare/wrangler.jsonc b/packages/workflow/tests/cloudflare/wrangler.jsonc new file mode 100644 index 000000000..9e7f7877d --- /dev/null +++ b/packages/workflow/tests/cloudflare/wrangler.jsonc @@ -0,0 +1,16 @@ +{ + "name": "workflow-owner-tests", + "main": "worker.ts", + "compatibility_date": "2026-08-01", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { "name": "STORAGE_PROBE", "class_name": "StorageProbeObject" }, + { "name": "OWNER", "class_name": "OwnerObject" }, + { "name": "EXECUTOR", "class_name": "ExecutorObject" }, + ], + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["StorageProbeObject", "OwnerObject", "ExecutorObject"] }, + ], +} diff --git a/packages/workflow/tests/delivery-gate.test.ts b/packages/workflow/tests/delivery-gate.test.ts new file mode 100644 index 000000000..14d98503d --- /dev/null +++ b/packages/workflow/tests/delivery-gate.test.ts @@ -0,0 +1,93 @@ +/** + * Tier WAD — one credential gate, at both boundaries that write a value. + * + * A delivered answer becomes retained state and then a journal event, and the + * settled contract is that it crosses the same gate durable journal persistence + * crosses before either exists. There are two places that write it — the local + * host's own delivery and a run's owner — and the risk this suite exists for is + * that they drift apart: one of them summarizing the gate, or the configuration + * changing under one and not the other. + * + * So this runs the scanner the journal is written through and the gate the + * owner applies over the same content and requires the same verdict. It is not + * a test of what the rules match; `packages/core` owns that. It is a test that + * there is one gate. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import type { Operation } from "effection"; +import { createSecretScanner } from "@executablemd/core/secrets"; +import { crossSecretGate } from "../src/cloudflare/owner-gate.ts"; +import { CommandError } from "../src/cloudflare/commands.ts"; + +/** + * A safe canary the repository's own credential rule matches. + * + * Not an issued token and not a real secret: a credential-named field carrying + * an opaque-looking value. It is here because it is exactly the shape a weaker + * detector lets through, so it is what a split gate would disagree about. + */ +const SAFE_CANARY = '{"password":"example-Purple7Elephant"}'; + +/** An issued token, assembled at run time so no literal is committed. */ +const ISSUED = `{"note":"ghp_${"abcdefghijklmnopqrstuvwxyz0123456789".slice(0, 36)}"}`; + +/** Whether the scanner the journal is written through refuses this content. */ +function* scanned(content: string): Operation { + return (yield* createSecretScanner().scan(content)).length > 0; +} + +/** Whether the gate a run's owner applies refuses this content. */ +function* gated(content: string): Operation { + try { + yield* crossSecretGate([content]); + return false; + } catch (error) { + if (error instanceof CommandError && error.refusal === "credential-detected") { + return true; + } + throw error; + } +} + +describe("the credential gate a delivered answer crosses", () => { + it("reaches the same verdict at the owner as at the journal", function* () { + const contents = [ + SAFE_CANARY, + ISSUED, + '{"approved":true}', + '{"note":"shipped the release"}', + '{"apiKey":"your-api-key-here"}', + "", + ]; + + const verdicts: { content: string; journal: boolean; owner: boolean }[] = []; + for (const content of contents) { + verdicts.push({ content, journal: yield* scanned(content), owner: yield* gated(content) }); + } + + // One gate: every disagreement here is a value one boundary would write and + // the other would refuse. + expect(verdicts.filter((verdict) => verdict.journal !== verdict.owner)).toEqual([]); + // And the safe canary is one the gate actually refuses, so the agreement + // above is not two detectors both saying nothing. + expect(verdicts.find((verdict) => verdict.content === SAFE_CANARY)?.owner).toBe(true); + expect(verdicts.find((verdict) => verdict.content === ISSUED)?.owner).toBe(true); + expect(verdicts.find((verdict) => verdict.content === '{"approved":true}')?.owner).toBe(false); + }); + + it("refuses every framing it is given, and reports no content", function* () { + let refused: unknown; + try { + yield* crossSecretGate(['{"approved":true}', SAFE_CANARY]); + } catch (error) { + refused = error; + } + + expect(refused instanceof CommandError).toBe(true); + // The refusal is one category. Neither the value nor the match travels. + expect(String(refused)).not.toContain("Purple7Elephant"); + expect(String(refused)).not.toContain("password"); + }); +}); diff --git a/packages/workflow/tests/git-blob.test.ts b/packages/workflow/tests/git-blob.test.ts new file mode 100644 index 000000000..dde4243b1 --- /dev/null +++ b/packages/workflow/tests/git-blob.test.ts @@ -0,0 +1,76 @@ +/** + * Naming a blob the way Git names one. + * + * A workflow definition holds each bundled component's object id, and a + * completed replay has no repository to ask what a retained source hashes to. + * So it computes the name itself — which is only worth anything if the name it + * computes is Git's. Two authorities settle that here, and neither of them is + * this code: FIPS 180-4's published SHA-1 answers, and the object ids + * `git hash-object -t blob` gave these exact bytes. + * + * The object ids are committed constants rather than a computation. Deriving + * them from the function under test would be a test agreeing with itself, and + * shelling out to Git would make a portable suite depend on a program. The + * end-to-end proof that this agrees with a real repository is + * `packages/cli/tests/workflow-replay.test.ts`, where the definition's hashes + * come from `git rev-parse` and this admission authenticates against them. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { gitBlobId, sha1Hex } from "../src/git-blob.ts"; + +/** Eight UTF-16 units, eleven bytes: `é` is two and the dragon is four. */ +const WIDE = "café \u{1f409}\n"; + +describe("SHA-1, held to its published answers", () => { + // deno-lint-ignore require-yield + it("reproduces the FIPS 180-4 examples", function* () { + expect(sha1Hex("abc")).toBe("a9993e364706816aba3e25717850c26c9cd0d89d"); + expect(sha1Hex("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq")).toBe( + "84983e441c3bd26ebaae4aa1f95129e5e54670f1", + ); + expect(sha1Hex("")).toBe("da39a3ee5e6b4b0d3255bfef95601890afd80709"); + // A million `a`s is the third published example; this is the block-boundary + // half of what it exercises, at a size a test can carry. + expect(sha1Hex("a".repeat(1000))).toBe("291e9a6c66994949b57ba5e650361e98fc36b1ba"); + }); + + // deno-lint-ignore require-yield + it("hashes bytes, not characters", function* () { + expect(sha1Hex(WIDE)).toBe(sha1Hex(new TextEncoder().encode(WIDE))); + }); +}); + +describe("the object id a blob has", () => { + // deno-lint-ignore require-yield + it("is the one Git gives it, under either object format", function* () { + expect(gitBlobId("staged.\n", "sha1")).toBe("4eb53b7fd720524e22040757b43e821f817ff0eb"); + expect(gitBlobId("staged.\n", "sha256")).toBe( + "bee278bf729e0ac11f0bd6bf2ec94b1536d51883bd6e426ac32ec0a94afe76ca", + ); + expect(gitBlobId("never imported.\n", "sha1")).toBe("0b42d358385c85db1957138c7a200ad153514209"); + expect(gitBlobId("", "sha1")).toBe("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"); + }); + + // deno-lint-ignore require-yield + it("frames the header with the encoded byte length", function* () { + // Eleven bytes, eight units of `String#length`. A framing that used the + // string length would write `blob 8` and name an object Git does not. + expect(new TextEncoder().encode(WIDE).length).toBe(11); + expect(WIDE.length).toBe(8); + expect(gitBlobId(WIDE, "sha1")).toBe("c4ae463ec163e7b0b1a47ca6f0d5a2205d3643dc"); + expect(gitBlobId(WIDE, "sha256")).toBe( + "3a2a85ffaa00d300e360a8f0e3b0d1b13e6bcdabfdcd8124d2f2e3dc062cc9f5", + ); + }); + + // deno-lint-ignore require-yield + it("names different bytes differently", function* () { + expect(gitBlobId("ALTERED\n", "sha1")).toBe("e93f6b023845f2035a5f3d299ae4624802b4e891"); + expect(gitBlobId("ALTERED\n", "sha1")).not.toBe(gitBlobId("staged.\n", "sha1")); + // The framing is what keeps content from being confused with its own + // header: these are not the same object. + expect(gitBlobId("staged.\n", "sha1")).not.toBe(sha1Hex("staged.\n")); + }); +}); diff --git a/packages/workflow/tests/host-neutrality.test.ts b/packages/workflow/tests/host-neutrality.test.ts new file mode 100644 index 000000000..bc10781ba --- /dev/null +++ b/packages/workflow/tests/host-neutrality.test.ts @@ -0,0 +1,128 @@ +/** + * Tier WRH — what the shared package may know about a host. + * + * `@executablemd/workflow` names no provider. That claim is what lets a second + * host implement the same lifecycle without the Deno entrypoint being loaded at + * all, and it is worth exactly as much as the imports underneath it: one + * `node:sqlite` or `cloudflare:` specifier in a shared module, or one + * `typeof Deno` test, and every module that resolves through it inherits a host. + * + * So this reads the source rather than describing it. It walks the modules a + * consumer reaches through the package root and fails on anything that names a + * runtime — the runtime-named entrypoints and their own subtrees excepted, + * because installing host behavior is what those are for. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { readTextFile, walk } from "@effectionx/fs"; +import { each } from "effection"; +import type { Operation } from "effection"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PACKAGE = fileURLToPath(new URL("..", import.meta.url)); + +/** + * The subtrees that are allowed to know a host, because naming one is their job. + * + * The runtime-named entrypoints and their implementation subtrees, and nothing + * else. `software-factory.ts` is deliberately absent: it is product-specific + * rather than host-specific, uses the cross-runtime Web primitives, and is held + * to these rules like any shared module. `vendor` is pinned upstream source + * whose drift verifier owns its bytes. + */ +const RUNTIME_OWNED = [ + "deno.ts", + "cloudflare.ts", + "src/deno", + "src/cloudflare", + "tests/cloudflare", + "vitest.config.ts", + "vendor", +]; + +/** Specifiers only a host adapter may import. */ +const HOST_SPECIFIERS = [ + "node:sqlite", + "node:fs", + "node:os", + "node:child_process", + "cloudflare:workers", + "cloudflare:test", + "@cloudflare/", +]; + +/** Ways a module could ask which runtime it is running under. */ +const RUNTIME_DETECTION = [ + /\btypeof\s+Deno\b/, + /\btypeof\s+Bun\b/, + /\bnavigator\s*\.\s*userAgent\b/, + /\bprocess\s*\.\s*versions\s*\.\s*bun\b/, + /\bglobalThis\s*\.\s*Deno\b/, + /\bglobalThis\s*\.\s*Bun\b/, +]; + +function* sharedModules(): Operation { + const owned = RUNTIME_OWNED.map((entry) => join(PACKAGE, entry)); + const found: string[] = []; + for (const entry of yield* each(walk(PACKAGE, { includeDirs: false }))) { + const path = entry.path; + const exempt = owned.some((root) => path === root || path.startsWith(`${root}/`)); + const generated = ["/node_modules/", "/tests/", "/npm/"].some((part) => path.includes(part)); + if (!exempt && !generated && path.endsWith(".ts") && !path.endsWith(".d.ts")) { + found.push(path); + } + yield* each.next(); + } + return found.toSorted(); +} + +function* offenders(check: (source: string) => boolean): Operation { + const named: string[] = []; + for (const path of yield* sharedModules()) { + const source = yield* readTextFile(path); + if (check(source)) { + named.push(relative(PACKAGE, path)); + } + } + return named; +} + +describe("the shared workflow package", () => { + it("finds the modules it is making a claim about", function* () { + const modules = yield* sharedModules(); + expect(modules.length > 20).toEqual(true); + expect(modules.some((path) => path.endsWith("/src/lifecycle/execution.ts"))).toEqual(true); + expect(modules.some((path) => path.endsWith("/src/software-factory/run-id.ts"))).toEqual(true); + expect(modules.some((path) => path.endsWith("/src/sqlite/workflow-schema.ts"))).toEqual(true); + // The remote seam is ordinary shared code. It is the runner's half of a + // connection to a provider, which is exactly why it must name none: an + // exemption here would let the provider's vocabulary back in through the + // one module whose whole purpose is to keep it out. + expect(modules.some((path) => path.endsWith("/src/remote/read.ts"))).toEqual(true); + expect(modules.some((path) => path.endsWith("/src/remote/client.ts"))).toEqual(true); + expect(modules.some((path) => path.endsWith("/src/remote/records.ts"))).toEqual(true); + expect(modules.some((path) => path.endsWith("/src/workspace/root-manifest.ts"))).toEqual(true); + expect(modules.some((path) => path.endsWith("/src/workspace/sha256.ts"))).toEqual(true); + expect(modules.some((path) => path.includes("/src/deno/"))).toEqual(false); + expect(modules.some((path) => path.includes("/src/cloudflare/"))).toEqual(false); + }); + + it("imports no host-owned specifier outside a runtime-named entrypoint", function* () { + const named = yield* offenders((source) => + HOST_SPECIFIERS.some( + (specifier) => + source.includes(`from "${specifier}`) || source.includes(`import("${specifier}`), + ), + ); + expect(named).toEqual([]); + }); + + it("asks no module which runtime it is running under", function* () { + const named = yield* offenders((source) => + RUNTIME_DETECTION.some((pattern) => pattern.test(source)), + ); + expect(named).toEqual([]); + }); +}); diff --git a/packages/workflow/tests/public-entrypoint.test.ts b/packages/workflow/tests/public-entrypoint.test.ts index 4095eaa82..b4d0620a8 100644 --- a/packages/workflow/tests/public-entrypoint.test.ts +++ b/packages/workflow/tests/public-entrypoint.test.ts @@ -22,6 +22,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { until } from "effection"; +import { readTextFile } from "@effectionx/fs"; import { spawnSync } from "node:child_process"; import process from "node:process"; import { fileURLToPath } from "node:url"; @@ -52,6 +53,7 @@ const PROBE = fileURLToPath(new URL("./support/public-entrypoint-probe.ts", impo const HELPER_MODULE = fileURLToPath( new URL("./support/credential-helper-entry.ts", import.meta.url), ); +const CLOUDFLARE_ENTRYPOINT = fileURLToPath(new URL("../cloudflare.ts", import.meta.url)); describe("workflow published Deno entrypoint", () => { it("offers no route from the entrypoint to an authenticated invocation", function* () { @@ -153,10 +155,25 @@ describe("workflow published Deno entrypoint", () => { "useGitComposition", "denoGitAuthentication", "denoCredentialBroker", + "RemoteReadLink", + "cloudflareReadLink", + "stageCloudflareContent", ]) { expect(reachable).not.toContain(seam); } expect(COMPOSITION_IS_NOT_A_KEY).toBe(false); expect(yield* until(Promise.resolve(true))).toBe(true); }); + + it("keeps the Cloudflare private protocol out of its host entrypoint", function* () { + const source = yield* readTextFile(CLOUDFLARE_ENTRYPOINT); + for (const privateModule of [ + "commands.ts", + "acquisition.ts", + "dispatcher.ts", + "private-schema.ts", + ]) { + expect(source).not.toContain(privateModule); + } + }); }); diff --git a/packages/workflow/tests/remote-client.test.ts b/packages/workflow/tests/remote-client.test.ts new file mode 100644 index 000000000..a9f74f92d --- /dev/null +++ b/packages/workflow/tests/remote-client.test.ts @@ -0,0 +1,678 @@ +/** + * Tier WRH — carrying a request to a run's owner. + * + * Correlation and teardown are what this is about. A socket delivers what the + * owner sent whenever it sent it, so answers are matched by the id they name + * rather than by arrival order; and a connection that ends must fail the + * requests still waiting rather than leave a caller blocked on an answer that + * can never come. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped, sleep, spawn } from "effection"; +import { + type OwnerSocket, + OwnerLinkError, + type SocketListener, + MAX_MESSAGE_BYTES, + useOwnerConnection, +} from "../src/remote/client.ts"; + +/** These tests are about correlation, so most of them read any value. */ +function readString(value: unknown): unknown { + return value; +} + +/** A parser that refuses anything but a string, so a bad value fails the link. */ +function requireString(value: unknown): string { + if (typeof value !== "string") { + throw new Error("expected a string"); + } + return value; +} + +/** + * What the connection refused with, having proved it refused at all. + * + * A caught value is `unknown`, and asserting it into `OwnerLinkError` would let + * an unrelated failure read as the transport category a test expected. + */ +function refusalOf(error: unknown): string { + if (!(error instanceof OwnerLinkError)) { + throw new Error(`expected an OwnerLinkError, got ${String(error)}`); + } + return error.refusal; +} + +/** + * A socket a test drives by hand, and can ask what happened to it. + * + * It counts closes and tracks the listeners still installed, because the claims + * under test are about teardown: that the connection closes its socket exactly + * once and stops listening. A fake that merely retained its callbacks would let + * a test assert cleanup that never happened — which is how the previous version + * of this suite passed while the connection leaked both. + */ +function fakeSocket(options: { failSend?: boolean } = {}) { + const sent: Record[] = []; + const listeners = new Map>(); + let closes = 0; + + const deliver = (type: string, event: { data?: unknown }) => { + for (const listener of listeners.get(type) ?? []) { + listener(event); + } + }; + + const socket: OwnerSocket = { + send(data: string): void { + if (options.failSend === true) { + throw new Error("the socket refused the write"); + } + sent.push(JSON.parse(data)); + }, + close(): void { + closes += 1; + }, + addEventListener(type, listener): void { + const existing = listeners.get(type) ?? new Set(); + existing.add(listener); + listeners.set(type, existing); + }, + removeEventListener(type, listener): void { + listeners.get(type)?.delete(listener); + }, + }; + + return { + socket, + sent, + get closes(): number { + return closes; + }, + /** How many listeners are still installed, of any type. */ + get listening(): number { + return [...listeners.values()].reduce((total, set) => total + set.size, 0); + }, + answer(value: unknown): void { + deliver("message", { + data: typeof value === "string" ? value : JSON.stringify(value), + }); + }, + end(): void { + deliver("close", {}); + }, + error(): void { + deliver("error", {}); + }, + }; +} + +describe("a connection to a run's owner", () => { + it("sends the command with its id and answers the caller that asked", function* () { + const wire = fakeSocket(); + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const asking = yield* spawn(() => owner.ask("a1", { command: "frontier" }, readString)); + yield* sleep(0); + // The request is on the wire before any answer exists. + expect(wire.sent).toEqual([{ command: "frontier", id: "a1" }]); + wire.answer({ id: "a1", outcome: "performed", value: { root: "root-a" } }); + expect(yield* asking).toEqual({ outcome: "performed", value: { root: "root-a" } }); + }); + yield* sleep(0); + }); + + it("matches answers by the id they name, not by arrival order", function* () { + const wire = fakeSocket(); + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const first = yield* spawn(() => owner.ask("a1", { command: "frontier" }, readString)); + const second = yield* spawn(() => owner.ask("a2", { command: "settle" }, readString)); + yield* sleep(0); + // Both requests are on the wire before either is answered. + expect(wire.sent.map((request) => request.id)).toEqual(["a1", "a2"]); + + // Answered in the opposite order to the asking. + wire.answer({ id: "a2", outcome: "performed", value: "second" }); + wire.answer({ id: "a1", outcome: "performed", value: "first" }); + + expect(yield* first).toEqual({ outcome: "performed", value: "first" }); + expect(yield* second).toEqual({ outcome: "performed", value: "second" }); + }); + yield* sleep(0); + }); + + it("hands back a refusal as an answer rather than a transport failure", function* () { + const wire = fakeSocket(); + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const asking = yield* spawn(() => owner.ask("a1", { command: "commit" }, readString)); + yield* sleep(0); + wire.answer({ id: "a1", outcome: "refused", refusal: "acquisition:already-running" }); + expect(yield* asking).toEqual({ + outcome: "refused", + refusal: "acquisition:already-running", + }); + }); + yield* sleep(0); + }); + + it("fails a request still waiting when the connection ends", function* () { + const wire = fakeSocket(); + let raised: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const asking = yield* spawn(function* () { + try { + yield* owner.ask("a1", { command: "frontier" }, readString); + } catch (error) { + raised = error; + } + }); + wire.end(); + yield* asking; + }); + yield* sleep(0); + expect(raised).toBeInstanceOf(OwnerLinkError); + expect(refusalOf(raised)).toBe("closed"); + }); + + it("refuses to ask through a connection that already ended", function* () { + const wire = fakeSocket(); + let raised: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + wire.end(); + try { + yield* owner.ask("a1", { command: "frontier" }, readString); + } catch (error) { + raised = error; + } + }); + expect(refusalOf(raised)).toBe("closed"); + }); + + it("refuses a second request under an id already in flight", function* () { + const wire = fakeSocket(); + let raised: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + yield* spawn(() => owner.ask("a1", { command: "frontier" }, readString)); + yield* sleep(0); + try { + yield* owner.ask("a1", { command: "settle" }, readString); + } catch (error) { + raised = error; + } + wire.answer({ id: "a1", outcome: "performed", value: null }); + }); + expect(refusalOf(raised)).toBe("duplicate-answer"); + }); + + it("fails every waiter when it cannot read an answer", function* () { + const wire = fakeSocket(); + const raised: unknown[] = []; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const first = yield* spawn(function* () { + try { + yield* owner.ask("a1", { command: "frontier" }, readString); + } catch (error) { + raised.push(error); + } + }); + const second = yield* spawn(function* () { + try { + yield* owner.ask("a2", { command: "settle" }, readString); + } catch (error) { + raised.push(error); + } + }); + yield* sleep(0); + // A commit may already have landed on the owner. Dropping this and + // leaving both callers waiting is the failure mode being refused. + wire.answer("not json at all"); + yield* first; + yield* second; + }); + expect(raised).toHaveLength(2); + for (const error of raised) { + expect(refusalOf(error)).toBe("malformed-answer"); + } + expect(wire.closes).toBe(1); + expect(wire.listening).toBe(0); + }); + + it("ends on request, once, ahead of the scope that owns it", function* () { + const wire = fakeSocket(); + const raised: unknown[] = []; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const asking = yield* spawn(function* () { + try { + yield* owner.ask("a1", { command: "frontier" }, readString); + } catch (error) { + raised.push(error); + } + }); + yield* sleep(0); + + // A caller that has given up on this acquisition ends the connection + // rather than waiting for its scope: the socket is the acquisition, and + // one still open still holds the run. + owner.close(); + yield* asking; + expect(raised.map(refusalOf)).toEqual(["closed"]); + expect(wire.closes).toBe(1); + expect(wire.listening).toBe(0); + + // Nothing new goes out on it afterwards. + try { + yield* owner.ask("a2", { command: "frontier" }, readString); + } catch (error) { + raised.push(error); + } + expect(raised.map(refusalOf)).toEqual(["closed", "closed"]); + }); + // And the scope ending finds the teardown already done rather than + // closing a second time. + expect(wire.closes).toBe(1); + }); + + it("fails closed on an answer naming a request nobody made", function* () { + const wire = fakeSocket(); + let raised: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const asking = yield* spawn(function* () { + try { + yield* owner.ask("a1", { command: "frontier" }, readString); + } catch (error) { + raised = error; + } + }); + yield* sleep(0); + wire.answer({ id: "somebody-else", outcome: "performed", value: 1 }); + yield* asking; + }); + expect(refusalOf(raised)).toBe("unknown-answer"); + }); + + it("tells the asker why its own answer was unreadable, and everyone else the channel ended", function* () { + const wire = fakeSocket(); + const raised: unknown[] = []; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const first = yield* spawn(function* () { + try { + yield* owner.ask("a1", { command: "frontier" }, requireString); + } catch (error) { + raised.push(error); + } + }); + const second = yield* spawn(function* () { + try { + yield* owner.ask("a2", { command: "settle" }, requireString); + } catch (error) { + raised.push(error); + } + }); + yield* sleep(0); + // Performed, and the value is not what the command's parser reads. The + // caller must not receive it, and the other waiter must not be left. + wire.answer({ id: "a1", outcome: "performed", value: { not: "a string" } }); + yield* first; + yield* second; + }); + expect(raised).toHaveLength(2); + // The request whose answer failed keeps the parser's own failure: the + // boundary above it can only classify what a value meant if it still holds + // the failure that said so. Reporting an unreachable owner here would be + // untrue — the owner answered, and this build could not read it. + const asker = raised.find((error) => !(error instanceof OwnerLinkError)); + expect(String(asker)).toContain("expected a string"); + // Nothing else is true for the other waiter except that the channel ended. + const other = raised.filter((error) => error !== asker); + expect(other.map(refusalOf)).toEqual(["malformed-answer"]); + expect(wire.closes).toBe(1); + expect(wire.listening).toBe(0); + }); + + it("still delivers a refusal without consulting the success parser", function* () { + const wire = fakeSocket(); + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const asking = yield* spawn(() => + owner.ask("a1", { command: "commit" }, () => { + throw new Error("a refusal must not reach this"); + }), + ); + yield* sleep(0); + wire.answer({ id: "a1", outcome: "refused", refusal: "acquisition:already-running" }); + expect(yield* asking).toEqual({ + outcome: "refused", + refusal: "acquisition:already-running", + }); + }); + }); + + it("refuses a refusal that is not a category this side can branch on", function* () { + const wire = fakeSocket(); + let raised: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const asking = yield* spawn(function* () { + try { + yield* owner.ask("a1", { command: "frontier" }, readString); + } catch (error) { + raised = error; + } + }); + yield* sleep(0); + // An arbitrary remote sentence must not become this side's public failure + // identity, so it is read as an answer this build cannot understand. + wire.answer({ id: "a1", outcome: "refused", refusal: "something went wrong!" }); + yield* asking; + }); + expect(refusalOf(raised)).toBe("malformed-answer"); + }); + + it("refuses an answer whose correlation id is not one", function* () { + const wire = fakeSocket(); + let raised: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const asking = yield* spawn(function* () { + try { + yield* owner.ask("a1", { command: "frontier" }, readString); + } catch (error) { + raised = error; + } + }); + yield* sleep(0); + wire.answer({ id: "x".repeat(200), outcome: "performed", value: 1 }); + yield* asking; + }); + expect(refusalOf(raised)).toBe("malformed-answer"); + }); + + it("closes the socket once and stops listening when its scope ends", function* () { + const wire = fakeSocket(); + let answered = false; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + expect(wire.listening).toBeGreaterThan(0); + yield* spawn(function* () { + yield* owner.ask("a1", { command: "frontier" }, readString); + answered = true; + }); + yield* sleep(0); + // Leaving with a request in flight. The connection is the acquisition, so + // the owner only learns this runner is gone when the socket closes. + }); + + expect(wire.closes).toBe(1); + expect(wire.listening).toBe(0); + expect(answered).toBe(false); + + // A late message and a late close reach nothing and raise nothing. + wire.answer({ id: "a1", outcome: "performed", value: "too late" }); + wire.end(); + expect(answered).toBe(false); + expect(wire.closes).toBe(1); + }); + + it("ends the same way however the connection is lost", function* () { + // Each of these is one teardown with one owner: the waiters learn why, the + // listeners go, and the socket closes exactly once. + const cases: [string, (wire: ReturnType) => void][] = [ + ["closed", (wire) => wire.end()], + ["socket-error", (wire) => wire.error()], + ["malformed-answer", (wire) => wire.answer("not json at all")], + ["unknown-answer", (wire) => wire.answer({ id: "nobody", outcome: "performed", value: 1 })], + ]; + + for (const [expected, provoke] of cases) { + const wire = fakeSocket(); + let raised: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const asking = yield* spawn(function* () { + try { + yield* owner.ask("a1", { command: "frontier" }, readString); + } catch (error) { + raised = error; + } + }); + yield* sleep(0); + provoke(wire); + yield* asking; + }); + expect(refusalOf(raised)).toBe(expected); + expect(wire.closes).toBe(1); + expect(wire.listening).toBe(0); + } + }); + + it("keeps the failure that caused teardown when a close follows it", function* () { + const wire = fakeSocket(); + let raised: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const asking = yield* spawn(function* () { + try { + yield* owner.ask("a1", { command: "frontier" }, readString); + } catch (error) { + raised = error; + } + }); + yield* sleep(0); + wire.answer("not json at all"); + // The remote end closes right after. The caller should still learn what + // actually went wrong rather than a generic `closed`. + wire.end(); + yield* asking; + }); + expect(refusalOf(raised)).toBe("malformed-answer"); + expect(wire.closes).toBe(1); + }); + + it("tears down when the socket refuses the write, and sends nothing", function* () { + const wire = fakeSocket({ failSend: true }); + let raised: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + try { + yield* owner.ask("a1", { command: "frontier" }, readString); + } catch (error) { + raised = error; + } + }); + expect(refusalOf(raised)).toBe("send-failed"); + expect(wire.sent).toEqual([]); + expect(wire.closes).toBe(1); + expect(wire.listening).toBe(0); + }); + + it("refuses a request larger than one message, before it is outstanding", function* () { + const wire = fakeSocket(); + let raised: unknown; + let reused: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + try { + // Under the bound on its own; over it once the correlation id and the + // framing around it are counted. Measuring one member instead would + // let exactly this request through. + yield* owner.ask( + "over", + { command: "retrieval", metadata: "m".repeat(MAX_MESSAGE_BYTES - 40) }, + readString, + ); + } catch (error) { + raised = error; + } + // The id never became outstanding, so it is still usable. A request that + // was registered and then refused would fail here as a duplicate. + try { + yield* spawn(function* () { + yield* owner.ask("over", { command: "frontier" }, readString); + }); + yield* sleep(0); + } catch (error) { + reused = error; + } + }); + expect(refusalOf(raised)).toBe("too-large"); + expect(reused).toBe(undefined); + // Exactly one message left: the small one. + expect(wire.sent).toEqual([{ id: "over", command: "frontier" }]); + }); + + it("refuses to send a correlation id it would refuse to read", function* () { + const wire = fakeSocket(); + let raised: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + try { + yield* owner.ask("", { command: "frontier" }, readString); + } catch (error) { + raised = error; + } + expect(refusalOf(raised)).toBe("malformed-request"); + try { + yield* owner.ask("x".repeat(200), { command: "frontier" }, readString); + } catch (error) { + raised = error; + } + }); + expect(refusalOf(raised)).toBe("malformed-request"); + // Nothing left, so nothing to correlate an answer to. + expect(wire.sent).toEqual([]); + }); + + it("refuses an answer whose branch carries a member it does not declare", function* () { + const cases: unknown[] = [ + { id: "a1", outcome: "performed", value: 1, refusal: "acquisition:stale" }, + { id: "a1", outcome: "refused", refusal: "acquisition:stale", value: 1 }, + { id: "a1", outcome: "performed" }, + { id: "a1", outcome: "refused" }, + { id: "a1", outcome: "performed", value: 1, extra: true }, + ]; + for (const answer of cases) { + const wire = fakeSocket(); + let raised: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const asking = yield* spawn(function* () { + try { + yield* owner.ask("a1", { command: "frontier" }, readString); + } catch (error) { + raised = error; + } + }); + yield* sleep(0); + wire.answer(answer); + yield* asking; + }); + expect(refusalOf(raised)).toBe("malformed-answer"); + } + }); + + it("releases the socket when the scope holding it is cancelled", function* () { + const wire = fakeSocket(); + let raised: unknown; + yield* scoped(function* () { + const holding = yield* spawn(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + try { + yield* owner.ask("a1", { command: "frontier" }, readString); + } catch (error) { + raised = error; + } + }); + yield* sleep(0); + expect(wire.listening).toBeGreaterThan(0); + // Cancellation, rather than the scope reaching its end. The connection is + // the acquisition either way, so the socket must still close. + yield* holding.halt(); + }); + expect(wire.closes).toBe(1); + expect(wire.listening).toBe(0); + // Halting the caller means it is never told anything; the socket closing is + // what the owner observes. + expect(raised).toBe(undefined); + }); + + it("carries a refusal category this build has never heard of", function* () { + const wire = fakeSocket(); + let answered: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const asking = yield* spawn(() => owner.ask("a1", { command: "commit" }, readString)); + yield* sleep(0); + // Well-spelled and not a category this layer knows. Deciding which + // categories exist belongs to the adapter that declares the union, so the + // connection hands it through rather than guessing on the adapter's + // behalf and failing a run over a word. + wire.answer({ id: "a1", outcome: "refused", refusal: "workspace:root-unknown-here" }); + answered = yield* asking; + }); + expect(answered).toEqual({ outcome: "refused", refusal: "workspace:root-unknown-here" }); + expect(wire.closes).toBe(1); + }); + + it("fails closed on a second answer to a request already settled", function* () { + const wire = fakeSocket(); + let answered: unknown; + let refused: unknown; + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + const first = yield* spawn(() => owner.ask("a1", { command: "frontier" }, readString)); + yield* sleep(0); + wire.answer({ id: "a1", outcome: "performed", value: "once" }); + answered = yield* first; + + const second = yield* spawn(function* () { + try { + yield* owner.ask("a2", { command: "settle" }, readString); + } catch (error) { + refused = error; + } + }); + yield* sleep(0); + // The owner answers `a1` again. Correlation has broken. + wire.answer({ id: "a1", outcome: "performed", value: "twice" }); + yield* second; + }); + expect(answered).toEqual({ outcome: "performed", value: "once" }); + expect(refusalOf(refused)).toBe("duplicate-answer"); + expect(wire.closes).toBe(1); + expect(wire.listening).toBe(0); + }); +}); diff --git a/packages/workflow/tests/remote-database.test.ts b/packages/workflow/tests/remote-database.test.ts new file mode 100644 index 000000000..42820b40a --- /dev/null +++ b/packages/workflow/tests/remote-database.test.ts @@ -0,0 +1,485 @@ +/** + * Tier WRH — one run's storage, owned somewhere else. + * + * The interface is the same one the local host answers, so what is under test + * is conformance rather than mechanism: a snapshot stays a snapshot, a nested + * transaction refuses while unrelated work waits its turn, a closed handle is + * closed, and every read is a fresh anchored one rather than a cache. + * + * The owner is a deterministic fake. What it is standing in for — atomic + * application, authoritative revisions, anchored SQLite ordering — is proved on + * real workerd; what is proved here is the handle's own behaviour, which is + * arithmetic over what the owner said. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import { Err, Ok, type Operation, type Result, scoped, sleep, spawn } from "effection"; +import type { WorkflowRunDatabase, WorkflowRunTransaction } from "../src/storage/api.ts"; +import { WorkflowDatabaseClosedError, WorkflowTransactionError } from "../src/storage/errors.ts"; +import type { DefinitionRetrieval, DocumentExecutionRecord } from "../src/storage/record.ts"; +import type { CommitIntent, StartingFrontier } from "../src/remote/collector.ts"; +import type { CommitDecision } from "../src/remote/publication.ts"; +import { + activeWorkspaceRoute, + type RemoteRunLink, + useRemoteRunDatabase, +} from "../src/remote/database.ts"; +import type { RemoteFrontierSnapshot } from "../src/remote/read.ts"; +import type { RemoteRetainedAnswer } from "../src/remote/answer-link.ts"; + +const ROOT = "a".repeat(64); +const RUN_ID = "remote-run"; + +function event(name: string): DurableEvent { + return { + type: "yield", + coroutineId: "root", + description: { type: "test", name }, + result: { status: "ok", value: name }, + }; +} + +function frontierOf(entries: { eventId: string; event: DurableEvent }[]): RemoteFrontierSnapshot { + return { + record: { + runId: RUN_ID, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + status: "running", + createdAt: "2026-09-04T00:00:00.000Z", + updatedAt: "2026-09-04T00:00:00.000Z", + }, + retrieval: undefined, + workspaceRootId: ROOT, + journalEventId: entries.at(-1)?.eventId ?? null, + entries: entries.map((entry) => ({ ...entry, workspaceRootId: ROOT })), + }; +} + +/** An owner that answers from what it has been told, and records what it was asked. */ +function owner( + options: { + retrieval?: (metadata: string | null) => Result; + executions?: () => Result; + commit?: (intent: CommitIntent) => Result; + } = {}, +) { + const retained: { eventId: string; event: DurableEvent }[] = []; + const commits: CommitIntent[] = []; + const retrievals: (string | null)[] = []; + let frontierReads = 0; + + const link: RemoteRunLink = { + *frontier(): Operation { + const snapshot = frontierOf(retained); + return { + workspaceRootId: snapshot.workspaceRootId, + journalEventId: snapshot.journalEventId, + events: snapshot.entries.map((entry) => entry.event), + }; + }, + // deno-lint-ignore require-yield + *pendingAnswer(): Operation> { + // Nothing is delivered to these runs. A handle that answered otherwise + // would be claiming for a wait no test here reaches. + return Ok(undefined); + }, + // deno-lint-ignore require-yield + *frontierSnapshot(): Operation { + frontierReads += 1; + return frontierOf(retained); + }, + // deno-lint-ignore require-yield + *commit(intent: CommitIntent): Operation> { + commits.push(intent); + if (options.commit !== undefined) { + return options.commit(intent); + } + const ids = intent.events.map((_entry, index) => `event-${retained.length + index}`); + for (const [index, offered] of intent.events.entries()) { + retained.push({ eventId: ids[index] ?? "", event: offered }); + } + return Ok({ workspaceRootId: intent.expectedWorkspaceRootId, journalEventIds: ids }); + }, + // deno-lint-ignore require-yield + *replaceRetrieval( + _expected: string, + metadata: string | null, + ): Operation> { + retrievals.push(metadata); + return options.retrieval === undefined + ? Ok( + metadata === null + ? undefined + : { + metadata: JSON.parse(metadata) as Json, + revision: retrievals.filter((entry) => entry !== null).length, + updatedAt: "2026-09-04T00:00:01.000Z", + }, + ) + : options.retrieval(metadata); + }, + // deno-lint-ignore require-yield + *readExecutions(): Operation> { + return options.executions === undefined ? Ok([]) : options.executions(); + }, + }; + + return { + link, + commits, + retrievals, + retained, + get frontierReads(): number { + return frontierReads; + }, + /** An event the owner retained without this handle asking. */ + appendElsewhere(name: string): void { + retained.push({ eventId: `outside-${retained.length}`, event: event(name) }); + }, + }; +} + +function useDatabase(link: RemoteRunLink): Operation { + return useRemoteRunDatabase(link, frontierOf([])); +} + +function ok(result: Result): T { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +describe("a run whose storage is somewhere else", () => { + it("initializes its snapshots from one frontier and does not refresh them", function* () { + const remote = owner(); + yield* scoped(function* () { + const database = yield* useDatabase(remote.link); + expect(database.record.runId).toBe(RUN_ID); + expect(database.retrieval).toBe(undefined); + + remote.appendElsewhere("written by somebody else"); + // A read consults the owner; the handle's own snapshots do not move. + expect(yield* database.journal.readAll()).toHaveLength(1); + expect(database.record.runId).toBe(RUN_ID); + expect(database.retrieval).toBe(undefined); + }); + }); + + it("reads a fresh journal every time and never serves a cache", function* () { + const remote = owner(); + yield* scoped(function* () { + const database = yield* useDatabase(remote.link); + expect(yield* database.journal.readAll()).toEqual([]); + remote.appendElsewhere("later"); + expect(yield* database.journal.readAll()).toHaveLength(1); + const entries = ok(yield* database.readJournalEntries()); + // The entry snapshot carries what the journal alone cannot: the owner's + // identity for the row and the root it was written against. + expect(entries[0]?.eventId).toBe("outside-0"); + expect(entries[0]?.workspaceRootId).toBe(ROOT); + expect(remote.frontierReads).toBe(3); + }); + }); + + it("appends through the one commit path, as a journal-only transaction", function* () { + const remote = owner(); + yield* scoped(function* () { + const database = yield* useDatabase(remote.link); + yield* database.journal.append(event("appended")); + expect(remote.commits).toHaveLength(1); + expect(remote.commits[0]?.publication).toBe(null); + expect(remote.commits[0]?.events).toHaveLength(1); + expect(yield* database.journal.readAll()).toHaveLength(1); + }); + }); + + it("shows a transaction its own writes, and commits them once", function* () { + const remote = owner(); + yield* scoped(function* () { + const database = yield* useDatabase(remote.link); + remote.appendElsewhere("already there"); + const outcome = ok( + yield* database.transact(function* (transaction) { + yield* transaction.journal.append(event("mine")); + // Read-your-writes: the admitted prefix, then this transaction's own. + const seen = yield* transaction.journal.readAll(); + expect(seen).toHaveLength(2); + return "body value"; + }), + ); + expect(outcome).toBe("body value"); + expect(remote.commits).toHaveLength(1); + }); + }); + + it("refuses a nested transaction and any same-handle operation inside a body", function* () { + const remote = owner(); + yield* scoped(function* () { + const database = yield* useDatabase(remote.link); + const refusals: unknown[] = []; + ok( + yield* database.transact(function* () { + const nested = yield* database.transact(function* () { + return "never"; + }); + refusals.push(nested.ok ? undefined : nested.error); + const read = yield* database.readJournalEntries(); + refusals.push(read.ok ? undefined : read.error); + try { + yield* database.journal.append(event("from inside")); + } catch (error) { + refusals.push(error); + } + return "done"; + }), + ); + expect(refusals).toHaveLength(3); + for (const refusal of refusals) { + expect(refusal).toBeInstanceOf(WorkflowTransactionError); + } + // None of them reached the owner. + expect(remote.commits).toHaveLength(1); + }); + }); + + it("lets unrelated work wait its turn rather than refusing it", function* () { + const remote = owner(); + yield* scoped(function* () { + const database = yield* useDatabase(remote.link); + const order: string[] = []; + const holding = yield* spawn(() => + database.transact(function* () { + order.push("transaction started"); + yield* sleep(5); + order.push("transaction finishing"); + return "held"; + }), + ); + yield* sleep(0); + // A different scope, not a descendant of the body. + const waiting = yield* spawn(function* () { + const entries = yield* database.readJournalEntries(); + order.push(entries.ok ? "read succeeded" : "read refused"); + }); + yield* holding; + yield* waiting; + expect(order).toEqual(["transaction started", "transaction finishing", "read succeeded"]); + }); + }); + + it("does not let another handle inherit this one's open transaction", function* () { + const remote = owner(); + yield* scoped(function* () { + const first = yield* useDatabase(remote.link); + const second = yield* useDatabase(remote.link); + const outcome = ok( + yield* first.transact(function* () { + // A transaction on one handle says nothing about another. + const other = yield* second.readJournalEntries(); + return other.ok ? "second read" : "second refused"; + }), + ); + expect(outcome).toBe("second read"); + }); + }); + + it("refuses every member once its scope has ended", function* () { + const remote = owner(); + let database: WorkflowRunDatabase | undefined; + yield* scoped(function* () { + database = yield* useDatabase(remote.link); + }); + if (database === undefined) { + throw new Error("expected a handle"); + } + const closed = database; + const results = [ + yield* closed.readJournalEntries(), + yield* closed.replaceRetrievalMetadata({ where: "later" }), + yield* closed.readDocumentExecutions(), + yield* closed.transact(function* () { + return "never"; + }), + ]; + for (const result of results) { + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(WorkflowDatabaseClosedError); + } + } + let raised: unknown; + try { + yield* closed.journal.append(event("after close")); + } catch (error) { + raised = error; + } + expect(raised).toBeInstanceOf(WorkflowDatabaseClosedError); + // Nothing reached the owner after the handle closed. + expect(remote.commits).toHaveLength(0); + expect(remote.retrievals).toHaveLength(0); + }); + + it("updates only the handle whose replacement succeeded", function* () { + const remote = owner(); + yield* scoped(function* () { + const first = yield* useDatabase(remote.link); + const second = yield* useDatabase(remote.link); + ok(yield* first.replaceRetrievalMetadata({ locator: "https://example.invalid/x.git" })); + expect(first.retrieval?.revision).toBe(1); + // Another handle's replacement is not this handle's snapshot. + expect(second.retrieval).toBe(undefined); + + // Two calls carrying identical metadata are two replacements. + ok(yield* first.replaceRetrievalMetadata({ locator: "https://example.invalid/x.git" })); + expect(first.retrieval?.revision).toBe(2); + + ok(yield* first.replaceRetrievalMetadata(undefined)); + expect(first.retrieval).toBe(undefined); + expect(remote.retrievals).toEqual([ + '{"locator":"https://example.invalid/x.git"}', + '{"locator":"https://example.invalid/x.git"}', + null, + ]); + }); + }); + + it("canonicalizes metadata before it is sent", function* () { + const remote = owner(); + yield* scoped(function* () { + const database = yield* useDatabase(remote.link); + ok(yield* database.replaceRetrievalMetadata({ b: 1, a: { d: 2, c: 3 } })); + // Sorted keys and no incidental whitespace, so two callers writing the + // same metadata write the same bytes. + expect(remote.retrievals[0]).toBe('{"a":{"c":3,"d":2},"b":1}'); + }); + }); + + it("refuses metadata that is not a JSON value, without asking the owner", function* () { + const remote = owner(); + yield* scoped(function* () { + const database = yield* useDatabase(remote.link); + const offered = { locator: () => "not json" } as unknown as Json; + const refused = yield* database.replaceRetrievalMetadata(offered); + expect(refused.ok).toBe(false); + // Nothing was sent: an inadmissible value is not a request. + expect(remote.retrievals).toHaveLength(0); + expect(database.retrieval).toBe(undefined); + }); + }); + + it("fails closed when the answer describes another replacement", function* () { + const remote = owner({ + retrieval: () => + Ok({ + metadata: { locator: "something else entirely" }, + revision: 1, + updatedAt: "2026-09-04T00:00:01.000Z", + }), + }); + yield* scoped(function* () { + const database = yield* useDatabase(remote.link); + const refused = yield* database.replaceRetrievalMetadata({ locator: "what was asked" }); + expect(refused.ok).toBe(false); + // The snapshot is what it was: an answer about another value installs + // nothing, because it would change where the definition is fetched from. + expect(database.retrieval).toBe(undefined); + }); + }); + + it("leaves its snapshot alone when a replacement is refused", function* () { + const remote = owner({ + retrieval: () => Err(new WorkflowTransactionError("this run has moved")), + }); + yield* scoped(function* () { + const database = yield* useDatabase(remote.link); + const refused = yield* database.replaceRetrievalMetadata({ locator: "x" }); + expect(refused.ok).toBe(false); + expect(database.retrieval).toBe(undefined); + }); + }); + + it("hands a Workspace route only to the exact database and transaction", function* () { + const remote = owner(); + yield* scoped(function* () { + const database = yield* useDatabase(remote.link); + const other = yield* useDatabase(remote.link); + let held: WorkflowRunTransaction | undefined; + ok( + yield* database.transact(function* (transaction) { + held = transaction; + expect(yield* activeWorkspaceRoute(database, transaction)).not.toBe(undefined); + // A foreign database, or a transaction object that is not this one. + expect(yield* activeWorkspaceRoute(other, transaction)).toBe(undefined); + expect(yield* activeWorkspaceRoute(database, { journal: transaction.journal })).toBe( + undefined, + ); + return "done"; + }), + ); + if (held === undefined) { + throw new Error("expected a transaction"); + } + // Outside the body the route is gone, so a retained object reaches nothing. + expect(yield* activeWorkspaceRoute(database, held)).toBe(undefined); + }); + }); + + it("sends no commit when the body fails, and answers with the refusal when the owner does", function* () { + const failing = owner({ commit: () => Err(new WorkflowTransactionError("owner refused")) }); + yield* scoped(function* () { + const database = yield* useDatabase(failing.link); + const refused = yield* database.transact(function* (transaction) { + yield* transaction.journal.append(event("attempted")); + return "never returned"; + }); + expect(refused.ok).toBe(false); + }); + + const raising = owner(); + yield* scoped(function* () { + const database = yield* useDatabase(raising.link); + // A body that raised is a failed transaction, not a raised one: the + // interface answers with a `Result`, and the same condition returns + // `Err` from the local provider. + const failed = yield* database.transact(function* (transaction) { + yield* transaction.journal.append(event("attempted")); + throw new Error("the body failed"); + }); + expect(failed.ok).toBe(false); + if (!failed.ok) { + expect(String(failed.error)).toContain("the body failed"); + } + expect(raising.commits).toHaveLength(0); + // And the handle is still usable afterwards. + expect(ok(yield* database.readJournalEntries())).toEqual([]); + }); + }); + + it("returns the executions the owner assembled, in order", function* () { + const executions: DocumentExecutionRecord[] = [ + { executionId: "one", startedAt: "2026-09-04T00:00:00.000Z" }, + { + executionId: "two", + startedAt: "2026-09-04T00:00:01.000Z", + stoppedAt: "2026-09-04T00:00:02.000Z", + stopStatus: "completed", + }, + ]; + const remote = owner({ executions: () => Ok(executions) }); + yield* scoped(function* () { + const database = yield* useDatabase(remote.link); + expect(ok(yield* database.readDocumentExecutions())).toEqual(executions); + }); + }); +}); diff --git a/packages/workflow/tests/remote-delivery.test.ts b/packages/workflow/tests/remote-delivery.test.ts new file mode 100644 index 000000000..54cb386fd --- /dev/null +++ b/packages/workflow/tests/remote-delivery.test.ts @@ -0,0 +1,696 @@ +/** + * Tier WAD — answering a durable wait on a run somebody else owns. + * + * Two halves that must not be confused, and neither is observable alone. + * **Delivery** happens while nothing is running: it asks the owner what the run + * is waiting at, judges the offered value against exactly that, crosses the + * secret gate, and asks the owner to retain it. **The claim** is what spends + * it: an execution standing at that same wait publishes one answer event and + * enlists the consumption in the same transaction, and the value reaches the + * document only once the owner has committed both. + * + * The owner-side facts — that the retention is one row written in one + * transaction, that a refusal writes nothing, and that consuming and appending + * commit together — are proved against a real Durable Object in + * `tests/cloudflare/remote-delivery.vitest.ts`. These are the runner's half: + * what is judged before anything is sent, what is sent, and what a claim + * enlists. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { call, Err, Ok, type Operation, race, type Result, scoped } from "effection"; +import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import { collect, inlineSource, prepareElicitation, registerComponents } from "@executablemd/core"; +import type { JsonObject } from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import { retainedWorkflowInstallation } from "../src/run.ts"; +import { installRemoteInputDelivery } from "../src/remote/delivery.ts"; +import { installRemoteSuspensionAnswers } from "../src/remote/answers.ts"; +import type { + RemoteAnswerRetained, + RemoteAnswerRetention, + RemoteDeliveryLink, + RemoteRetainedAnswer, + RemoteRetainedWaitRecord, +} from "../src/remote/answer-link.ts"; +import type { CommitIntent, StartingFrontier } from "../src/remote/collector.ts"; +import type { CommitDecision } from "../src/remote/publication.ts"; +import { useRemoteRunDatabase, type RemoteRunLink } from "../src/remote/database.ts"; +import { routeRemoteRunJournal } from "../src/remote/journal-route.ts"; +import type { RemoteFrontierSnapshot } from "../src/remote/read.ts"; +import { WorkflowInputDelivery, type WorkflowAnswerRetention } from "../src/suspension/delivery.ts"; +import { SUSPENSION_ANSWER, SUSPENSION_REQUEST } from "../src/suspension/effects.ts"; +import { suspensionRequestFingerprint } from "../src/suspension/api.ts"; +import { suspendFor } from "../src/suspension/suspend.ts"; +import { createSuspensionController } from "../src/deno/suspension.ts"; +import type { SuspensionNotice } from "../src/deno/suspension.ts"; +import type { DefinitionRetrieval, DocumentExecutionRecord } from "../src/storage/record.ts"; +import { establishJournalProvenance } from "@executablemd/durable-streams"; + +const RUN_ID = "release-1.4"; +const SUSPENSION = "wait-1"; +const REQUEST_EVENT = "event-request"; +const SCHEMA = { + type: "object", + properties: { approved: { type: "boolean" }, note: { type: "string" } }, + required: ["approved"], + additionalProperties: false, +}; +const REQUEST = { kind: "approval", release: "1.4" }; +const SECOND = { kind: "approval", release: "1.5" }; +const ANSWER: Json = { approved: true }; +const ROOT = "a".repeat(64); + +/** + * A synthetic credential, assembled at run time. + * + * Written out as a literal it would be rejected by push protection, and joining + * the parts leaves the runtime value identical — so what the scanner sees here + * is exactly what it would see in a delivered answer. + */ +const CANARY = `ghp_${"abcdefghijklmnopqrstuvwxyz0123456789".slice(0, 36)}`; + +const FINGERPRINT = suspensionRequestFingerprint({ request: REQUEST, responseSchema: SCHEMA }); + +/** What one retained event describes, for events that describe anything. */ +function describedType(event: DurableEvent): string { + return event.type === "yield" ? event.description.type : event.type; +} + +function waitRecord(overrides: Partial = {}): RemoteRetainedWaitRecord { + return { + runId: RUN_ID, + suspensionId: SUSPENSION, + requestEventId: REQUEST_EVENT, + request: REQUEST, + responseSchema: SCHEMA, + requestFingerprint: FINGERPRINT, + ...overrides, + }; +} + +/** A scripted delivery plane, which records everything it was asked. */ +function scriptedLink( + script: { + wait?: Result; + retain?: Result; + } = {}, +) { + const asked: string[] = []; + const retained: RemoteAnswerRetention[] = []; + const link: RemoteDeliveryLink = { + // deno-lint-ignore require-yield + *wait(): Operation> { + asked.push("wait"); + return script.wait ?? Ok(waitRecord()); + }, + // deno-lint-ignore require-yield + *retain(retention: RemoteAnswerRetention): Operation> { + asked.push("retain"); + retained.push(retention); + return script.retain ?? Ok({ runId: retention.runId, suspensionId: retention.suspensionId }); + }, + }; + return { link, asked, retained }; +} + +function delivered( + link: RemoteDeliveryLink, + request: { runId?: string; suspensionId?: string; value?: Json; secretDetection?: boolean } = {}, +): Operation> { + return scoped(function* () { + yield* installRemoteInputDelivery(link); + return yield* WorkflowInputDelivery.operations.deliver({ + runId: request.runId ?? RUN_ID, + suspensionId: request.suspensionId ?? SUSPENSION, + value: request.value ?? ANSWER, + secretDetection: request.secretDetection ?? true, + }); + }); +} + +describe("delivering one typed value to a remote run", () => { + it("judges the value against the wait the owner names, then retains it", function* () { + const scripted = scriptedLink(); + const outcome = yield* delivered(scripted.link); + + expect([outcome.ok, outcome.ok === false && String(outcome.error)]).toEqual([true, false]); + expect(outcome.ok && outcome.value).toEqual({ runId: RUN_ID, suspensionId: SUSPENSION }); + // The owner was asked what the run is waiting at before anything was sent + // for retention, and the retention names what the value was judged against. + expect(scripted.asked).toEqual(["wait", "retain"]); + // The retention carries the value and the gate decision and nothing else. + // There is no member saying the value was checked: the owner judges it. + expect(scripted.retained[0]).toEqual({ + runId: RUN_ID, + suspensionId: SUSPENSION, + answer: ANSWER, + secretDetection: true, + }); + }); + + it("refuses a value the retained schema does not admit, and retains nothing", function* () { + const scripted = scriptedLink(); + const outcome = yield* delivered(scripted.link, { value: { approved: "yes" } }); + + expect(outcome.ok).toBe(false); + expect(outcome.ok === false && outcome.error.message).toContain( + "does not satisfy the response", + ); + // Nothing crossed. A run must not hold a value it could never be given. + expect(scripted.asked).toEqual(["wait"]); + }); + + it("refuses a credential without repeating it, and retains it when told to", function* () { + const scanned = scriptedLink(); + const refused = yield* delivered(scanned.link, { + value: { approved: true, note: CANARY }, + }); + + expect(refused.ok).toBe(false); + const message = refused.ok === false ? refused.error.message : ""; + expect(message).toContain("secret detection matched it"); + // Neither the value nor the match travels with the refusal. + expect(message).not.toContain(CANARY); + expect(scanned.asked).toEqual(["wait"]); + + // The explicit opt-out is the only way past the gate, and it retains the + // value the caller offered. + const opted = scriptedLink(); + const retained = yield* delivered(opted.link, { + value: { approved: true, note: CANARY }, + secretDetection: false, + }); + expect(retained.ok).toBe(true); + expect(opted.retained[0]?.answer).toEqual({ approved: true, note: CANARY }); + // And the choice travels with it, because the owner applies the gate. + expect(opted.retained[0]?.secretDetection).toBe(false); + }); + + it("refuses a request that is not one, before the owner is reached", function* () { + const scripted = scriptedLink(); + const outcomes = yield* scoped(function* () { + yield* installRemoteInputDelivery(scripted.link); + return { + empty: yield* WorkflowInputDelivery.operations.deliver({ + runId: "", + suspensionId: SUSPENSION, + value: ANSWER, + secretDetection: true, + }), + unnamed: yield* WorkflowInputDelivery.operations.deliver({ + runId: RUN_ID, + suspensionId: "", + value: ANSWER, + secretDetection: true, + }), + }; + }); + + expect(outcomes.empty.ok).toBe(false); + expect(outcomes.unnamed.ok).toBe(false); + expect(scripted.asked).toEqual([]); + }); + + it("refuses an owner that answers about another wait", function* () { + const scripted = scriptedLink({ wait: Ok(waitRecord({ suspensionId: "wait-2" })) }); + const outcome = yield* delivered(scripted.link); + + expect(outcome.ok).toBe(false); + expect(outcome.ok === false && outcome.error.message).toContain("a different wait"); + expect(scripted.asked).toEqual(["wait"]); + }); + + it("refuses an owner whose fingerprint is not the request it returned", function* () { + const scripted = scriptedLink({ + wait: Ok(waitRecord({ requestFingerprint: "b".repeat(64) })), + }); + const outcome = yield* delivered(scripted.link); + + expect(outcome.ok).toBe(false); + // The value would have been judged against one request and retained against + // another. Nothing is sent. + expect(outcome.ok === false && outcome.error.message).toContain("a different request"); + expect(scripted.asked).toEqual(["wait"]); + }); + + it("refuses a retained request no wait could be answered for", function* () { + const scripted = scriptedLink({ wait: Ok(waitRecord({ responseSchema: [] })) }); + const outcome = yield* delivered(scripted.link); + + expect(outcome.ok).toBe(false); + expect(scripted.asked).toEqual(["wait"]); + }); + + it("reports what the owner refused, and nothing about how it was reached", function* () { + const scripted = scriptedLink({ retain: Err(new Error("this run is not waiting")) }); + const outcome = yield* delivered(scripted.link); + + expect(outcome.ok).toBe(false); + expect(scripted.asked).toEqual(["wait", "retain"]); + }); +}); + +describe("one judgment, wherever a response is judged", () => { + /** + * The cases the boundaries have to agree about. + * + * `multipleOf` is here because it is where the compiler this replaced and the + * settled draft-07 judgment disagreed: `0.3` is a multiple of `0.1`, and the + * value is now accepted everywhere rather than accepted at one boundary and + * refused at another. + */ + const cases: { name: string; schema: JsonObject; value: Json; admitted: boolean }[] = [ + { name: "a valid answer", schema: SCHEMA, value: ANSWER, admitted: true }, + { + name: "an answer the schema does not admit", + schema: SCHEMA, + value: { approved: "yes" }, + admitted: false, + }, + { + name: "a non-representable step", + schema: { type: "number", multipleOf: 0.1 }, + value: 0.3, + admitted: true, + }, + { + name: "a self-contained reference", + schema: { + definitions: { flag: { type: "boolean" } }, + type: "object", + properties: { approved: { $ref: "#/definitions/flag" } }, + required: ["approved"], + }, + value: { approved: true }, + admitted: true, + }, + { + name: "a self-contained reference the value fails", + schema: { + definitions: { flag: { type: "boolean" } }, + type: "object", + properties: { approved: { $ref: "#/definitions/flag" } }, + required: ["approved"], + }, + value: { approved: "yes" }, + admitted: false, + }, + ]; + + it("reaches the same verdict through the document path and remote delivery", function* () { + const verdicts: { name: string; document: boolean; remote: boolean }[] = []; + for (const example of cases) { + // What `` judges a provider's answer with, and what the local + // host judges a delivered answer with: one prepared validator. + const prepared = yield* prepareElicitation(example.schema, "workflow answer"); + const document = prepared.validator.judge(example.value).length === 0; + + // The remote delivery boundary, whole: a scripted owner returns the + // retained wait and the production installer judges the value. + const scripted = scriptedLink({ + wait: Ok( + waitRecord({ + request: REQUEST, + responseSchema: example.schema, + requestFingerprint: suspensionRequestFingerprint({ + request: REQUEST, + responseSchema: example.schema, + }), + }), + ), + }); + const outcome = yield* delivered(scripted.link, { + value: example.value, + secretDetection: false, + }); + verdicts.push({ name: example.name, document, remote: outcome.ok }); + } + + expect(verdicts.filter((verdict) => verdict.document !== verdict.remote)).toEqual([]); + expect(verdicts).toEqual( + cases.map((example) => ({ + name: example.name, + document: example.admitted, + remote: example.admitted, + })), + ); + }); + + /** One schema written as JSON, so every declared name survives. */ + function parsedSchema(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)) { + held[name] = Reflect.get(parsed, name); + } + return held; + } + + it("refuses a schema no answer can be judged against, before anything is sent", function* () { + const unusable: JsonObject[] = [ + { type: "object", properties: { decision: { $ref: "other.json#/x" } } }, + // Parsed rather than written as a literal: an object literal takes + // `__proto__` as the prototype and the key never exists. + parsedSchema('{"type":"object","properties":{"__proto__":{"type":"string"}}}'), + { type: "not-a-type" }, + { type: "object", nope: 1 }, + { $async: true, type: "object" }, + ]; + + for (const schema of unusable) { + const scripted = scriptedLink({ + wait: Ok( + waitRecord({ + responseSchema: schema, + requestFingerprint: suspensionRequestFingerprint({ + request: REQUEST, + responseSchema: schema, + }), + }), + ), + }); + const outcome = yield* delivered(scripted.link, { secretDetection: false }); + expect([JSON.stringify(schema), outcome.ok, scripted.asked]).toEqual([ + JSON.stringify(schema), + false, + ["wait"], + ]); + } + }); +}); + +/** + * One owner that retains what it is told, as the real protocol would. + * + * It keeps the journal the transaction commits, answers the frontier from it, + * and records the intents it received — which is what a claim has to be judged + * by, because what a claim does is propose one. + */ +function scriptedOwner(script: { pending?: RemoteRetainedAnswer | undefined } = {}) { + const events: { eventId: string; event: DurableEvent }[] = []; + const intents: CommitIntent[] = []; + const asked: string[] = []; + let minted = 0; + let pending = script.pending; + let refuse = false; + + const snapshot = (): RemoteFrontierSnapshot => ({ + record: { + runId: RUN_ID, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + retrieval: undefined, + workspaceRootId: ROOT, + journalEventId: events.at(-1)?.eventId ?? null, + entries: events.map((entry) => ({ + eventId: entry.eventId, + event: entry.event, + workspaceRootId: ROOT, + })), + }); + + const link: RemoteRunLink = { + // deno-lint-ignore require-yield + *frontier(): Operation { + const now = snapshot(); + return { + workspaceRootId: now.workspaceRootId, + journalEventId: now.journalEventId, + events: now.entries.map((entry) => entry.event), + }; + }, + // deno-lint-ignore require-yield + *frontierSnapshot(): Operation { + return snapshot(); + }, + // deno-lint-ignore require-yield + *pendingAnswer(suspensionId: string): Operation> { + asked.push(suspensionId); + return Ok(pending?.suspensionId === suspensionId ? pending : undefined); + }, + // deno-lint-ignore require-yield + *commit(intent: CommitIntent): Operation> { + intents.push(intent); + if (refuse) { + return Err(new Error("this owner refused the proposal")); + } + // What the owner does with a consumption: it spends the row, in the same + // transaction as the events, or neither. + if (intent.answer !== null) { + if (pending === undefined || pending.state !== "pending") { + return Err(new Error("there is no retained answer to spend")); + } + pending = { ...pending, state: "consumed" }; + } + const ids: string[] = []; + for (const event of intent.events) { + minted += 1; + const eventId = `event-${minted}`; + events.push({ eventId, event }); + ids.push(eventId); + } + return Ok({ workspaceRootId: ROOT, journalEventIds: ids }); + }, + // deno-lint-ignore require-yield + *replaceRetrieval(): Operation> { + return Ok(undefined); + }, + // deno-lint-ignore require-yield + *readExecutions(): Operation> { + return Ok([]); + }, + }; + + return { + link, + intents, + events, + asked, + waits(): string[] { + return events.flatMap((entry) => + describedType(entry.event) === SUSPENSION_REQUEST && entry.event.type === "yield" + ? [String(entry.event.description.name ?? "")] + : [], + ); + }, + get pending(): RemoteRetainedAnswer | undefined { + return pending; + }, + deliver(answer: Json, suspensionId: string, fingerprint = FINGERPRINT): void { + const request = events.find( + (entry) => + describedType(entry.event) === SUSPENSION_REQUEST && + entry.event.type === "yield" && + entry.event.description.name === suspensionId, + ); + pending = { + suspensionId, + requestEventId: request?.eventId ?? "", + requestFingerprint: fingerprint, + answer, + state: "pending", + }; + }, + refuseNext(): void { + refuse = true; + }, + }; +} + +interface Reached { + readonly notice: SuspensionNotice | undefined; + readonly returned: unknown; + readonly thrown: unknown; +} + +/** + * Run the waiting document once against a remote run, and settle what it did. + * + * The production pieces: the real remote handle over the scripted owner, the + * real routed journal and provenance, the real answer provider installed beside + * them, and the real suspension controller standing in for the executor. + */ +function reach(owner: ReturnType): Operation { + return scoped(function* () { + const database = yield* useRemoteRunDatabase(owner.link, yield* owner.link.frontierSnapshot()); + // The run's own journal underneath, so an append outside a transaction is + // a journal-only commit to the owner rather than something kept locally. + const stream = routeRemoteRunJournal(database, database.journal); + const provenance = establishJournalProvenance(stream); + yield* installRemoteSuspensionAnswers({ link: owner.link, database, provenance }); + + const suspension = createSuspensionController({ database }); + let thrown: unknown; + let returned: unknown; + let notice: SuspensionNotice | undefined; + + yield* registerComponents([ + { + name: "Probe", + origin: "tier-wad", + props: { type: "object", properties: {}, additionalProperties: false }, + // Two waits, so an answered one is behind a run that is still going. + // A document with one wait completes the moment it is answered, and a + // completed run replays nothing. + *fn() { + returned = yield* suspendFor({ request: REQUEST, responseSchema: SCHEMA }); + yield* suspendFor({ request: SECOND, responseSchema: SCHEMA }); + return ""; + }, + }, + ]); + + yield* race([ + call(function* (): Operation { + try { + yield* suspension.own( + call(function* (): Operation { + yield* collect( + yield* executeInstalled({ ...inlineSource("\n"), stream }, [ + retainedWorkflowInstallation({ + runId: RUN_ID, + base: "main", + pinnedCommit: "9fceb02d0ae598e95dc970b74767f19372d61af8", + }), + ]), + ); + }), + ); + } catch (error) { + thrown = error; + } + }), + call(function* (): Operation { + notice = yield* suspension.notice; + }), + ]); + + return { notice, returned, thrown }; + }); +} + +describe("a remote run's answer claim", () => { + it("waits when nothing is retained, and publishes no answer", function* () { + const owner = scriptedOwner(); + const reached = yield* reach(owner); + + expect(reached.notice?.suspensionId).toBeDefined(); + expect(reached.returned).toBe(undefined); + // One request event and nothing else. A wait nobody answered is a wait. + const published = owner.events.map((entry) => describedType(entry.event)); + expect(published).toContain(SUSPENSION_REQUEST); + expect(published).not.toContain(SUSPENSION_ANSWER); + expect(owner.intents.every((intent) => intent.answer === null)).toBe(true); + }); + + it("spends the retained answer and publishes exactly one answer event", function* () { + const owner = scriptedOwner(); + // One execution reaches the wait and stops; the value arrives afterwards. + yield* reach(owner); + const first = owner.waits()[0] ?? ""; + owner.deliver(ANSWER, first); + + const resumed = yield* reach(owner); + + // The document received the delivered value, and it received it from the + // wait rather than from a handler — then went on to its next wait. + expect(resumed.returned).toEqual(ANSWER); + expect(resumed.notice?.suspensionId).toBeDefined(); + expect(resumed.notice?.suspensionId).not.toBe(first); + // Exactly one answer event, behind the request it answers. + const answers = owner.events.filter( + (entry) => describedType(entry.event) === SUSPENSION_ANSWER, + ); + expect(answers).toHaveLength(1); + const answered = answers[0]?.event; + expect(answered !== undefined && answered.type === "yield" && answered.description.name).toBe( + first, + ); + expect(answered !== undefined && answered.type === "yield" && answered.result).toEqual({ + status: "ok", + value: ANSWER, + }); + const positions = owner.events.map((entry) => describedType(entry.event)); + expect(positions.indexOf(SUSPENSION_ANSWER)).toBeGreaterThan( + positions.indexOf(SUSPENSION_REQUEST), + ); + + // And the proposal that published it is the one that spent the row. + const spending = owner.intents.filter((intent) => intent.answer !== null); + expect(spending).toHaveLength(1); + expect(spending[0]?.answer?.suspensionId).toBe(first); + expect(spending[0]?.answer?.requestFingerprint).toBe(FINGERPRINT); + expect(spending[0]?.events).toHaveLength(1); + expect(spending[0]?.events.map((event) => serializeDurableEvent(event))).toEqual( + answers.map((entry) => serializeDurableEvent(entry.event)), + ); + expect(owner.pending?.state).toBe("consumed"); + }); + + it("leaves the answer pending when the owner refuses the proposal", function* () { + const owner = scriptedOwner(); + yield* reach(owner); + owner.deliver(ANSWER, owner.waits()[0] ?? ""); + owner.refuseNext(); + + const resumed = yield* reach(owner); + + // Nothing was returned to the document, nothing was published, and the + // answer is still there to be spent by a later execution. + expect(resumed.returned).toBe(undefined); + expect(owner.pending?.state).toBe("pending"); + expect( + owner.events.filter((entry) => describedType(entry.event) === SUSPENSION_ANSWER), + ).toHaveLength(0); + }); + + it("does not claim an answer delivered against a different request", function* () { + const owner = scriptedOwner(); + yield* reach(owner); + owner.deliver(ANSWER, owner.waits()[0] ?? "", "c".repeat(64)); + + const resumed = yield* reach(owner); + + // The wait is reached and stays a wait: this value answers something else. + expect(resumed.returned).toBe(undefined); + expect(resumed.notice?.suspensionId).toBe(owner.waits()[0]); + expect(owner.pending?.state).toBe("pending"); + expect(owner.intents.every((intent) => intent.answer === null)).toBe(true); + }); + + it("replays a published answer without reading or spending anything again", function* () { + const owner = scriptedOwner(); + yield* reach(owner); + const first = owner.waits()[0] ?? ""; + owner.deliver(ANSWER, first); + yield* reach(owner); + const published = owner.events.length; + const asked = owner.asked.length; + + const replayed = yield* reach(owner); + + // The answer came back from the journal. Nothing was published, nothing + // was spent, and the retained state was never asked about for that wait. + expect(replayed.returned).toEqual(ANSWER); + expect(owner.events).toHaveLength(published); + expect(owner.intents.filter((intent) => intent.answer !== null)).toHaveLength(1); + expect(owner.asked.slice(asked)).not.toContain(first); + }); +}); diff --git a/packages/workflow/tests/remote-fork.test.ts b/packages/workflow/tests/remote-fork.test.ts new file mode 100644 index 000000000..258f80ffa --- /dev/null +++ b/packages/workflow/tests/remote-fork.test.ts @@ -0,0 +1,699 @@ +/** + * Tier WRH — how a remote fork crosses from a source snapshot to a destination. + * + * That the destination commits whole or not at all, and that its copied prefix + * outlives the source, are owner facts and are proved against a real Durable + * Object in `tests/cloudflare/remote-fork.vitest.ts`. These are the runner's + * half: that the source is read through the no-acquisition plane, that every + * member of the snapshot is offered before anything is committed, and that what + * the final command claims is what was actually offered. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { type Operation, scoped, spawn, withResolvers } from "effection"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import { WorkflowLifecycle } from "../src/lifecycle/api.ts"; +import type { ExecutorLock } from "../src/lifecycle/api.ts"; +import type { WorkflowForkRequest } from "../src/lifecycle/execution.ts"; +import type { WorkflowExecutionTransitions } from "../src/lifecycle/execution.ts"; +import { useRemoteLifecycle } from "../src/remote/lifecycle.ts"; +import type { RemoteForkSource } from "../src/remote/read.ts"; +import type { RemoteForkCommit, RemoteForkPart } from "../src/remote/lifecycle-link.ts"; +import { installedHost, RUN_ID, ROOT, type Script } from "./support/remote-lifecycle-host.ts"; + +const SOURCE_RUN_ID = "6dktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; +const DESTINATION = "7ektgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; + +function event(name: string): string { + return serializeDurableEvent({ + type: "yield", + coroutineId: "root", + description: { type: "test", name }, + result: { status: "ok", value: name }, + }); +} + +function source(): RemoteForkSource { + return { + sourceRunId: SOURCE_RUN_ID, + anchor: "f".repeat(64), + checkpointEventId: "event-work", + checkpointWorkspaceRootId: ROOT, + runRecordWorkspaceRootId: ROOT, + rootImportWorkspaceRootId: ROOT, + inherited: [ + { eventId: "event-a", record: event("a"), workspaceRootId: ROOT }, + { eventId: "event-b", record: event("b"), workspaceRootId: ROOT }, + ], + roots: [ + { + rootId: ROOT, + formatVersion: 1, + manifest: "{}", + manifestHashes: ["b".repeat(64)], + blobHashes: ["c".repeat(64)], + }, + ], + manifests: [{ hash: "b".repeat(64), size: 3, lastSeen: 0, encoded: new Uint8Array([1, 2, 3]) }], + blobs: [{ hash: "c".repeat(64), size: 3, lastSeen: 0, content: new Uint8Array([1, 2, 3]) }], + checkouts: [ + { + kind: "repository", + name: "alpha", + locator: "https://git.example.invalid/alpha.git", + locatorFingerprint: "d".repeat(64), + requestedBase: null, + creationCommit: "9".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", + checkoutPath: "/", + }, + ], + }; +} + +function request(runId = DESTINATION): WorkflowForkRequest { + return { + runId, + selection: { sourceRunId: SOURCE_RUN_ID, checkpointEventId: "event-work" }, + creation: { + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + }, + rootImport: { + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { status: "ok", value: { kind: "repository", path: "README.md", content: "# fork" } }, + }, + }; +} + +function* installed( + script: Script, + body: (transitions: WorkflowExecutionTransitions) => Operation, +): Operation { + return yield* scoped(function* () { + const transitions = yield* useRemoteLifecycle(installedHost(script)); + return yield* body(transitions); + }); +} + +function* acquired(runId: string): Operation { + const taken = yield* WorkflowLifecycle.operations.acquireExecutor(runId); + if (!taken.ok) { + throw taken.error; + } + if (taken.value.kind !== "acquired") { + throw new Error("expected the executor lock to be acquired"); + } + return taken.value.lock; +} + +describe("a remote fork's destination", () => { + it("offers the whole snapshot before it commits any of it", function* () { + const asked: string[] = []; + const staged: RemoteForkPart[] = []; + const commits: RemoteForkCommit[] = []; + const outcome = yield* installed( + { asked, staged, commits, source: source() }, + function* (transitions) { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }, + ); + + expect([outcome.ok, outcome.ok === false && String(outcome.error)]).toEqual([true, false]); + // Everything was offered, and the commit came last. + expect(asked.at(-1)).toBe("fork"); + expect(asked.filter((command) => command === "fork-stage")).toHaveLength(6); + expect(staged.map((part) => `${part.section}:${part.position}`)).toEqual([ + "roots:0", + // The metadata a digest cannot stand for travels beside the content. + "manifests:0", + "blobs:0", + "inherited:0", + "inherited:1", + "checkouts:0", + ]); + // What the final command claims is what was offered, and where it came + // from is the selection that was read. + expect(commits[0]?.counts).toEqual({ + inherited: 2, + roots: 1, + manifests: 1, + blobs: 1, + checkouts: 1, + }); + expect(commits[0]?.origin).toEqual({ + sourceRunId: SOURCE_RUN_ID, + checkpointEventId: "event-work", + checkpointWorkspaceRootId: ROOT, + runRecordWorkspaceRootId: ROOT, + rootImportWorkspaceRootId: ROOT, + anchor: "f".repeat(64), + }); + }); + + it("names every question of one fork distinctly, under the one identity", function* () { + const commands: string[] = []; + const parts: string[] = []; + yield* installed({ commands, parts, source: source() }, function* (transitions) { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + + // One fork asks the destination eight questions: whether it already holds + // this fork, six offers of the snapshot, and the commit. + const asked = [...commands, ...parts]; + expect(asked).toHaveLength(8); + // Each is its own command. An owner keys a retained decision by the + // identity it was asked under, so two different requests sharing one + // identity would meet each other's fingerprint and be refused as repeats + // of something they are not. + expect(new Set(asked).size).toBe(asked.length); + // And all of them belong to the one call, so a retry spells each of them + // exactly the way the first attempt did. + expect(new Set(asked.map((command) => command.split(":")[0])).size).toBe(1); + }); + + it("carries the inherited records exactly as the source retained them", function* () { + const staged: RemoteForkPart[] = []; + yield* installed({ staged, source: source() }, function* (transitions) { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + + const inherited = staged.filter((part) => part.section === "inherited"); + expect(inherited.map((part) => part.part["record"])).toEqual([event("a"), event("b")]); + expect(inherited.map((part) => part.part["eventId"])).toEqual(["event-a", "event-b"]); + }); + + it("writes the fork's own run record rather than the source's", function* () { + const commits: RemoteForkCommit[] = []; + yield* installed({ commits, source: source() }, function* (transitions) { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + + const head = commits[0]; + // Its own identity, and the root import its own definition produced. + expect(JSON.stringify(head?.runRecord)).toContain(DESTINATION); + expect(JSON.stringify(head?.runRecord)).not.toContain(SOURCE_RUN_ID); + expect(head?.rootImport).toEqual(request().rootImport); + }); + + it("refuses a fork under a lock issued for another run, and reads nothing", function* () { + const asked: string[] = []; + const outcome = yield* installed({ asked, source: source() }, function* (transitions) { + const lock = yield* acquired(RUN_ID); + return yield* transitions.fork(lock, request()); + }); + + expect(outcome.ok).toBe(false); + // Not one part offered, and no source read: the lock was wrong before any + // of that could matter. + expect(asked).toEqual([]); + }); + + it("refuses a staged fork whose source it cannot read, and takes no acquisition", function* () { + const opened: string[] = []; + const outcome = yield* installed({ opened }, function* (transitions) { + return yield* transitions.stageFork(request()); + }); + + expect(outcome.ok).toBe(false); + // Staging takes no destination acquisition at all, failure or not. + expect(opened).toEqual([]); + }); + + it("stages a candidate without acquiring or committing anything", function* () { + const asked: string[] = []; + const opened: string[] = []; + const outcome = yield* installed({ asked, opened, source: source() }, function* (transitions) { + return yield* transitions.stageFork(request()); + }); + + // This scripted host stages nothing, which is the point: what is proved + // here is that nothing was acquired and nothing was committed on the way. + expect(outcome.ok).toBe(false); + expect(opened).toEqual([]); + expect(asked).toEqual([]); + }); + + it("continues a destination that already holds this fork, without its source", function* () { + const asked: string[] = []; + const sourced: string[] = []; + const outcome = yield* installed( + // No source at all: this host would fail if one were asked for. + { asked, sourced, continues: true }, + function* (transitions) { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }, + ); + + expect([outcome.ok, outcome.ok === false && String(outcome.error)]).toEqual([true, false]); + // The destination answered from what it retains. Nothing was read from the + // source, and nothing was staged. + expect(sourced).toEqual([]); + expect(asked).toEqual(["fork-continue"]); + }); + + it("asks the source only when the destination holds no fork yet", function* () { + const asked: string[] = []; + const sourced: string[] = []; + const outcome = yield* installed({ asked, sourced, source: source() }, function* (transitions) { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + + expect(outcome.ok).toBe(true); + // Absent, so the source was read and staged, and the commit came last. + expect(sourced).toEqual([SOURCE_RUN_ID]); + expect(asked[0]).toBe("fork-continue"); + expect(asked.at(-1)).toBe("fork"); + }); + + it("stays absent when the destination is pristine and the source cannot be read", function* () { + const asked: string[] = []; + const outcome = yield* installed({ asked }, function* (transitions) { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + + expect(outcome.ok).toBe(false); + // It asked the destination, learned there was nothing, and stopped when + // the source it needed was unavailable. Nothing was committed. + expect(asked).toEqual(["fork-continue"]); + }); + + it("resends the command it already sent when its answer was lost", function* () { + const asked: string[] = []; + const sourced: string[] = []; + const commands: string[] = []; + const loseAnswer = new Set(["command-1:commit"]); + const committed = new Map(); + const script: Script = { + asked, + sourced, + commands, + loseAnswer, + committed, + source: source(), + }; + const outcome = yield* installed(script, function* (transitions) { + const first = yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + // The source is gone by the time the retry happens. + const retried = yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + return { first, retried, sourced: [...sourced], asked: [...asked] }; + }); + + expect(outcome.first.ok).toBe(false); + expect(outcome.retried.ok).toBe(true); + // The retry resent the exact command before anything else, so the source + // was read once — for the first attempt — and not again. + expect(outcome.sourced).toEqual([SOURCE_RUN_ID]); + expect(outcome.asked.at(-1)).toBe("fork"); + expect(commands.filter((id) => id === "command-1:commit").length).toBeGreaterThan(1); + }); + + it("sends only the command it already sent when its answer is lost twice", function* () { + const asked: string[] = []; + const sourced: string[] = []; + const script: Script = { + asked, + sourced, + loseAnswer: new Set(["command-1:commit"]), + committed: new Map(), + source: source(), + }; + const outcome = yield* installed(script, function* (transitions) { + const first = yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + // Lost again on the resend. + script.loseAnswer?.add("command-1:commit"); + const asking = asked.length; + const second = yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + return { first, second, sent: asked.slice(asking), sourced: [...sourced] }; + }); + + expect(outcome.first.ok).toBe(false); + expect(outcome.second.ok).toBe(false); + // The second attempt sent the finalized command and nothing else: no + // continuation, and no second source read. + expect(outcome.sent).toEqual(["fork"]); + expect(outcome.sourced).toEqual([SOURCE_RUN_ID]); + }); + + it("retires an invocation the owner definitively refused", function* () { + const asked: string[] = []; + const script: Script = { + asked, + forkRefuses: true, + loseAnswer: new Set(["command-1:commit"]), + committed: new Map(), + source: source(), + }; + const outcome = yield* installed(script, function* (transitions) { + const first = yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + const asking = asked.length; + const second = yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + return { first, second, sent: asked.slice(asking) }; + }); + + expect(outcome.second.ok).toBe(false); + // The resend was answered — with a conflict — so nothing followed it. + expect(outcome.sent).toEqual(["fork"]); + }); + + it("restages under a second transfer's own identity when the destination needs one", function* () { + const asked: string[] = []; + const parts: string[] = []; + const commits: RemoteForkCommit[] = []; + const reused: string[] = []; + const decided: string[] = []; + const script: Script = { + asked, + parts, + commits, + reused, + decided, + loseAnswer: new Set(["command-1:commit"]), + needsTransfer: new Set(["command-1:commit"]), + committed: new Map(), + source: source(), + }; + const outcome = yield* installed(script, function* (transitions) { + const first = yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + const asking = asked.length; + const staging = parts.length; + const second = yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + return { + first, + second, + sent: asked.slice(asking), + offered: parts.slice(0, staging), + reoffered: parts.slice(staging), + }; + }); + + expect(outcome.first.ok).toBe(false); + expect([ + outcome.second.ok, + outcome.second.ok === false && String(outcome.second.error), + ]).toEqual([true, false]); + // The resend was told its transfer is not there, so the same logical fork + // staged the snapshot again and committed. It never asked whether the + // destination already holds the fork: the destination just said it holds + // nothing and was offered nothing. + expect(outcome.sent).toEqual([ + "fork", + "fork-stage", + "fork-stage", + "fork-stage", + "fork-stage", + "fork-stage", + "fork-stage", + "fork", + ]); + // `needs-transfer` is an answer, so the identity it answered is finished. + // Every command of the second transfer carries a name that has never been + // answered — which is exactly what the link enforces. + expect(commits.map((commit) => commit.commandId)).toEqual([ + "command-1:commit", + "command-1:commit", + "command-2:commit", + ]); + expect(new Set(outcome.offered).size).toBe(outcome.offered.length); + expect(new Set(outcome.reoffered).size).toBe(outcome.reoffered.length); + for (const offered of outcome.reoffered) { + expect(outcome.offered).not.toContain(offered); + } + expect(reused).toEqual([]); + // And exactly one destination execution was ever begun. + expect(decided).toHaveLength(1); + expect(outcome.second.ok && outcome.second.value.execution.executionId).toBe(decided[0]); + }); + + it("keeps the fork it was committing when it is cancelled, and commits it once", function* () { + const asked: string[] = []; + const commits: RemoteForkCommit[] = []; + const decided: string[] = []; + const retired: string[] = []; + const reused: string[] = []; + const sourced: string[] = []; + const entered = withResolvers(); + let held = 0; + const script: Script = { + asked, + commits, + decided, + retired, + reused, + sourced, + committed: new Map(), + source: source(), + commitGate: { + *wait(): Operation { + held += 1; + if (held > 1) { + return; + } + entered.resolve(); + yield* withResolvers().operation; + }, + }, + }; + const outcome = yield* installed(script, function* (transitions) { + yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + const sent = yield* spawn(() => transitions.fork(lock, request())); + yield* entered.operation; + // Interrupted with the destination's decision made and its answer in + // flight — the one moment a fork is genuinely ambiguous. + yield* sent.halt(); + }); + const asking = asked.length; + const reads = sourced.length; + const second = yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + return { second, sent: asked.slice(asking), reads: sourced.length - reads }; + }); + + // The interrupted acquisition gave up its connection rather than holding a + // run nobody could reach. + expect(retired).toEqual([DESTINATION]); + // The replacement resent that exact command and nothing else, without + // reading the source again. + expect(outcome.sent).toEqual(["fork"]); + expect(outcome.reads).toBe(0); + expect(commits.map((commit) => commit.commandId)).toEqual([ + "command-1:commit", + "command-1:commit", + ]); + // One fork was committed, and the replacement was handed that one. + expect(decided).toHaveLength(1); + expect([ + outcome.second.ok, + outcome.second.ok === false && String(outcome.second.error), + ]).toEqual([true, false]); + expect(outcome.second.ok && outcome.second.value.execution.executionId).toBe(decided[0]); + expect(reused).toEqual([]); + }); + + it("keeps no question when it is cancelled reading the source, and stays usable", function* () { + const asked: string[] = []; + const commands: string[] = []; + const retired: string[] = []; + const reused: string[] = []; + const entered = withResolvers(); + let held = 0; + const script: Script = { + asked, + commands, + retired, + reused, + committed: new Map(), + source: source(), + sourceGate: { + *wait(): Operation { + held += 1; + if (held > 1) { + return; + } + entered.resolve(); + yield* withResolvers().operation; + }, + }, + }; + const outcome = yield* installed(script, function* (transitions) { + return yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + const sent = yield* spawn(() => transitions.fork(lock, request())); + yield* entered.operation; + // The continuation was answered and nothing has been mutated. Reading + // a source is not a mutation however it is interrupted. + yield* sent.halt(); + const asking = asked.length; + // The same acquisition, which was released rather than retired. + const again = yield* transitions.fork(lock, request()); + return { again, sent: asked.slice(asking) }; + }); + }); + + // Nothing was retired, because nothing was outstanding. + expect(retired).toEqual([]); + expect([outcome.again.ok, outcome.again.ok === false && String(outcome.again.error)]).toEqual([ + true, + false, + ]); + // The second call is a new question under a new identity — no ambiguity + // was retained for a mutation that never happened — and it asks the + // destination the whole thing again. + expect(outcome.sent[0]).toBe("fork-continue"); + expect(commands).toEqual(["command-1:continue", "command-2:continue", "command-2:commit"]); + expect(reused).toEqual([]); + }); + + it("keeps no question when it is cancelled offering the snapshot", function* () { + const asked: string[] = []; + const commands: string[] = []; + const parts: string[] = []; + const retired: string[] = []; + const reused: string[] = []; + const entered = withResolvers(); + let held = 0; + const script: Script = { + asked, + commands, + parts, + retired, + reused, + committed: new Map(), + source: source(), + stageGate: { + *wait(): Operation { + held += 1; + if (held > 1) { + return; + } + entered.resolve(); + yield* withResolvers().operation; + }, + }, + }; + const outcome = yield* installed(script, function* (transitions) { + return yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + const sent = yield* spawn(() => transitions.fork(lock, request())); + yield* entered.operation; + // Offered parts are scratch until a final command claims them. An + // offer interrupted halfway has mutated nothing. + yield* sent.halt(); + const asking = asked.length; + const again = yield* transitions.fork(lock, request()); + return { again, sent: asked.slice(asking) }; + }); + }); + + expect(retired).toEqual([]); + expect([outcome.again.ok, outcome.again.ok === false && String(outcome.again.error)]).toEqual([ + true, + false, + ]); + // A whole second offer, under a second identity: no part and no commit + // reaches for a name the first attempt already used. + expect(outcome.sent.filter((command) => command === "fork-stage")).toHaveLength(6); + expect(commands.at(-1)).toBe("command-2:commit"); + expect(new Set(parts).size).toBe(parts.length); + expect(reused).toEqual([]); + }); + + it("resends only the second transfer's own command when its answer is lost", function* () { + const asked: string[] = []; + const sourced: string[] = []; + const reused: string[] = []; + const decided: string[] = []; + const commits: RemoteForkCommit[] = []; + const script: Script = { + asked, + sourced, + reused, + decided, + commits, + // The first transfer's commit is lost and never happened; the second + // transfer's commit is lost after the owner made it. + loseAnswer: new Set(["command-1:commit", "command-2:commit"]), + needsTransfer: new Set(["command-1:commit"]), + committed: new Map(), + source: source(), + }; + const outcome = yield* installed(script, function* (transitions) { + const attempts = []; + for (let attempt = 0; attempt < 3; attempt += 1) { + const asking = asked.length; + const reads = sourced.length; + const attempted = yield* scoped(function* () { + const lock = yield* acquired(DESTINATION); + return yield* transitions.fork(lock, request()); + }); + attempts.push({ + outcome: attempted, + sent: asked.slice(asking), + reads: sourced.length - reads, + }); + } + return attempts; + }); + + expect(outcome.map((attempt) => attempt.outcome.ok)).toEqual([false, false, true]); + // The third attempt resent one command — the second transfer's own final + // command, verbatim — and read no source to build it. + expect(outcome[2]?.sent).toEqual(["fork"]); + expect(outcome[2]?.reads).toBe(0); + expect(commits.map((commit) => commit.commandId)).toEqual([ + "command-1:commit", + "command-1:commit", + "command-2:commit", + "command-2:commit", + ]); + // A retry reaches for the identity the second transfer already used rather + // than for the answered one, and never for a fresh one. + expect(reused).toEqual([]); + expect(decided).toHaveLength(1); + }); +}); diff --git a/packages/workflow/tests/remote-inspection.test.ts b/packages/workflow/tests/remote-inspection.test.ts new file mode 100644 index 000000000..43bccb1c8 --- /dev/null +++ b/packages/workflow/tests/remote-inspection.test.ts @@ -0,0 +1,1183 @@ +/** + * Tier WRH — what a remote inspection projects to, and what it refuses. + * + * The owner-side facts — that reading takes no acquisition, and that one + * answer comes from one committed reading — are proved against a real Durable + * Object in `tests/cloudflare/remote-read-plane.vitest.ts`. These are the other + * half: that the provider-neutral values a caller receives are the shared + * projection, and that an anchored sequence which does not hold together + * publishes nothing at all. + */ + +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { type Operation, scoped } from "effection"; +import { + cloudflareReadPlane, + FORK_SOURCE_ANSWER_BYTES, + PUBLIC_ANSWER_BYTES, + type ReadTransport, +} from "../src/cloudflare/read-client.ts"; +import type { RemoteReadPlane } from "../src/remote/read.ts"; +import { sha256Hex } from "../src/workspace/sha256.ts"; +import { compareUtf8, WORKSPACE_ROOT_DOMAIN } from "../src/workspace/root-manifest.ts"; +import { useRemoteLifecycleReads } from "../src/remote/inspection.ts"; +import { WorkflowLifecycle } from "../src/lifecycle/api.ts"; +import { WorkflowRecordMalformedError, WorkflowRunIdMismatchError } from "../src/storage/errors.ts"; + +const RUN_ID = "5cktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; +const ROOT = "a".repeat(64); + +function runRecord(): Record { + return { + runId: RUN_ID, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + status: "running", + createdAt: "2026-09-05T00:00:00.000Z", + updatedAt: "2026-09-05T00:00:00.000Z", + }; +} + +function inspection(overrides: Record = {}): Record { + return { + record: runRecord(), + executions: [], + retrieval: null, + journalFrontier: null, + currentWorkspaceRootId: ROOT, + lineage: null, + ...overrides, + }; +} + +function event(name: string): string { + return JSON.stringify({ + type: "yield", + coroutineId: "root", + description: { type: "test", name }, + result: { status: "ok", value: name }, + }); +} + +/** An owner whose answers a test writes, recording what it was asked. */ +function plane(answer: (read: Record) => Record) { + const asked: Record[] = []; + const transport: ReadTransport = { + // deno-lint-ignore require-yield + *send(_admission, body: string): Operation { + const value: unknown = JSON.parse(body); + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("expected one read operation"); + } + const operation = Object.fromEntries(Object.entries(value)); + asked.push(operation); + return JSON.stringify(answer(operation)); + }, + }; + return { asked, transport }; +} + +/** The bytes as the private protocol carries them. */ +function base64(bytes: Uint8Array): string { + let text = ""; + for (const byte of bytes) { + text += String.fromCharCode(byte); + } + return btoa(text); +} + +/** + * Where a real owner says a page ended. + * + * A cursor is the position of the last member the page carried, inside the + * anchored selection it belongs to — not a name, and not a value beside the + * rows, so a scripted owner has to derive it the way a real one does. + */ +function cursorOf(from: number, rows: readonly unknown[], after: number | null): number | null { + return rows.length === 0 ? after : from + rows.length - 1; +} + +/** Install the read operations over one scripted owner, for one body. */ +function* installed( + answer: (read: Record) => Record, + body: (opened: RemoteReadPlane) => Operation, +): Operation { + return yield* scoped(function* () { + const held = plane(answer); + const opened = cloudflareReadPlane( + held.transport, + "release-1", + // deno-lint-ignore require-yield + function* () { + return "token"; + }, + RUN_ID, + ); + yield* useRemoteLifecycleReads(opened); + // The plane itself is handed to the body: a fork source is private, and no + // public lifecycle operation returns one. + return yield* body(opened); + }); +} + +describe("a remote run's inspection", () => { + it("refuses a request for another run before it reaches the owner", function* () { + const other = "6dktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; + const held = plane(() => ({ outcome: "performed", value: inspection() })); + const seen = yield* scoped(function* () { + yield* useRemoteLifecycleReads( + // deno-lint-ignore require-yield + cloudflareReadPlane( + held.transport, + "release-1", + function* () { + return "token"; + }, + RUN_ID, + ), + ); + return { + inspected: yield* WorkflowLifecycle.operations.inspect(other), + history: yield* WorkflowLifecycle.operations.history(other), + }; + }); + + expect([seen.inspected.ok, seen.history.ok]).toEqual([false, false]); + // Refused here, so the owner was never asked and nothing of the bound run + // came back. + expect(held.asked).toEqual([]); + for (const outcome of [seen.inspected, seen.history]) { + expect(outcome.ok === false && outcome.error).toEqual(expect.any(WorkflowRunIdMismatchError)); + expect(String(outcome.ok === false && outcome.error)).not.toContain(RUN_ID); + } + }); + + it("lists nothing for an owner holding no run, and fails on a damaged one", function* () { + const empty = yield* installed( + () => ({ outcome: "refused", refusal: "command:absent" }), + () => WorkflowLifecycle.operations.list(), + ); + // Pristine storage lists nothing rather than failing: there is no run, and + // that is a complete answer. + expect(empty.ok && empty.value).toEqual([]); + + const damaged = yield* installed( + () => ({ outcome: "refused", refusal: "storage:corrupt" }), + () => WorkflowLifecycle.operations.list(), + ); + // Anything else fails whole rather than reporting a healthy subset. + expect(damaged.ok).toBe(false); + expect(String(damaged.ok === false && damaged.error)).not.toContain("storage:"); + }); + + it("answers one frozen snapshot, and a bound owner's list of one", function* () { + const seen = yield* installed( + () => ({ outcome: "performed", value: inspection() }), + function* () { + return { + inspected: yield* WorkflowLifecycle.operations.inspect(RUN_ID), + listed: yield* WorkflowLifecycle.operations.list(), + }; + }, + ); + + expect(seen.inspected.ok).toBe(true); + if (seen.inspected.ok) { + expect(seen.inspected.value.record.runId).toBe(RUN_ID); + expect(seen.inspected.value.currentWorkspaceRootId).toBe(ROOT); + // Nothing callable, and nothing a caller can change. + expect(Object.isFrozen(seen.inspected.value)).toBe(true); + expect(seen.inspected.value.journalFrontier).toBe(undefined); + expect(seen.inspected.value.lineage).toBe(undefined); + } + // The plane is bound to one owner, so its whole visible domain is that + // owner: one coherent snapshot, and no enumeration of anything else. + expect(seen.listed.ok && seen.listed.value).toHaveLength(1); + }); + + it("projects history through the shared projection, across anchored pages", function* () { + const history = yield* installed( + (read) => { + if (read["operation"] !== "history") { + return { outcome: "performed", value: inspection() }; + } + // Two pages, anchored to the terminal event the first one chose. + if (read["after"] === null) { + return { + outcome: "performed", + value: { + anchor: "event-2", + after: null, + rows: [{ eventId: "event-1", record: event("first"), workspaceRootId: ROOT }], + done: false, + retainedRoots: [], + provenance: [], + }, + }; + } + return { + outcome: "performed", + value: { + anchor: "event-2", + after: "event-1", + rows: [{ eventId: "event-2", record: event("second"), workspaceRootId: ROOT }], + done: true, + retainedRoots: [ROOT], + provenance: [ + { eventId: "event-1", sourceRunId: "somewhere", sourceEventId: "event-9" }, + ], + }, + }; + }, + () => WorkflowLifecycle.operations.history(RUN_ID), + ); + + expect(history.ok).toBe(true); + if (history.ok) { + expect(history.value.map((entry) => entry.eventId)).toEqual(["event-1", "event-2"]); + // The shared projection's own members, not a second interpretation. + expect(history.value[0]?.forkability).not.toBe(undefined); + expect(history.value[0]?.inherited).toEqual({ + sourceRunId: "somewhere", + sourceEventId: "event-9", + }); + expect(history.value[1]?.inherited).toBe(undefined); + // No retained record bytes reach the public answer. + expect(JSON.stringify(history.value)).not.toContain('\\"type\\":\\"yield'); + } + }); + + it("publishes nothing when an anchored sequence does not hold together", function* () { + const first = { + anchor: "event-2", + after: null, + rows: [{ eventId: "event-1", record: event("first"), workspaceRootId: ROOT }], + done: false, + retainedRoots: [], + provenance: [], + }; + // Each of these is a second page that belongs to some other snapshot. + const broken: Record> = { + "a changed anchor": { ...first, anchor: "event-9", after: "event-1" }, + "a cursor it was not asked to continue": { ...first, after: "event-7" }, + "a repeated event": { ...first, after: "event-1" }, + "a page that terminates short of its anchor": { + ...first, + after: "event-1", + rows: [{ eventId: "event-3", record: event("third"), workspaceRootId: ROOT }], + done: true, + }, + "an empty page of a non-empty snapshot": { ...first, after: "event-1", rows: [] }, + "a member this build does not declare": { ...first, after: "event-1", extra: true }, + }; + + for (const [description, second] of Object.entries(broken)) { + const outcome = yield* installed( + (read) => ({ + outcome: "performed", + value: read["after"] === null ? first : second, + }), + () => WorkflowLifecycle.operations.history(RUN_ID), + ); + expect([description, outcome.ok]).toEqual([description, false]); + if (!outcome.ok) { + expect([description, outcome.error]).toEqual([ + description, + expect.any(WorkflowRecordMalformedError), + ]); + // Nothing of the page, and nothing of the protocol. + expect(String(outcome.error)).not.toContain("event-"); + } + } + }); + + it("refuses an inspection that describes another run", function* () { + const outcome = yield* installed( + () => ({ + outcome: "performed", + value: inspection({ + record: { ...runRecord(), runId: "6dktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa" }, + }), + }), + () => WorkflowLifecycle.operations.inspect(RUN_ID), + ); + expect(outcome.ok).toBe(false); + expect(outcome.ok === false && outcome.error).toEqual(expect.any(WorkflowRecordMalformedError)); + expect(String(outcome.ok === false && outcome.error)).not.toContain("6dktgrv"); + }); + + it("hands back a refusal as a storage failure, with nothing private in it", function* () { + const outcome = yield* installed( + () => ({ outcome: "refused", refusal: "command:absent" }), + () => WorkflowLifecycle.operations.inspect(RUN_ID), + ); + expect(outcome.ok).toBe(false); + expect(String(outcome.ok === false && outcome.error)).not.toContain("command:"); + }); + + it("refuses a fork source whose transported closure does not hold", function* () { + // One representative of each distinct structural failure, driven through + // the fork-source client itself rather than inferred from the history + // pager. A destination built from any of these could not restore the + // Workspace it was given. + const BLOB = new TextEncoder().encode("file bytes"); + const blobHash = sha256Hex(BLOB); + const contentManifest = JSON.stringify({ + version: 1, + chunks: [{ hash: blobHash, size: BLOB.length }], + }); + const encoded = new TextEncoder().encode(contentManifest); + const manifestHash = sha256Hex(encoded); + const rootManifest = JSON.stringify({ + format: 1, + entries: [ + { path: "/", kind: "directory", mode: 493, mtime: 0 }, + { + path: "/file.txt", + kind: "file", + mode: 420, + mtime: 0, + size: BLOB.length, + manifest: manifestHash, + hardlink: null, + }, + ], + }); + const rootId = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${rootManifest}`); + + const sound = { + inherited: [ + { eventId: "event-work", record: event("work"), workspaceRootId: rootId, position: 0 }, + ], + roots: [ + { + rootId, + formatVersion: 1, + manifest: rootManifest, + manifestHashes: [manifestHash], + blobHashes: [blobHash], + }, + ], + manifests: [{ hash: manifestHash, size: BLOB.length, lastSeen: 0, encoded: base64(encoded) }], + blobs: [{ hash: blobHash, size: BLOB.length, lastSeen: 0, content: base64(BLOB) }], + checkouts: [], + }; + + const damaged: Record typeof sound> = { + "a manifest that is not its own digest": (held) => ({ + ...held, + manifests: [{ ...held.manifests[0], hash: "d".repeat(64) }], + }), + "a manifest describing another size": (held) => ({ + ...held, + manifests: [{ ...held.manifests[0], size: 999 }], + }), + "a root whose references do not follow from its manifest": (held) => ({ + ...held, + roots: [{ ...held.roots[0], manifestHashes: [] }], + }), + "a blob that is not its own digest": (held) => ({ + ...held, + blobs: [{ ...held.blobs[0], hash: "e".repeat(64) }], + }), + "a blob disagreeing with the chunk that names it": (held) => ({ + ...held, + blobs: [{ ...held.blobs[0], size: 999 }], + }), + // Two wrong sizes that agree with each other: the blob row and the + // chunk both say eleven while the bytes are ten. Every one-dimensional + // comparison passes, so only the byte length catches it. + "coordinated sizes that disagree with the bytes": (held) => { + const wide = JSON.stringify({ + version: 1, + chunks: [{ hash: blobHash, size: BLOB.length + 1 }], + }); + const wideBytes = new TextEncoder().encode(wide); + const wideHash = sha256Hex(wideBytes); + const wideRoot = JSON.stringify({ + format: 1, + entries: [ + { path: "/", kind: "directory", mode: 493, mtime: 0 }, + { + path: "/file.txt", + kind: "file", + mode: 420, + mtime: 0, + size: BLOB.length + 1, + manifest: wideHash, + hardlink: null, + }, + ], + }); + const wideRootId = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${wideRoot}`); + return { + inherited: [ + { + eventId: "event-work", + record: event("work"), + workspaceRootId: wideRootId, + position: 0, + }, + ], + roots: [ + { + rootId: wideRootId, + formatVersion: 1, + manifest: wideRoot, + manifestHashes: [wideHash], + blobHashes: [blobHash], + }, + ], + manifests: [ + { hash: wideHash, size: BLOB.length + 1, lastSeen: 0, encoded: base64(wideBytes) }, + ], + blobs: [{ hash: blobHash, size: BLOB.length + 1, lastSeen: 0, content: base64(BLOB) }], + checkouts: [], + }; + }, + // The root claims a file length the content it names does not produce. + "a root file size the content does not produce": (held) => { + const wrongRoot = JSON.stringify({ + format: 1, + entries: [ + { path: "/", kind: "directory", mode: 493, mtime: 0 }, + { + path: "/file.txt", + kind: "file", + mode: 420, + mtime: 0, + size: BLOB.length + 5, + manifest: manifestHash, + hardlink: null, + }, + ], + }); + const wrongRootId = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${wrongRoot}`); + return { + ...held, + inherited: [ + { + eventId: "event-work", + record: event("work"), + workspaceRootId: wrongRootId, + position: 0, + }, + ], + roots: [{ ...held.roots[0], rootId: wrongRootId, manifest: wrongRoot }], + }; + }, + // A manifest that is sound in itself — its own digest, its own size — + // and simply nothing the selection asked for. + "a manifest nothing selected requires": (held) => { + const spare = new TextEncoder().encode( + JSON.stringify({ + version: 1, + chunks: [ + { hash: blobHash, size: BLOB.length }, + { hash: blobHash, size: BLOB.length }, + ], + }), + ); + return { + ...held, + // In the section's own order, so what is refused is the extra + // manifest and not the sequence that carried it. + manifests: [ + ...held.manifests, + { + hash: sha256Hex(spare), + size: BLOB.length * 2, + lastSeen: 0, + encoded: base64(spare), + }, + ].toSorted((left, right) => (left.hash < right.hash ? -1 : 1)), + }; + }, + "a row naming a root the selection did not carry": (held) => ({ + ...held, + inherited: [{ ...held.inherited[0], workspaceRootId: "a".repeat(64) }], + }), + }; + + for (const [description, damage] of Object.entries(damaged)) { + const held = damage(sound); + const outcome = yield* installed( + (read) => { + const section = String(read["section"]); + const found = Reflect.get(held, section); + const rows = Array.isArray(found) ? found : []; + const rootOf = held.roots[0]; + return { + outcome: "performed", + value: { + anchor: "anchor-1", + after: null, + section, + checkpointEventId: "event-work", + checkpointWorkspaceRootId: rootOf?.rootId ?? rootId, + runRecordWorkspaceRootId: rootOf?.rootId ?? rootId, + rootImportWorkspaceRootId: rootOf?.rootId ?? rootId, + rows, + from: 0, + cursor: cursorOf(0, rows, null), + done: true, + total: rows.length, + }, + }; + }, + (opened) => opened.forkSource("event-work"), + ); + expect([description, outcome.ok]).toEqual([description, false]); + if (!outcome.ok) { + // Nothing of the bytes, the hashes or the protocol. + expect(String(outcome.error)).not.toContain(blobHash); + expect(String(outcome.error)).not.toContain("command:"); + } + } + }); + + it("accepts a fork source whose closure holds, and refuses a moved anchor", function* () { + const BLOB = new TextEncoder().encode("file bytes"); + const blobHash = sha256Hex(BLOB); + const encoded = new TextEncoder().encode( + JSON.stringify({ version: 1, chunks: [{ hash: blobHash, size: BLOB.length }] }), + ); + const manifestHash = sha256Hex(encoded); + const rootManifest = JSON.stringify({ + format: 1, + entries: [ + { path: "/", kind: "directory", mode: 493, mtime: 0 }, + { + path: "/file.txt", + kind: "file", + mode: 420, + mtime: 0, + size: BLOB.length, + manifest: manifestHash, + hardlink: null, + }, + ], + }); + const rootId = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${rootManifest}`); + // One record spelled the way its source retained it, which is not the + // spelling re-encoding the parsed event would produce. + const spelled = JSON.stringify(JSON.parse(event("work")), null, 2); + const sections: Record = { + inherited: [{ eventId: "event-work", record: spelled, workspaceRootId: rootId, position: 0 }], + roots: [ + { + rootId, + formatVersion: 1, + manifest: rootManifest, + manifestHashes: [manifestHash], + blobHashes: [blobHash], + }, + ], + manifests: [{ hash: manifestHash, size: BLOB.length, lastSeen: 0, encoded: base64(encoded) }], + blobs: [{ hash: blobHash, size: BLOB.length, lastSeen: 0, content: base64(BLOB) }], + checkouts: [], + }; + const answer = (anchorFor: (section: string) => string) => (read: Record) => { + const section = String(read["section"]); + const rows = sections[section] ?? []; + return { + outcome: "performed", + value: { + anchor: anchorFor(section), + after: null, + section, + checkpointEventId: "event-work", + checkpointWorkspaceRootId: rootId, + runRecordWorkspaceRootId: rootId, + rootImportWorkspaceRootId: rootId, + rows, + from: 0, + cursor: cursorOf(0, rows, null), + done: true, + total: rows.length, + }, + }; + }; + + const whole = yield* installed( + answer(() => "anchor-1"), + (opened) => opened.forkSource("event-work"), + ); + expect(whole.ok).toBe(true); + if (whole.ok) { + expect(whole.value.inherited.map((row) => row.eventId)).toEqual(["event-work"]); + // Byte for byte: a destination retains these bytes, and a spelling + // rebuilt from the parse would be a history it never inherited. + expect(whole.value.inherited[0]?.record).toBe(spelled); + expect(spelled).not.toBe(event("work")); + expect(whole.value.roots.map((root) => root.rootId)).toEqual([rootId]); + expect(whole.value.blobs[0]?.content).toEqual(BLOB); + } + + // The checkouts section arrives from a selection that has moved on. + const moved = yield* installed( + answer((section) => (section === "checkouts" ? "anchor-2" : "anchor-1")), + (opened) => opened.forkSource("event-work"), + ); + expect(moved.ok).toBe(false); + expect(moved.ok === false && moved.error).toEqual(expect.any(WorkflowRecordMalformedError)); + }); + + it("refuses a fork-source sequence that does not describe one selection", function* () { + // A selection is one answer carried over several pages. These are the ways + // a sequence can stop being that answer: a page that describes a different + // selection, a page that advances past what it carried, and a graph of + // checkouts a destination could not retain. + const BLOB = new TextEncoder().encode("file bytes"); + const blobHash = sha256Hex(BLOB); + const encoded = new TextEncoder().encode( + JSON.stringify({ version: 1, chunks: [{ hash: blobHash, size: BLOB.length }] }), + ); + const manifestHash = sha256Hex(encoded); + const directory = (path: string) => ({ path, kind: "directory", mode: 493, mtime: 0 }); + const rootManifest = JSON.stringify({ + format: 1, + // Canonical order: the root, then every path by its UTF-8 bytes. Seven + // directories, because a checkout identity that collides under a + // separator still needs a place of its own. + entries: [ + directory("/"), + { + path: "/file.txt", + kind: "file", + mode: 420, + mtime: 0, + size: BLOB.length, + manifest: manifestHash, + hardlink: null, + }, + ...["/five", "/four", "/one", "/seven", "/six", "/three", "/two"].map((path) => + directory(path), + ), + ], + }); + const rootId = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${rootManifest}`); + const repository = (name: string, checkoutPath: string) => ({ + kind: "repository", + name, + locator: `https://git.example.invalid/${name}.git`, + locatorFingerprint: "b".repeat(64), + requestedBase: null, + creationCommit: "9".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", + checkoutPath, + }); + const worktree = (repositoryName: string, name: string, checkoutPath: string) => ({ + kind: "worktree", + repositoryName, + name, + requestedBranch: "topic", + requestedBase: null, + creationCommit: "9".repeat(40), + checkoutPath, + }); + const alpha = repository("alpha", "/one"); + const beta = repository("beta", "/two"); + const ALPHA_KEY = JSON.stringify(["repository", "alpha"]); + const BETA_KEY = JSON.stringify(["repository", "beta"]); + // A prefix of three, so a permutation of two of them is a thing a page can + // carry and a sequence can be asked to accept. + const journal = ["a", "b", "c"].map((name, position) => ({ + eventId: `event-${name}`, + record: event(name), + workspaceRootId: rootId, + position, + })); + + interface Page { + readonly after: number | null; + readonly from: number; + readonly rows: readonly unknown[]; + readonly cursor: number | null; + readonly done: boolean; + readonly total: number; + } + interface Plan { + readonly heads: { checkpoint: string; runRecord: string; rootImport: string }; + readonly sections: Record; + } + + /** One page carrying a whole section, as an owner with little to say sends. */ + const whole = (section: string, rows: readonly unknown[]): Page[] => [ + { + after: null, + from: 0, + rows, + cursor: cursorOf(0, rows, null), + done: true, + total: rows.length, + }, + ]; + const sound: Plan = { + heads: { checkpoint: rootId, runRecord: rootId, rootImport: rootId }, + sections: { + inherited: whole("inherited", journal), + roots: whole("roots", [ + { + rootId, + formatVersion: 1, + manifest: rootManifest, + manifestHashes: [manifestHash], + blobHashes: [blobHash], + }, + ]), + manifests: whole("manifests", [ + { hash: manifestHash, size: BLOB.length, lastSeen: 0, encoded: base64(encoded) }, + ]), + blobs: whole("blobs", [ + { hash: blobHash, size: BLOB.length, lastSeen: 0, content: base64(BLOB) }, + ]), + checkouts: whole("checkouts", [alpha, beta]), + }, + }; + const checkouts = (pages: readonly Page[]): Plan => ({ + ...sound, + sections: { ...sound.sections, checkouts: pages }, + }); + const inherited = (pages: readonly Page[]): Plan => ({ + ...sound, + sections: { ...sound.sections, inherited: pages }, + }); + + const broken: Record = { + "a head naming a Workspace root the selection did not carry": { + ...sound, + heads: { ...sound.heads, runRecord: "c".repeat(64) }, + }, + "a head that is no Workspace root identity at all": { + ...sound, + heads: { ...sound.heads, rootImport: "the root import" }, + }, + "a checkout in a directory the checkpoint Workspace does not hold": checkouts( + whole("checkouts", [repository("alpha", "/nowhere")]), + ), + "a Worktree of a Repository the selection did not carry": checkouts( + whole("checkouts", [alpha, worktree("gamma", "topic", "/two")]), + ), + "two checkouts in one directory": checkouts( + whole("checkouts", [alpha, repository("beta", "/one")]), + ), + "a page that redeclares the size of its section": checkouts([ + { after: null, from: 0, rows: [alpha], cursor: 0, done: false, total: 2 }, + { + after: 0, + from: 1, + rows: [beta], + cursor: 1, + done: true, + total: 3, + }, + ]), + "a cursor naming a member the page did not carry": checkouts([ + { after: null, from: 0, rows: [alpha], cursor: 1, done: false, total: 2 }, + { + after: 1, + from: 1, + rows: [beta], + cursor: 1, + done: true, + total: 2, + }, + ]), + "a page repeating what an earlier page carried": checkouts([ + { after: null, from: 0, rows: [alpha], cursor: 0, done: false, total: 2 }, + { + after: 0, + from: 1, + rows: [alpha], + cursor: 0, + done: true, + total: 2, + }, + ]), + "a section arriving out of the order it is sorted in": checkouts([ + { after: null, from: 0, rows: [beta], cursor: 0, done: false, total: 2 }, + { after: 0, from: 1, rows: [alpha], cursor: 1, done: true, total: 2 }, + ]), + "a section ending short of what it declared": checkouts([ + { + after: null, + from: 0, + rows: [alpha, beta], + cursor: 1, + done: true, + total: 3, + }, + ]), + "an unfinished page carrying nothing to continue from": checkouts([ + { after: null, from: 0, rows: [], cursor: null, done: false, total: 2 }, + ]), + "a page beginning past where the sequence had reached": checkouts([ + { after: null, from: 0, rows: [alpha], cursor: 0, done: false, total: 3 }, + { + after: 0, + from: 2, + rows: [beta], + cursor: 1, + done: true, + total: 3, + }, + ]), + "one Repository selected twice in one page": checkouts([ + { + after: null, + from: 0, + rows: [alpha, alpha], + cursor: 0, + done: true, + total: 2, + }, + ]), + "a locator fingerprint that is no digest": checkouts( + whole("checkouts", [{ ...alpha, locatorFingerprint: "the fingerprint" }]), + ), + "an object format this build does not write": checkouts( + whole("checkouts", [{ ...alpha, objectFormat: "sha3" }]), + ), + "a checkout path that is no Workspace path": checkouts( + whole("checkouts", [{ ...alpha, checkoutPath: "one" }]), + ), + // Two rows exchanged inside one page. Their event ids are still unique, + // the page still begins where the sequence reached, and the cursor still + // names the row the page ended on — only the positions travelling with + // the rows say the journal never held them this way. + "two inherited rows exchanged inside one page": inherited( + whole("inherited", [journal[1], journal[0], journal[2]]), + ), + "two inherited rows exchanged across a page boundary": inherited([ + { + after: null, + from: 0, + rows: [journal[0], journal[2]], + cursor: 1, + done: false, + total: 3, + }, + { after: 1, from: 2, rows: [journal[1]], cursor: 2, done: true, total: 3 }, + ]), + "a root import naming a Workspace root the selection did not carry": { + ...sound, + heads: { ...sound.heads, rootImport: "d".repeat(64) }, + }, + }; + + const answering = (plan: Plan) => (read: Record) => { + const section = String(read["section"]); + const pages = plan.sections[section] ?? []; + const asked = read["after"]; + const after = typeof asked === "number" ? asked : null; + const page = pages.find((candidate) => candidate.after === after); + if (page === undefined) { + // A page nothing scripted: the client asked to continue from somewhere + // this owner never sent it. + return { outcome: "refused", refusal: "command:absent" }; + } + return { + outcome: "performed", + value: { + anchor: "anchor-1", + after, + section, + checkpointEventId: "event-work", + checkpointWorkspaceRootId: plan.heads.checkpoint, + runRecordWorkspaceRootId: plan.heads.runRecord, + rootImportWorkspaceRootId: plan.heads.rootImport, + rows: page.rows, + from: page.from, + cursor: page.cursor, + done: page.done, + total: page.total, + }, + }; + }; + + // The same prefix over two pages, with positions that continue across the + // boundary: accepted, in the source's order, with the records untouched. + const paged = yield* installed( + answering( + inherited([ + { + after: null, + from: 0, + rows: [journal[0], journal[1]], + cursor: 1, + done: false, + total: 3, + }, + { after: 1, from: 2, rows: [journal[2]], cursor: 2, done: true, total: 3 }, + ]), + ), + (opened) => opened.forkSource("event-work"), + ); + expect([paged.ok, paged.ok === false && String(paged.error)]).toEqual([true, false]); + if (paged.ok) { + expect(paged.value.inherited.map((row) => row.eventId)).toEqual([ + "event-a", + "event-b", + "event-c", + ]); + expect(paged.value.inherited.map((row) => row.record)).toEqual( + journal.map((row) => row.record), + ); + } + + // Names the retained schema accepts and a separator does not survive. + // `("a:b", "c")` and `("a", "b:c")` join to one string under a colon; + // `("a/b", "c")` and `("a", "b/c")` join to one string under a slash. All + // four are distinct Worktrees of Repositories that came with them. + const colliding: Plan = checkouts( + whole("checkouts", [ + repository("a", "/three"), + repository("a/b", "/five"), + repository("a:b", "/one"), + worktree("a", "b/c", "/seven"), + worktree("a", "b:c", "/four"), + worktree("a/b", "c", "/six"), + worktree("a:b", "c", "/two"), + ]), + ); + const distinct = yield* installed(answering(colliding), (opened) => + opened.forkSource("event-work"), + ); + expect([distinct.ok, distinct.ok === false && String(distinct.error)]).toEqual([true, false]); + if (distinct.ok) { + expect( + distinct.value.checkouts + .filter((one) => one.kind === "worktree") + .map((one) => [one.repositoryName, one.name]), + ).toEqual([ + ["a", "b/c"], + ["a", "b:c"], + ["a/b", "c"], + ["a:b", "c"], + ]); + } + + // The same scripting, undamaged, is accepted: every refusal below is the + // damage and not the shape of the script. + const held = yield* installed(answering(sound), (opened) => opened.forkSource("event-work")); + expect(held.ok).toBe(true); + if (held.ok) { + expect(held.value.checkouts.map((checkout) => checkout.name)).toEqual(["alpha", "beta"]); + } + + for (const [description, plan] of Object.entries(broken)) { + const outcome = yield* installed(answering(plan), (opened) => + opened.forkSource("event-work"), + ); + expect([description, outcome.ok]).toEqual([description, false]); + if (!outcome.ok) { + expect(String(outcome.error)).not.toContain("repository:"); + expect(String(outcome.error)).not.toContain("command:"); + } + } + }); + + it("holds a root to the canonical reference arrays a destination will derive", function* () { + // Two files, so a root's references are an array with an order rather than + // a single value. A destination compares its own derivation element for + // element when it retains the root, so the set being right is not enough. + const files = ["first bytes", "second bytes"].map((text) => { + const bytes = new TextEncoder().encode(text); + const blobHash = sha256Hex(bytes); + const encoded = new TextEncoder().encode( + JSON.stringify({ version: 1, chunks: [{ hash: blobHash, size: bytes.length }] }), + ); + return { bytes, blobHash, encoded, manifestHash: sha256Hex(encoded) }; + }); + const rootManifest = JSON.stringify({ + format: 1, + entries: [ + { path: "/", kind: "directory", mode: 493, mtime: 0 }, + ...files.map((file, index) => ({ + path: `/file-${index}.txt`, + kind: "file", + mode: 420, + mtime: 0, + size: file.bytes.length, + manifest: file.manifestHash, + hardlink: null, + })), + ], + }); + const rootId = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${rootManifest}`); + const manifestHashes = files.map((file) => file.manifestHash).toSorted(compareUtf8); + const blobHashes = files.map((file) => file.blobHash).toSorted(compareUtf8); + const sections = (root: Record): Record => ({ + inherited: [ + { eventId: "event-work", record: event("work"), workspaceRootId: rootId, position: 0 }, + ], + roots: [root], + manifests: files + .map((file) => ({ + hash: file.manifestHash, + size: file.bytes.length, + lastSeen: 0, + encoded: base64(file.encoded), + })) + .toSorted((left, right) => compareUtf8(left.hash, right.hash)), + blobs: files + .map((file) => ({ + hash: file.blobHash, + size: file.bytes.length, + lastSeen: 0, + content: base64(file.bytes), + })) + .toSorted((left, right) => compareUtf8(left.hash, right.hash)), + checkouts: [], + }); + const answering = (root: Record) => (read: Record) => { + const section = String(read["section"]); + const rows = sections(root)[section] ?? []; + return { + outcome: "performed", + value: { + anchor: "anchor-1", + after: null, + section, + checkpointEventId: "event-work", + checkpointWorkspaceRootId: rootId, + runRecordWorkspaceRootId: rootId, + rootImportWorkspaceRootId: rootId, + rows, + from: 0, + cursor: cursorOf(0, rows, null), + done: true, + total: rows.length, + }, + }; + }; + const canonical = { + rootId, + formatVersion: 1, + manifest: rootManifest, + manifestHashes, + blobHashes, + }; + + const held = yield* installed(answering(canonical), (opened) => + opened.forkSource("event-work"), + ); + expect([held.ok, held.ok === false && String(held.error)]).toEqual([true, false]); + if (held.ok) { + expect(held.value.roots[0]?.manifestHashes).toEqual(manifestHashes); + expect(held.value.roots[0]?.blobHashes).toEqual(blobHashes); + } + + const damaged: Record> = { + "content references in an order a root is never retained with": { + ...canonical, + manifestHashes: manifestHashes.toReversed(), + }, + "blob references in an order a root is never retained with": { + ...canonical, + blobHashes: blobHashes.toReversed(), + }, + "one content reference carried twice": { + ...canonical, + manifestHashes: [manifestHashes[0], ...manifestHashes], + }, + "one blob reference carried twice": { + ...canonical, + blobHashes: [blobHashes[0], ...blobHashes], + }, + }; + for (const [description, root] of Object.entries(damaged)) { + const outcome = yield* installed(answering(root), (opened) => + opened.forkSource("event-work"), + ); + expect([description, outcome.ok]).toEqual([description, false]); + } + }); + + it("reads a history answer larger than a fork source's own ceiling", function* () { + // A retained record may be as large as the transaction that wrote it, and + // history is paged by count rather than by bytes, so a single valid row can + // carry more than any fork-source page ever will. What a fork source's + // arithmetic bounds is fork-source pages. + const wide = "w".repeat(1_200_000); + // The canonical retained spelling, terminating newline included: what a + // journal holds is what `serializeDurableEvent()` wrote, and the owner + // accepts a record only when it round-trips to exactly that. + const record = serializeDurableEvent({ + type: "yield", + coroutineId: "root", + description: { type: "test", name: "wide" }, + result: { status: "ok", value: wide }, + }); + const answer = (rows: unknown[]) => ({ + anchor: "event-wide", + after: null, + rows, + done: true, + retainedRoots: [ROOT], + provenance: [], + }); + const page = answer([{ eventId: "event-wide", record, workspaceRootId: ROOT }]); + const bytes = (value: unknown) => + new TextEncoder().encode(JSON.stringify({ outcome: "performed", value })).length; + + // Between the two ceilings: too large for a fork-source answer, and well + // within what public history has always carried. + expect(new TextEncoder().encode(record).length).toBeGreaterThan(1_200_000); + expect(bytes(page)).toBeGreaterThan(FORK_SOURCE_ANSWER_BYTES); + expect(bytes(page)).toBeLessThan(PUBLIC_ANSWER_BYTES); + + const history = yield* installed( + (read) => ({ + outcome: "performed", + value: read["operation"] === "history" ? page : inspection(), + }), + () => WorkflowLifecycle.operations.history(RUN_ID), + ); + expect([history.ok, history.ok === false && String(history.error)]).toEqual([true, false]); + if (history.ok) { + expect(history.value.map((entry) => entry.eventId)).toEqual(["event-wide"]); + expect(history.value[0]?.workspaceRootId).toBe(ROOT); + const held = history.value[0]?.event; + expect(held?.type).toBe("yield"); + expect(held?.type === "yield" && held.result).toEqual({ status: "ok", value: wide }); + } + + // The same bytes as a fork-source answer are refused before they are read, + // so what admits the history answer is the operation asked and not a + // ceiling raised for everyone. + const forked = yield* installed( + () => ({ outcome: "performed", value: page }), + (opened) => opened.forkSource("event-wide"), + ); + expect(forked.ok).toBe(false); + expect(forked.ok === false && forked.error).toEqual(expect.any(WorkflowRecordMalformedError)); + + // And a history answer past its own ceiling still fails closed. + const huge = answer([ + { eventId: "event-wide", record, workspaceRootId: ROOT }, + { eventId: "event-wider", record, workspaceRootId: ROOT }, + ]); + expect(bytes(huge)).toBeGreaterThan(PUBLIC_ANSWER_BYTES); + const refused = yield* installed( + (read) => ({ + outcome: "performed", + value: read["operation"] === "history" ? huge : inspection(), + }), + () => WorkflowLifecycle.operations.history(RUN_ID), + ); + expect(refused.ok).toBe(false); + expect(refused.ok === false && refused.error).toEqual(expect.any(WorkflowRecordMalformedError)); + }); +}); diff --git a/packages/workflow/tests/remote-interoperability.test.ts b/packages/workflow/tests/remote-interoperability.test.ts new file mode 100644 index 000000000..6c1e48f9f --- /dev/null +++ b/packages/workflow/tests/remote-interoperability.test.ts @@ -0,0 +1,196 @@ +/** + * Tier WRH — the two capture implementations describe one Workspace. + * + * The local host walks the DOFS tables inside its own SQLite file. The runner + * walks a real directory. Neither walk can be shared, and a root identity is a + * digest of what the walk produced — so if the two ever disagreed, a run would + * change its Workspace by moving between hosts, and every no-op remote effect + * would propose a root the local host had never seen. + * + * Nothing here is produced by the code under test. The fixture is built through + * the authoritative Workspace transaction and captured by the local provider's + * own `capture()`, exactly as a real run retains a root. That root is then + * served over the remote read boundary, materialized by the production runner + * adapter, and captured again by the runner's implementation. The two + * identities have to be the same string. + * + * The tree is the discriminating one: two hardlink groups holding identical + * bytes, two independent files holding identical bytes, an empty file, a + * symbolic link, distinct modes and modification times, and a file large enough + * to cross more than one chunk. + */ + +import type { RemoteInvocationSnapshot } from "../src/remote/records.ts"; +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { DatabaseSync } from "node:sqlite"; +import { type Operation, scoped } from "effection"; +import type { WorkflowRunDatabase } from "../mod.ts"; +import { runnerFiles, useRunnerTrees } from "../src/deno/remote-files.ts"; +import { + type PrivateWorkspaceTransaction, + transactWorkspaceRoots, +} from "../src/deno/workspace/private.ts"; +import { captureWorkspace, materializeWorkspaceRoot } from "../src/remote/materialize.ts"; +import type { RemoteContent, RemoteContentRequest, RemoteReadLink } from "../src/remote/read.ts"; +import { parseWorkspaceRootManifest } from "../src/workspace/root-manifest.ts"; +import { createRun, runPath, useStorageRoot, withStorage } from "./support/storage.ts"; + +function reject(reason: string): never { + throw new Error(reason); +} + +function* transact( + database: WorkflowRunDatabase, + body: (workspace: PrivateWorkspaceTransaction) => Operation, +): Operation { + const result = yield* transactWorkspaceRoots(database, body); + if (!result.ok) { + throw result.error; + } + return result.value; +} + +/** + * The content one retained root closes over, read out of the run's own store. + * + * The owner would read these rows; here the test does, so what crosses the + * remote read boundary is exactly what the local host retained rather than + * anything the runner computed. + */ +function retainedContent(path: string): { + manifests: Map; + blobs: Map; +} { + const database = new DatabaseSync(path, { readOnly: true }); + try { + const manifests = new Map(); + for (const row of database.prepare("SELECT hash, encoded FROM vfs_manifests").all()) { + manifests.set(hex(row["hash"]), bytes(row["encoded"])); + } + const blobs = new Map(); + for (const row of database.prepare("SELECT hash, bytes FROM vfs_blob_bytes").all()) { + blobs.set(hex(row["hash"]), bytes(row["bytes"])); + } + return { manifests, blobs }; + } finally { + database.close(); + } +} + +function bytes(value: unknown): Uint8Array { + if (!(value instanceof Uint8Array)) { + throw new Error("expected retained content to be bytes"); + } + return value; +} + +function hex(value: unknown): string { + return Array.from(bytes(value), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** The retained root and its content, served the way an owner serves them. */ +function servedBy( + manifest: string, + rootId: string, + content: { manifests: Map; blobs: Map }, +): RemoteReadLink { + return { + // Materialization never asks for this; a stub that answered would say this + // test proved something it did not. + *invocationSnapshot(): Operation { + throw new Error("this read link carries no invocation snapshot"); + }, + // deno-lint-ignore require-yield + *frontier(): Operation { + throw new Error("this owner serves only a root and its content"); + }, + // deno-lint-ignore require-yield + *root(workspaceRootId: string) { + if (workspaceRootId !== rootId) { + throw new Error("asked for a root this owner does not hold"); + } + return parseWorkspaceRootManifest(manifest, reject); + }, + // deno-lint-ignore require-yield + *content(_rootId: string, request: RemoteContentRequest): Operation { + const found = + request.kind === "manifest" + ? content.manifests.get(request.digest) + : content.blobs.get(request.digest); + if (found === undefined) { + throw new Error(`the run retains no ${request.kind} ${request.digest}`); + } + return { kind: request.kind, digest: request.digest, bytes: found }; + }, + }; +} + +/** Everything the format carries, written through the authoritative surface. */ +function* buildWorkspace(workspace: PrivateWorkspaceTransaction): Operation { + const files = workspace.filesystem; + yield* files.mkdir("/docs", { mode: 0o755 }); + yield* files.mkdir("/docs/deep", { mode: 0o700 }); + yield* files.writeFile("/README.md", "a workspace\n", 0o644); + yield* files.writeFile("/empty", new Uint8Array(0), 0o600); + yield* files.writeFile("/docs/guide.md", "# guide\n", 0o644); + // Larger than one chunk, so its manifest names more than one piece. + yield* files.writeFile("/docs/deep/large.bin", new Uint8Array(700 * 1024).fill(7), 0o644); + yield* files.symlink("../README.md", "/docs/link"); + + // Two hardlink groups holding identical bytes: one manifest, two files. + yield* files.writeFile("/shared-a", "shared bytes\n", 0o644); + yield* files.link("/shared-a", "/shared-b"); + yield* files.writeFile("/other-a", "shared bytes\n", 0o644); + yield* files.link("/other-a", "/other-b"); + + // Two independent files holding identical bytes, which stay independent. + yield* files.writeFile("/loose-a", "loose bytes\n", 0o644); + yield* files.writeFile("/loose-b", "loose bytes\n", 0o644); + + // A mode a umask would narrow if a creation mode were trusted. + yield* files.writeFile("/group-writable", "wide\n", 0o666); + yield* files.mkdir("/wide-dir", { mode: 0o777 }); +} + +describe("a root the local host retained", () => { + it("materializes and recaptures to the same identity on the runner", function* () { + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun(); + const retained = yield* transact(database, function* (workspace) { + yield* buildWorkspace(workspace); + return yield* workspace.capture({ publish: true }); + }); + + // The fixture is the local provider's own capture, not the runner's. + const entries = parseWorkspaceRootManifest(retained.manifest, reject).entries; + const linked = entries.filter((entry) => entry.kind === "file" && entry.hardlink !== null); + expect(linked).toHaveLength(4); + expect(new Set(linked.map((entry) => (entry.kind === "file" ? entry.hardlink : "")))).toEqual( + new Set(["h0", "h1"]), + ); + expect(entries.some((entry) => entry.kind === "symlink")).toBe(true); + expect(entries.some((entry) => entry.kind === "file" && entry.size === 0)).toBe(true); + + const content = retainedContent(runPath(root, database.record.runId)); + const reads = servedBy(retained.manifest, retained.rootId, content); + + yield* scoped(function* () { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const tree = yield* trees.create("interoperability"); + const at = (logical: string) => (logical === "/" ? tree : `${tree}${logical}`); + + yield* materializeWorkspaceRoot(files, reads, at, retained.rootId, reject); + const recaptured = yield* captureWorkspace(files, at, reject); + + // One Workspace, two implementations, one identity. + expect(recaptured.root.rootId).toBe(retained.rootId); + expect(recaptured.root.manifest).toBe(retained.manifest); + expect([...recaptured.root.manifests]).toEqual([...retained.manifestHashes]); + expect([...recaptured.root.blobs]).toEqual([...retained.blobHashes]); + }); + }); + }); +}); diff --git a/packages/workflow/tests/remote-lifecycle.test.ts b/packages/workflow/tests/remote-lifecycle.test.ts new file mode 100644 index 000000000..0325f1d3c --- /dev/null +++ b/packages/workflow/tests/remote-lifecycle.test.ts @@ -0,0 +1,518 @@ +/** + * Tier WRH — what the remote executor lifecycle authorizes, and what it refuses. + * + * The owner-side facts — admission contention, pristine initialization, the + * acquisition/execution association surviving hibernation, and one transaction + * per transition — are proved against a real Durable Object in + * `tests/cloudflare/remote-lifecycle.vitest.ts`. These are the other half: that + * the lock is an object rather than a description, that its lifetime is its + * connection's, that one acquisition begins one execution, and that a caller + * never sees a private refusal. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { type Operation, type Result, scoped, spawn, withResolvers } from "effection"; +import { WorkflowLifecycle } from "../src/lifecycle/api.ts"; +import type { ExecutorLock } from "../src/lifecycle/api.ts"; +import type { + WorkflowBeginRequest, + WorkflowExecutionTransitions, +} from "../src/lifecycle/execution.ts"; +import { useRemoteLifecycle } from "../src/remote/lifecycle.ts"; +import { WorkflowRequestError } from "../src/storage/errors.ts"; +import { installedHost, RUN_ID, type Script } from "./support/remote-lifecycle-host.ts"; + +function* acquired(runId = RUN_ID): Operation { + const taken = yield* WorkflowLifecycle.operations.acquireExecutor(runId); + if (!taken.ok) { + throw taken.error; + } + if (taken.value.kind !== "acquired") { + throw new Error("expected the executor lock to be acquired"); + } + return taken.value.lock; +} + +function* installed( + script: Script, + body: (transitions: WorkflowExecutionTransitions) => Operation, +): Operation { + return yield* scoped(function* () { + const transitions = yield* useRemoteLifecycle(installedHost(script)); + return yield* body(transitions); + }); +} + +describe("a remote run's executor lifecycle", () => { + it("hands back a lock nothing else can be mistaken for", function* () { + const outcomes = yield* installed({}, function* (transitions) { + const lock = yield* acquired(); + const request: WorkflowBeginRequest = { runId: RUN_ID, action: "resume" }; + return { + // The same run, the same shape, a different object. + copied: yield* transitions.begin({ runId: lock.runId }, request), + frozen: yield* transitions.begin(Object.freeze({ runId: RUN_ID }), request), + // Another provider's lock: this one was never issued here at all. + foreign: yield* transitions.begin(Object.freeze({ runId: RUN_ID }), request), + held: yield* transitions.begin(lock, request), + }; + }); + + for (const [name, outcome] of Object.entries(outcomes)) { + if (name === "held") { + expect([name, outcome.ok]).toEqual([name, true]); + continue; + } + expect([name, outcome.ok]).toEqual([name, false]); + expect(outcome.ok === false && outcome.error).toEqual(expect.any(WorkflowRequestError)); + } + }); + + it("refuses a lock whose acquisition has ended, and takes nothing while it does", function* () { + const asked: string[] = []; + const outcome = yield* installed({ asked }, function* (transitions) { + // The lock outlives the scope that acquired it; its authority does not. + const escaped = yield* scoped(function* () { + return yield* acquired(); + }); + const after = asked.length; + const refused = yield* transitions.begin(escaped, { runId: RUN_ID, action: "resume" }); + return { refused, before: after, sent: asked.length }; + }); + + expect(outcome.refused.ok).toBe(false); + // Nothing was asked of the owner: a released lock is refused before a + // command is composed, let alone sent. + expect(outcome.sent).toBe(outcome.before); + }); + + it("reports a live executor rather than failing, and advances nothing", function* () { + const asked: string[] = []; + const taken = yield* installed({ asked, admit: "already-running" }, function* () { + return yield* WorkflowLifecycle.operations.acquireExecutor(RUN_ID); + }); + + expect(taken.ok).toBe(true); + expect(taken.ok && taken.value.kind).toBe("already-running"); + expect(asked).toEqual([]); + }); + + it("begins one execution per acquisition, and settles only that one", function* () { + const asked: string[] = []; + const outcome = yield* installed({ asked }, function* (transitions) { + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + if (!begun.ok) { + throw begun.error; + } + const again = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + const foreign = yield* transitions.settle(lock, { + executionId: "execution-elsewhere", + status: "completed", + }); + const settled = yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status: "completed", + }); + return { again, foreign, settled, asked: [...asked] }; + }); + + expect(outcome.again.ok).toBe(false); + expect(outcome.foreign.ok).toBe(false); + expect(outcome.settled.ok).toBe(true); + // The second begin and the foreign settlement never reached the owner. + expect(outcome.asked.filter((command) => command === "begin")).toHaveLength(1); + expect(outcome.asked.filter((command) => command === "settle")).toHaveLength(1); + }); + + it("refuses a begin addressed to another run before the transport", function* () { + const asked: string[] = []; + const outcome = yield* installed({ asked }, function* (transitions) { + const lock = yield* acquired(); + return yield* transitions.begin(lock, { runId: "another-run", action: "resume" }); + }); + + expect(outcome.ok).toBe(false); + expect(asked).toEqual([]); + }); + + it("carries a refusal about the run as its own condition", function* () { + const conditions: readonly ("cancelled" | "resume-failed")[] = ["cancelled", "resume-failed"]; + for (const refusal of conditions) { + const outcome = yield* installed({ begin: refusal }, function* (transitions) { + const lock = yield* acquired(); + return yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + }); + expect([refusal, outcome.ok]).toEqual([refusal, false]); + if (!outcome.ok) { + // The condition, and nothing about how it was spelled underneath. + expect(String(outcome.error)).not.toContain("command:"); + expect(String(outcome.error)).toContain(RUN_ID); + } + } + }); + + it("takes its own acquisition to cancel, and gives it back", function* () { + const opened: string[] = []; + const closed: string[] = []; + const outcome = yield* installed({ opened, closed }, function* () { + return yield* WorkflowLifecycle.operations.cancel(RUN_ID); + }); + + expect(outcome.ok).toBe(true); + expect(opened).toEqual([RUN_ID]); + // The acquisition cancellation took for itself is not still held. + expect(closed).toEqual([RUN_ID]); + }); + + it("refuses to cancel a run a live executor holds", function* () { + const asked: string[] = []; + const outcome = yield* installed({ asked, admit: "already-running" }, function* () { + return yield* WorkflowLifecycle.operations.cancel(RUN_ID); + }); + + expect(outcome.ok).toBe(false); + // Nothing was asked of the owner, and the no-acquisition plane was not + // consulted to decide who holds the run. + expect(asked).toEqual([]); + }); + + it("composes with the reads already installed rather than replacing them", function* () { + const outcome = yield* scoped(function* () { + let asked = false; + yield* WorkflowLifecycle.around({ + // deno-lint-ignore require-yield + *inspect(): Operation> { + asked = true; + throw new WorkflowRequestError("the installed read answered"); + }, + }); + yield* useRemoteLifecycle(installedHost({})); + const taken = yield* WorkflowLifecycle.operations.acquireExecutor(RUN_ID); + try { + yield* WorkflowLifecycle.operations.inspect(RUN_ID); + } catch { + // The installed read answered by raising; what matters is that it was + // the one that answered. + } + return { taken, asked }; + }); + + expect(outcome.taken.ok).toBe(true); + // The lifecycle provider added its two operations over the read provider's, + // rather than installing an object that answers only its own. + expect(outcome.asked).toBe(true); + }); + + it("answers from the nearest provider, over one installed further out", function* () { + const answered: string[] = []; + const outcome = yield* scoped(function* () { + // A provider in an enclosing scope, installed the way every provider in + // this repository installs. Nothing should reach it. + yield* WorkflowLifecycle.around( + { + // deno-lint-ignore require-yield + *acquireExecutor(): Operation> { + answered.push("outer-acquire"); + throw new WorkflowRequestError("the outer provider answered acquireExecutor"); + }, + // deno-lint-ignore require-yield + *cancel(): Operation> { + answered.push("outer-cancel"); + throw new WorkflowRequestError("the outer provider answered cancel"); + }, + // deno-lint-ignore require-yield + *inspect(): Operation> { + answered.push("outer-inspect"); + throw new WorkflowRequestError("the outer provider answered inspect"); + }, + }, + { at: "min" }, + ); + return yield* scoped(function* () { + // The read provider, then the lifecycle provider, both nearer the work. + yield* WorkflowLifecycle.around( + { + // deno-lint-ignore require-yield + *inspect(): Operation> { + answered.push("inner-inspect"); + throw new WorkflowRequestError("the inner read provider answered inspect"); + }, + }, + { at: "min" }, + ); + yield* useRemoteLifecycle(installedHost({})); + const taken = yield* WorkflowLifecycle.operations.acquireExecutor(RUN_ID); + const cancelled = yield* WorkflowLifecycle.operations.cancel(RUN_ID); + try { + yield* WorkflowLifecycle.operations.inspect(RUN_ID); + } catch { + // The read provider answers by raising; which one raised is what is + // being observed. + } + return { taken, cancelled }; + }); + }); + + expect(outcome.taken.ok).toBe(true); + expect(outcome.cancelled.ok).toBe(true); + // The nearest provider answered its own operations, the read provider + // installed beside it still answered its own, and the outer one answered + // nothing at all. + expect(answered).toEqual(["inner-inspect"]); + }); + + it("asks the same question after a lost answer, and gets one decision", function* () { + const commands: string[] = []; + const loseAnswer = new Set(); + const committed = new Map(); + const script: Script = { commands, loseAnswer, committed: committed as never }; + const outcome = yield* scoped(function* () { + const transitions = yield* useRemoteLifecycle(installedHost(script)); + const first = yield* scoped(function* () { + const lock = yield* acquired(); + // The owner will commit and the answer will be lost. + loseAnswer.add("command-1:begin"); + return yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + }); + // The connection is gone with its answer. A replacement acquisition asks + // the same question. + const second = yield* scoped(function* () { + const lock = yield* acquired(); + return yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + }); + return { first, second }; + }); + + expect(outcome.first.ok).toBe(false); + expect(outcome.second.ok).toBe(true); + // The same command identity both times, so the owner answered with the + // decision it had already made rather than making a second one. + expect(commands).toEqual(["command-1:begin", "command-1:begin"]); + if (outcome.second.ok) { + // And the execution the caller is handed is the one that was begun. + expect(outcome.second.value.execution.executionId).toBe("execution-1"); + } + }); + + it("refuses a second call while the first is still waiting for its answer", function* () { + const asked: string[] = []; + const entered = withResolvers(); + const release = withResolvers(); + const script: Script = { + asked, + gate: { + *wait(): Operation { + entered.resolve(); + yield* release.operation; + }, + }, + }; + const outcome = yield* installed(script, function* (transitions) { + const lock = yield* acquired(); + const request: WorkflowBeginRequest = { runId: RUN_ID, action: "resume" }; + const first = yield* spawn(() => transitions.begin(lock, request)); + // The first call has sent and is waiting for its answer. The second is a + // different call, however equal its arguments look. + yield* entered.operation; + const second = yield* transitions.begin(lock, request); + release.resolve(); + return { first: yield* first, second }; + }); + + expect(outcome.first.ok).toBe(true); + expect(outcome.second.ok).toBe(false); + // One owner mutation, not two. + expect(asked.filter((command) => command === "begin")).toHaveLength(1); + }); + + it("gives a corrected request a new identity after a definitive answer", function* () { + const commands: string[] = []; + const outcome = yield* installed({ commands, begin: "cancelled" }, function* (transitions) { + const lock = yield* acquired(); + const refused = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + return { refused, commands: [...commands] }; + }); + // The owner answered, so that question is finished. What it is not is a + // claim on the next one's identity. + expect(outcome.refused.ok).toBe(false); + expect(outcome.commands).toEqual(["command-1:begin"]); + + const second = yield* installed({ commands: [] }, function* (transitions) { + const lock = yield* acquired(); + return yield* transitions.begin(lock, { runId: RUN_ID, action: "start" }); + }); + expect(second.ok).toBe(true); + }); + + it("asks the same cancellation again when its answer was lost", function* () { + const commands: string[] = []; + const outcome = yield* installed( + { commands, loseAnswer: new Set(["command-1:cancel"]) }, + function* () { + const first = yield* WorkflowLifecycle.operations.cancel(RUN_ID); + // The connection that asked is gone; the question is not. + const second = yield* WorkflowLifecycle.operations.cancel(RUN_ID); + return { first, second, commands: [...commands] }; + }, + ); + + expect(outcome.first.ok).toBe(false); + expect(outcome.second.ok).toBe(true); + // One identity, asked twice, so the owner answers with what it decided. + expect(outcome.commands).toEqual(["command-1:cancel", "command-1:cancel"]); + }); + + it("gives a later cancellation a fresh identity once one was answered", function* () { + const commands: string[] = []; + const outcome = yield* installed({ commands }, function* () { + const first = yield* WorkflowLifecycle.operations.cancel(RUN_ID); + const second = yield* WorkflowLifecycle.operations.cancel(RUN_ID); + return { first, second, commands: [...commands] }; + }); + + expect(outcome.first.ok).toBe(true); + expect(outcome.second.ok).toBe(true); + // Answered, so the claim was retired and the next call is its own. + expect(outcome.commands).toEqual(["command-1:cancel", "command-2:cancel"]); + }); + + it("frees the acquisition when a call is cancelled before it sends", function* () { + const asked: string[] = []; + const outcome = yield* installed({ asked }, function* (transitions) { + const lock = yield* acquired(); + // Cancelled while it is still deciding it may not proceed: nothing was + // sent, so the acquisition is free to try again. + const refused = yield* transitions.begin(lock, { + runId: RUN_ID, + action: "resume", + creation: { + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + }, + }); + const after = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + return { refused, after, asked: [...asked] }; + }); + + expect(outcome.refused.ok).toBe(false); + // The guard lifted, so the corrected call went through. + expect(outcome.after.ok).toBe(true); + expect(outcome.asked).toEqual(["begin"]); + }); + + it("keeps the question a cancelled begin was asking, and lets a replacement finish it", function* () { + const asked: string[] = []; + const commands: string[] = []; + const decided: string[] = []; + const retired: string[] = []; + const reused: string[] = []; + const entered = withResolvers(); + let held = 0; + const script: Script = { + asked, + commands, + decided, + retired, + reused, + committed: new Map(), + gate: { + *wait(): Operation { + held += 1; + if (held > 1) { + // Only the first answer is caught in flight. The replacement's is + // delivered, which is the whole point of taking one. + return; + } + entered.resolve(); + yield* withResolvers().operation; + }, + }, + }; + const outcome = yield* installed(script, function* (transitions) { + yield* scoped(function* () { + const lock = yield* acquired(); + const sent = yield* spawn(() => + transitions.begin(lock, { runId: RUN_ID, action: "resume" }), + ); + yield* entered.operation; + // Interrupted with the owner's decision made and its answer in flight. + yield* sent.halt(); + }); + // A replacement acquisition, asking the same question. + return yield* scoped(function* () { + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + if (!begun.ok) { + return { begun, settled: undefined, again: undefined }; + } + const settled = yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status: "completed", + }); + // The same execution cannot be settled twice through this lock. + const again = yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status: "completed", + }); + return { begun, settled, again }; + }); + }); + + // The interrupted acquisition gave up its connection, and the replacement + // asked under the exact identity the interrupted call was asking under. + expect(retired).toEqual([RUN_ID]); + expect(commands.slice(0, 2)).toEqual(["command-1:begin", "command-1:begin"]); + expect(outcome.begun.ok).toBe(true); + // One execution was ever begun, and the replacement adopted that one. + expect(decided).toEqual(["execution-1"]); + expect(outcome.begun.ok && outcome.begun.value.execution.executionId).toBe("execution-1"); + // It settles exactly once through the lock that adopted it. + expect(outcome.settled?.ok).toBe(true); + expect(outcome.again?.ok).toBe(false); + expect(asked.filter((command) => command === "settle")).toHaveLength(1); + expect(reused).toEqual([]); + }); + + it("retires an acquisition cancelled after its command went out", function* () { + const asked: string[] = []; + const retired: string[] = []; + const entered = withResolvers(); + const script: Script = { + asked, + retired, + gate: { + *wait(): Operation { + entered.resolve(); + // Never resolved: this call is cancelled while it waits. + yield* withResolvers().operation; + }, + }, + }; + const outcome = yield* installed(script, function* (transitions) { + const lock = yield* acquired(); + const sent = yield* spawn(() => transitions.begin(lock, { runId: RUN_ID, action: "resume" })); + yield* entered.operation; + // The call is interrupted with its answer outstanding. + yield* sent.halt(); + // The lock cannot start anything else: whether the owner committed is + // unknown, and a fresh mutation must not race that. + return yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + }); + + expect(outcome.ok).toBe(false); + expect(asked.filter((command) => command === "begin")).toHaveLength(1); + // The connection went with the lock, before its scope ended. A socket + // still holding the run for a lock nobody may use would leave the run + // unreachable by anybody. + expect(retired).toEqual([RUN_ID]); + }); +}); diff --git a/packages/workflow/tests/remote-materialization.test.ts b/packages/workflow/tests/remote-materialization.test.ts new file mode 100644 index 000000000..0ba0b8046 --- /dev/null +++ b/packages/workflow/tests/remote-materialization.test.ts @@ -0,0 +1,244 @@ +/** + * Tier WRH — putting a retained root on a runner and reading it back. + * + * The claim under test is one equality: an untouched materialization captures + * to the exact root it was materialized from. Everything else in the remote + * provider rests on it. If it did not hold, a Workspace operation that changed + * nothing would still propose a new root, every no-op would look like a + * mutation, and the owner could not tell a real change from an artefact of how + * the runner unpacked the tree. + * + * A real temporary filesystem, deliberately. Modes, modification times, an + * empty file, a symbolic link and a hardlink group are properties of a + * filesystem, and a fake that stored them in a map would prove only that the + * map kept what it was given. + */ + +import type { RemoteInvocationSnapshot } from "../src/remote/records.ts"; +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, type Operation, resource, scoped, until } from "effection"; +import { + chmod, + link, + lutimes, + mkdir, + mkdtemp, + rm, + symlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { runnerFiles, useRunnerTrees } from "../src/deno/remote-files.ts"; +import { + captureWorkspace, + materializeWorkspaceRoot, + type RunnerFiles, +} from "../src/remote/materialize.ts"; +import type { RemoteContent, RemoteContentRequest, RemoteReadLink } from "../src/remote/read.ts"; +import type { WorkspaceRootManifest } from "../src/workspace/root-manifest.ts"; +import { parseWorkspaceRootManifest } from "../src/workspace/root-manifest.ts"; +import { encodeContentManifest } from "../src/workspace/content-manifest.ts"; + +function reject(reason: string): never { + throw new Error(reason); +} + +/** Where one logical Workspace path sits under `root`. */ +function at(root: string): (logical: string) => string { + return (logical) => (logical === "/" ? root : join(root, logical.slice(1))); +} + +/** + * An owner that serves exactly what a capture produced. + * + * It answers from the capture's own manifests and blobs, so what crosses is + * what the runner would have had to send. Nothing here validates: the point is + * that materialization rebuilds the tree, and the validation of pieces is the + * connection's, proved where the connection is. + */ +function servedBy(captured: { + root: { manifest: string; rootId: string }; + contents: ReadonlyMap; + blobs: ReadonlyMap; +}): RemoteReadLink { + return { + // Materialization never asks for this; a stub that answered would say this + // test proved something it did not. + *invocationSnapshot(): Operation { + throw new Error("this read link carries no invocation snapshot"); + }, + // deno-lint-ignore require-yield + *frontier(): Operation { + throw new Error("this owner serves only a root and its content"); + }, + // deno-lint-ignore require-yield + *root(workspaceRootId: string): Operation { + if (workspaceRootId !== captured.root.rootId) { + throw new Error("asked for a root this owner does not hold"); + } + return parseWorkspaceRootManifest(captured.root.manifest, reject); + }, + // deno-lint-ignore require-yield + *content(_rootId: string, request: RemoteContentRequest): Operation { + const bytes = + request.kind === "manifest" + ? captured.contents.get(request.digest)?.manifestBytes + : captured.blobs.get(request.digest); + if (bytes === undefined) { + throw new Error("asked for content this owner does not hold"); + } + return { kind: request.kind, digest: request.digest, bytes }; + }, + }; +} + +/** One tree with every entry kind the format carries. */ +function* buildTree(root: string): Operation { + yield* until(mkdir(join(root, "docs"), { mode: 0o755 })); + yield* until(mkdir(join(root, "docs", "deep"), { mode: 0o700 })); + yield* until(writeFile(join(root, "README.md"), "a workspace\n", { mode: 0o644 })); + yield* until(writeFile(join(root, "empty"), new Uint8Array(0), { mode: 0o600 })); + yield* until(writeFile(join(root, "docs", "guide.md"), "# guide\n", { mode: 0o644 })); + // Larger than one chunk, so the manifest names more than one piece. + yield* until( + writeFile(join(root, "docs", "deep", "large.bin"), new Uint8Array(700 * 1024).fill(7), { + mode: 0o644, + }), + ); + yield* until(symlink("../README.md", join(root, "docs", "link"))); + + // Two hardlink groups holding *identical* bytes. They share one DOFS + // manifest and are still two files, so a materializer that indexed by + // content would link the second group to the first and merge them. + yield* until(writeFile(join(root, "shared-a"), "shared bytes\n", { mode: 0o644 })); + yield* until(link(join(root, "shared-a"), join(root, "shared-b"))); + yield* until(writeFile(join(root, "other-a"), "shared bytes\n", { mode: 0o644 })); + yield* until(link(join(root, "other-a"), join(root, "other-b"))); + + // And two independent files with the same bytes, which must stay two files + // with no hardlink group at all. + yield* until(writeFile(join(root, "loose-a"), "loose bytes\n", { mode: 0o644 })); + yield* until(writeFile(join(root, "loose-b"), "loose bytes\n", { mode: 0o644 })); + + // Modes the usual 0022 umask narrows at creation, set explicitly so the + // retained root genuinely carries them. Materialization then has to restore + // them under the same umask, which is only possible by setting them. + yield* until(writeFile(join(root, "group-writable"), "wide\n")); + yield* until(chmod(join(root, "group-writable"), 0o666)); + yield* until(mkdir(join(root, "wide-dir"))); + yield* until(chmod(join(root, "wide-dir"), 0o777)); + + for (const [path, mtime] of [ + [join(root, "README.md"), 1_700_000_001], + [join(root, "empty"), 1_700_000_002], + [join(root, "docs", "guide.md"), 1_700_000_003], + [join(root, "docs", "deep", "large.bin"), 1_700_000_004], + [join(root, "shared-a"), 1_700_000_005], + [join(root, "other-a"), 1_700_000_009], + [join(root, "loose-a"), 1_700_000_010], + [join(root, "loose-b"), 1_700_000_011], + [join(root, "group-writable"), 1_700_000_012], + [join(root, "wide-dir"), 1_700_000_013], + [join(root, "docs", "deep"), 1_700_000_006], + [join(root, "docs"), 1_700_000_007], + [root, 1_700_000_008], + ] as const) { + yield* until(utimes(path, mtime, mtime)); + } + // The link's own time, well in the past and set without following it. + yield* until(lutimes(join(root, "docs", "link"), 1_600_000_000, 1_600_000_000)); +} + +describe("materializing a retained Workspace root", () => { + it("captures an untouched materialization back to the exact root it came from", function* () { + // A umask that would narrow a created mode, so the restoration has to be + // explicit rather than incidental. + const previous = process.umask(0o022); + const files: RunnerFiles = runnerFiles(); + const trees = yield* useRunnerTrees(); + const source = yield* trees.create("source"); + yield* buildTree(source); + + const captured = yield* captureWorkspace(files, at(source), reject); + const entries = captured.root.entries; + // The tree really does exercise what the format carries. + expect(entries.filter((entry) => entry.kind === "directory")).toHaveLength(4); + expect(entries.filter((entry) => entry.kind === "symlink")).toHaveLength(1); + // Two groups of two, holding identical bytes and still two groups. + const linked = entries.filter((entry) => entry.kind === "file" && entry.hardlink !== null); + expect(linked).toHaveLength(4); + expect(new Set(linked.map((entry) => (entry.kind === "file" ? entry.hardlink : "")))).toEqual( + new Set(["h0", "h1"]), + ); + // And they share one manifest, which is what makes this discriminating: + // a materializer indexing by content would merge them. + expect(new Set(linked.map((entry) => (entry.kind === "file" ? entry.manifest : ""))).size).toBe( + 1, + ); + // Equal bytes did not make the independent pair into a group. + for (const path of ["/loose-a", "/loose-b"]) { + const loose = entries.find((entry) => entry.path === path); + expect(loose?.kind === "file" && loose.hardlink).toBe(null); + } + // The wide modes survived the umask rather than being narrowed by it. + expect(entries.find((entry) => entry.path === "/group-writable")?.mode).toBe(0o666); + expect(entries.find((entry) => entry.path === "/wide-dir")?.mode).toBe(0o777); + expect(entries.find((entry) => entry.path === "/docs/link")?.mtime).toBe(1_600_000_000_000); + expect(entries.some((entry) => entry.kind === "file" && entry.size === 0)).toBe(true); + // 700 KiB is two chunks at the pinned chunk size, so a file crosses the + // transport as more than one piece. + const large = entries.find((entry) => entry.path === "/docs/deep/large.bin"); + if (large?.kind !== "file") { + throw new Error("expected the large file to be captured as a file"); + } + expect(captured.contents.get(large.manifest)?.chunks).toHaveLength(2); + + const destination = yield* trees.create("destination"); + yield* materializeWorkspaceRoot( + files, + servedBy(captured), + at(destination), + captured.root.rootId, + reject, + ); + + const again = yield* captureWorkspace(files, at(destination), reject); + process.umask(previous); + expect(again.root.rootId).toBe(captured.root.rootId); + expect(again.root.manifest).toBe(captured.root.manifest); + expect([...again.root.manifests]).toEqual([...captured.root.manifests]); + expect([...again.root.blobs]).toEqual([...captured.root.blobs]); + }); + + it("encodes a content manifest the way the store stores one", function* () { + // The runner and the owner must name identical bytes identically, and the + // encoding is what decides that. + expect( + new TextDecoder().decode(encodeContentManifest([{ hash: "a".repeat(64), size: 3 }])), + ).toBe(`{"version":1,"chunks":[{"hash":"${"a".repeat(64)}","size":3}]}`); + expect(new TextDecoder().decode(encodeContentManifest([]))).toBe('{"version":1,"chunks":[]}'); + }); + + it("removes the materialization when its scope ends, however it ends", function* () { + const files: RunnerFiles = runnerFiles(); + let path = ""; + yield* scoped(function* () { + const trees = yield* useRunnerTrees(); + path = yield* trees.create("scoped"); + yield* until(writeFile(join(path, "present"), "here\n")); + }); + // The scope that owned it has ended, so the tree is gone rather than left + // behind for a later invocation to find. + let listed: unknown; + try { + listed = yield* files.list(path); + } catch (error) { + listed = error; + } + expect(listed).toBeInstanceOf(Error); + }); +}); diff --git a/packages/workflow/tests/remote-publication.test.ts b/packages/workflow/tests/remote-publication.test.ts new file mode 100644 index 000000000..0d2839c87 --- /dev/null +++ b/packages/workflow/tests/remote-publication.test.ts @@ -0,0 +1,985 @@ +/** + * Tier WRH — what the production runner sends, and what it keeps. + * + * The owner's half is proved on real workerd, where atomicity and hibernation + * are real. This is the other half: whether the runner can build the command + * the owner accepts, whether it sends one at all when the work did not finish, + * and whether anything survives on disk that should not. + * + * The connection is a deterministic fake because what crosses it is arithmetic + * over what the transaction decided. The filesystem is real, because a tree + * that was supposed to be removed is not a claim a fake can settle. + */ + +import type { RemoteInvocationSnapshot } from "../src/remote/records.ts"; +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import { ensure, type Operation, scoped, sleep, spawn, until } from "effection"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { agentSessionKey } from "../src/storage/agent-session.ts"; +import { cloudflareOwnerLink } from "../src/cloudflare/client.ts"; +import { runnerFiles, useRunnerTrees } from "../src/deno/remote-files.ts"; +import { + type CommitIntent, + createTransactionGate, + transactRemotely, +} from "../src/remote/collector.ts"; +import { useAttempt, useMaterialization } from "../src/remote/invocation.ts"; +import { type OwnerSocket, type SocketListener, useOwnerConnection } from "../src/remote/client.ts"; +import type { + RemoteContent, + RemoteContentRequest, + RemoteFrontierSnapshot, + RemoteReadLink, +} from "../src/remote/read.ts"; +import { captureWorkspace, type CapturedWorkspace } from "../src/remote/materialize.ts"; +import { + parseWorkspaceRootManifest, + WORKSPACE_ROOT_DOMAIN, +} from "../src/workspace/root-manifest.ts"; +import { sha256Hex } from "../src/workspace/sha256.ts"; +import { locatorFingerprintOf } from "../src/composition/locator.ts"; +import type { RetainedMapping } from "../src/remote/publication.ts"; + +function reject(reason: string): never { + throw new Error(reason); +} + +function event(name: string) { + return { + type: "yield" as const, + coroutineId: "root", + description: { type: "test", name }, + result: { status: "ok" as const, value: name }, + }; +} + +const LOCATOR = "https://git.example.invalid/octo/app.git"; + +/** The Repository mapping these tests enlist. */ +function repositoryMapping(): RetainedMapping { + return { + kind: "repository", + locator: LOCATOR, + record: { + name: "app", + locatorFingerprint: locatorFingerprintOf(LOCATOR), + requestedBase: null, + creationCommit: "9".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", + checkoutPath: "/docs", + }, + }; +} + +/** A recording connection: every request it was sent, and canned answers. */ +function wire(answer: (request: Record) => Record) { + const sent: Record[] = []; + const listeners = new Map>(); + let deliver = true; + const socket: OwnerSocket = { + send(data: string): void { + const request = JSON.parse(data) as Record; + sent.push(request); + if (!deliver) { + return; + } + const response = answer(request); + for (const listener of listeners.get("message") ?? []) { + listener({ data: JSON.stringify({ id: request["id"], ...response }) }); + } + }, + close(): void {}, + addEventListener(type, listener): void { + const found = listeners.get(type) ?? new Set(); + found.add(listener); + listeners.set(type, found); + }, + removeEventListener(type, listener): void { + listeners.get(type)?.delete(listener); + }, + }; + return { + socket, + sent, + /** Stop answering, as a connection lost mid-request would. */ + silence(): void { + deliver = false; + }, + end(): void { + for (const listener of listeners.get("close") ?? []) { + listener({}); + } + }, + }; +} + +/** + * What a correct owner answers each private command with. + * + * The commit answer is derived from the request the way the owner derives it: + * the root the proposal selected — proposed when there is a publication, the + * unchanged expected one when there is not — and one minted identity for each + * event. The runner checks the answer against what it asked, so an owner that + * answered with something else would not be believed. + * + * `sizes` is what the owner measured after decoding staged bytes. + */ +function ownerAnswers(_rootId = "", _sizes: ReadonlyMap = new Map()) { + return (request: Record): Record => { + if (request["command"] === "stage") { + // The length the owner would have measured after decoding, computed from + // the encoding itself so this answers about the bytes it was actually + // sent rather than about a number a test remembered to set. + const encoded = String(request["bytes"] ?? ""); + const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; + return { + outcome: "performed", + value: { + kind: request["kind"], + digest: request["digest"], + size: (encoded.length / 4) * 3 - padding, + }, + }; + } + const publication = request["publication"]; + const selected = + publication === null || publication === undefined + ? request["expectedWorkspaceRootId"] + : (publication as Record)["proposedWorkspaceRootId"]; + const events = Array.isArray(request["events"]) ? request["events"] : []; + return { + outcome: "performed", + value: { + workspaceRootId: selected, + journalEventIds: events.map((_entry, index) => `event-${index}`), + }, + }; + }; +} + +/** The final command a transaction sent, proved to be one. */ +function lastCommit(sent: readonly Record[]): Record { + const commit = sent.at(-1); + if (commit === undefined || commit["command"] !== "commit") { + throw new Error("expected the last request to be a commit"); + } + return commit; +} + +/** One object member, read rather than asserted into shape. */ +function member(value: unknown, name: string): Record { + const found = value === null || typeof value !== "object" ? undefined : Object.entries(value); + const entry = found?.find(([key]) => key === name)?.[1]; + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`expected ${name} to be an object`); + } + return Object.fromEntries(Object.entries(entry)); +} + +/** One text member, read rather than asserted. */ +function text(value: Record, name: string): string { + const found = value[name]; + if (typeof found !== "string") { + throw new Error(`expected ${name} to be text`); + } + return found; +} + +/** One list member, read the same way. */ +function memberList(value: Record, name: string): Record[] { + const entry = value[name]; + if (!Array.isArray(entry)) { + throw new Error(`expected ${name} to be a list`); + } + return entry.map((item) => { + if (item === null || typeof item !== "object" || Array.isArray(item)) { + throw new Error(`expected every ${name} entry to be an object`); + } + return Object.fromEntries(Object.entries(item)); + }); +} + +function ids(): () => string { + let id = 0; + return () => `request-${(id += 1)}`; +} + +/** An owner that answers frontier/root/content from one captured tree. */ +function readsOf(captured: { + root: { manifest: string; rootId: string }; + contents: ReadonlyMap; + blobs: ReadonlyMap; +}): RemoteReadLink { + return { + // Materialization never asks for this; a stub that answered would say this + // test proved something it did not. + *invocationSnapshot(): Operation { + throw new Error("this read link carries no invocation snapshot"); + }, + // deno-lint-ignore require-yield + *frontier(): Operation { + return { + record: { + runId: "remote-run", + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + status: "running", + createdAt: "2026-09-03T00:00:00.000Z", + updatedAt: "2026-09-03T00:00:00.000Z", + }, + retrieval: undefined, + workspaceRootId: captured.root.rootId, + journalEventId: null, + entries: [], + }; + }, + // deno-lint-ignore require-yield + *root(workspaceRootId: string) { + if (workspaceRootId !== captured.root.rootId) { + throw new Error("asked for a root this owner does not hold"); + } + return parseWorkspaceRootManifest(captured.root.manifest, reject); + }, + // deno-lint-ignore require-yield + *content(_rootId: string, request: RemoteContentRequest): Operation { + const bytes = + request.kind === "manifest" + ? captured.contents.get(request.digest)?.manifestBytes + : captured.blobs.get(request.digest); + if (bytes === undefined) { + throw new Error("asked for content this owner does not hold"); + } + return { kind: request.kind, digest: request.digest, bytes }; + }, + }; +} + +/** A small starting tree, captured so an owner can serve it. */ +function* startingTree(): Operation<{ captured: CapturedWorkspace; reads: RemoteReadLink }> { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const root = yield* trees.create("source"); + yield* until(writeFile(`${root}/README.md`, "starting\n", { mode: 0o644 })); + yield* until(mkdir(`${root}/docs`, { mode: 0o755 })); + const captured = yield* captureWorkspace( + files, + (logical) => (logical === "/" ? root : `${root}${logical}`), + reject, + ); + return { captured, reads: readsOf(captured) }; +} + +describe("what the production runner publishes", () => { + it("sends one closed commit describing everything the transaction decided", function* () { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + const sizes = new Map(); + const transport = wire(ownerAnswers(captured.root.rootId, sizes)); + const connection = yield* useOwnerConnection(transport.socket); + + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + yield* until(writeFile(attempt.at("/NOTES.md"), "written by the effect\n", { mode: 0o644 })); + const proposed = yield* attempt.capture(); + for (const [digest, content] of proposed.contents) { + sizes.set(digest, content.manifestBytes.length); + } + for (const [digest, bytes] of proposed.blobs) { + sizes.set(digest, bytes.length); + } + + const link = cloudflareOwnerLink(connection, reads, ids()); + const committed = yield* transactRemotely( + link, + createTransactionGate(), + function* (transaction, enlist) { + yield* transaction.journal.append(event("published")); + enlist(attempt, [repositoryMapping()]); + return "done"; + }, + ); + expect(committed).toMatchObject({ ok: true }); + + // The last request is one closed commit carrying the whole proposal. + const commit = lastCommit(transport.sent); + expect(commit["expectedWorkspaceRootId"]).toBe(captured.root.rootId); + expect(commit["events"]).toEqual([serializeDurableEvent(event("published"))]); + const publication = member(commit, "publication"); + expect(publication["proposedWorkspaceRootId"]).toBe(proposed.root.rootId); + expect(sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${String(publication["proposedManifest"])}`)).toBe( + proposed.root.rootId, + ); + expect(text(memberList(commit, "mappings")[0] ?? {}, "locator")).toBe(LOCATOR); + // Everything the proposal names was staged before the commit went out. + const staged = transport.sent.filter((request) => request["command"] === "stage"); + expect(staged.length).toBe(proposed.root.manifests.length + proposed.root.blobs.length); + }); + + it("sends no commit and keeps no tree when the body does not finish", function* () { + const files = runnerFiles(); + const outcomes: Record Operation> = {}; + for (const description of ["raises", "is cancelled"]) { + let attemptPath = ""; + const transport = wire(ownerAnswers("")); + yield* scoped(function* () { + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); + let raised: unknown; + yield* scoped(function* () { + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + attemptPath = attempt.at("/"); + if (description === "raises") { + try { + yield* transactRemotely(link, createTransactionGate(), function* () { + throw new Error("the effect failed"); + }); + } catch (error) { + raised = error; + } + return; + } + const running = yield* spawn(() => + transactRemotely(link, createTransactionGate(), function* () { + yield* sleep(10_000); + return "never"; + }), + ); + yield* sleep(0); + yield* running.halt(); + }); + expect([description, description === "raises" ? raised instanceof Error : true]).toEqual([ + description, + true, + ]); + }); + // No commit was sent, and the attempt tree is gone. + expect([ + description, + transport.sent.some((request) => request["command"] === "commit"), + ]).toEqual([description, false]); + let listed: unknown; + try { + listed = yield* until(readdir(attemptPath)); + } catch (error) { + listed = error; + } + expect([description, listed instanceof Error]).toEqual([description, true]); + } + void outcomes; + }); + + it("transfers the attempt inside the transaction that the owner performed", function* () { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + const sizes = new Map(); + const transport = wire(ownerAnswers("", sizes)); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); + const acceptedBefore = materialization.at("/"); + + let attemptRoot = ""; + let acceptedDuringBody = ""; + const committed = yield* scoped(function* () { + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + attemptRoot = attempt.at("/"); + yield* until(writeFile(attempt.at("/NOTES.md"), "published\n", { mode: 0o644 })); + const proposed = yield* attempt.capture(); + for (const [digest, content] of proposed.contents) { + sizes.set(digest, content.manifestBytes.length); + } + for (const [digest, blob] of proposed.blobs) { + sizes.set(digest, blob.length); + } + return yield* transactRemotely(link, createTransactionGate(), function* (_tx, enlist) { + enlist(attempt); + // Still the old Workspace while the answer is unknown. + acceptedDuringBody = materialization.at("/"); + return "done"; + }); + }); + + expect(committed).toMatchObject({ ok: true }); + expect(acceptedDuringBody).toBe(acceptedBefore); + + // By the time the transaction reported success the transfer had happened: + // the accepted path is the attempt's tree and reads the attempted bytes. + expect(materialization.at("/")).toBe(attemptRoot); + expect(materialization.workspaceRootId).not.toBe(captured.root.rootId); + expect(yield* until(readFile(materialization.at("/NOTES.md"), "utf8"))).toBe("published\n"); + + // And the tree the run used to be at is gone. + let listed: unknown; + try { + listed = yield* until(readdir(acceptedBefore)); + } catch (error) { + listed = error; + } + expect(listed).toBeInstanceOf(Error); + }); + + it("offers no way to move the accepted Workspace without the owner", function* () { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); + const accepted = materialization.at("/"); + + yield* scoped(function* () { + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + yield* until(writeFile(attempt.at("/NOTES.md"), "never published\n", { mode: 0o644 })); + + // Everything a caller can reach by name. Reading where the Workspace is, + // reading what an attempt holds, and putting an attempt back to the + // accepted root — and nothing that moves either. `restore` throws this + // attempt's own work away; it publishes nothing, and the accepted + // materialization it restores from is what the owner already confirmed. + expect(Object.keys(materialization).toSorted()).toEqual(["at", "workspaceRootId"]); + expect(Object.keys(attempt).toSorted()).toEqual(["at", "capture", "restore"]); + const reachable = [ + ...Object.getOwnPropertyNames(attempt), + ...Object.getOwnPropertyNames(materialization), + ]; + for (const name of ["promote", "transfer", "replace", "accept", "propose", "seal"]) { + expect(reachable).not.toContain(name); + } + + // A caller can still capture. What it gets back is a description, and + // there is nothing to hand it to: `enlist` takes an attempt, so a + // publication that no live attempt owns cannot be expressed at all. + const described = yield* attempt.capture(); + expect(described.root.rootId).not.toBe(captured.root.rootId); + }); + + expect(materialization.at("/")).toBe(accepted); + expect(materialization.workspaceRootId).toBe(captured.root.rootId); + }); + + it("cannot be changed by a caller that kept its own copy", function* () { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + const transport = wire(ownerAnswers("")); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); + + const mappings: RetainedMapping[] = [repositoryMapping()]; + yield* scoped(function* () { + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist(attempt, mappings); + // The caller still holds the array it passed and edits it afterwards. + const first = mappings[0]; + if (first?.kind === "repository") { + mappings[0] = { ...first, locator: "https://elsewhere.invalid/x.git" }; + } + return "done"; + }); + }); + expect(text(memberList(lastCommit(transport.sent), "mappings")[0] ?? {}, "locator")).toBe( + LOCATOR, + ); + }); + + it("sends a journal-only commit with no publication and stages nothing", function* () { + const files = runnerFiles(); + yield* scoped(function* () { + const trees = yield* useRunnerTrees(); + void trees; + const { captured, reads } = yield* startingTree(); + const transport = wire(ownerAnswers(captured.root.rootId)); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const committed = yield* transactRemotely( + link, + createTransactionGate(), + function* (transaction) { + yield* transaction.journal.append(event("noted")); + return "done"; + }, + ); + expect(committed).toMatchObject({ ok: true }); + + const commit = lastCommit(transport.sent); + // A transaction that only appended proposes nothing. Inventing a + // Workspace change to make the shape uniform would publish a root nobody + // asked for, so `publication` is null and nothing was staged. + expect(commit["publication"]).toBe(null); + expect(commit["mappings"]).toEqual([]); + expect(transport.sent.some((request) => request["command"] === "stage")).toBe(false); + }); + void files; + }); + + it("encodes every kind of retained mapping the owner accepts", function* () { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + const transport = wire(ownerAnswers("")); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); + yield* scoped(function* () { + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist(attempt, [ + repositoryMapping(), + { + kind: "worktree", + record: { + repositoryName: "app", + name: "feature", + requestedBranch: "feature", + requestedBase: null, + creationCommit: "2".repeat(40), + checkoutPath: "/docs", + }, + }, + { + kind: "agent-session", + record: { + provider: "acp", + agentCommand: "/usr/bin/agent", + sessionIdentity: "session-1", + sessionKey: agentSessionKey({ + provider: "acp", + agentCommand: "/usr/bin/agent", + sessionIdentity: "session-1", + }), + policy: "strict", + assertion: { kind: "acp-session", value: "abc" }, + createdAt: "2026-09-03T00:00:00.000Z", + }, + }, + ]); + return "done"; + }); + }); + const mappings = memberList(lastCommit(transport.sent), "mappings"); + expect(mappings.map((mapping) => mapping["kind"])).toEqual([ + "repository", + "worktree", + "agent-session", + ]); + // Only a Repository carries the locator; the other two are the record. + expect(mappings.filter((mapping) => "locator" in mapping)).toHaveLength(1); + }); + + it("retries a lost answer with the same identity and the same bytes", function* () { + // A retry happens on a new connection: the one that lost the answer is + // gone, and a connection refuses to reuse a correlation id of its own. What + // has to be stable is the identity across those two connections, because + // that is what the owner recognizes the retry by. + const sent: Record[][] = []; + let intent: CommitIntent | undefined; + for (const attempt of [0, 1]) { + yield* scoped(function* () { + const { captured, reads } = yield* startingTree(); + const transport = wire(ownerAnswers(captured.root.rootId)); + sent.push(transport.sent); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + intent ??= { + expectedWorkspaceRootId: captured.root.rootId, + expectedJournalEventId: null, + events: [], + publication: null, + mappings: [], + bytes: new Map(), + answer: null, + }; + const committed = yield* link.commit(intent); + expect([attempt, committed.ok]).toEqual([attempt, true]); + }); + } + + const first = sent[0]?.find((request) => request["command"] === "commit"); + const second = sent[1]?.find((request) => request["command"] === "commit"); + expect(first?.["id"]).toBe(second?.["id"]); + // Byte-equivalent, so the owner sees the request it already decided. + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + }); + + it("asks a different question for a different proposal", function* () { + const { captured, reads } = yield* startingTree(); + const transport = wire(ownerAnswers(captured.root.rootId)); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const intent: CommitIntent = { + expectedWorkspaceRootId: captured.root.rootId, + expectedJournalEventId: null, + events: [], + publication: null, + mappings: [], + bytes: new Map(), + answer: null, + }; + yield* link.commit(intent); + yield* link.commit({ ...intent, events: [event("later")] }); + const commits = transport.sent.filter((request) => request["command"] === "commit"); + expect(commits).toHaveLength(2); + expect(commits[0]?.["id"]).not.toBe(commits[1]?.["id"]); + }); + + it("promotes nothing and keeps no tree when the owner refuses", function* () { + const files = runnerFiles(); + let attemptPath = ""; + yield* scoped(function* () { + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + const transport = wire(() => ({ outcome: "refused", refusal: "command:stale-root" })); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); + yield* scoped(function* () { + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + attemptPath = attempt.at("/"); + yield* until(writeFile(attempt.at("/NOTES.md"), "refused\n", { mode: 0o644 })); + const committed = yield* transactRemotely(link, createTransactionGate(), function* () { + return "done"; + }); + // A refusal is an answer, and the answer is no. + expect(committed.ok).toBe(false); + }); + expect(materialization.workspaceRootId).toBe(captured.root.rootId); + }); + let listed: unknown; + try { + listed = yield* until(readdir(attemptPath)); + } catch (error) { + listed = error; + } + expect(listed).toBeInstanceOf(Error); + }); + + it("promotes nothing when the answer is lost", function* () { + const files = runnerFiles(); + yield* scoped(function* () { + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + const transport = wire(ownerAnswers(captured.root.rootId)); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); + yield* scoped(function* () { + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + yield* until(writeFile(attempt.at("/NOTES.md"), "unanswered\n", { mode: 0o644 })); + // The connection goes while the answer is in flight. + transport.silence(); + const asking = yield* spawn(() => + transactRemotely(link, createTransactionGate(), function* () { + return "done"; + }), + ); + yield* sleep(0); + transport.end(); + const committed = yield* asking; + // Undecided, not failed — whether the owner committed cannot be known + // from here. Either way nothing is promoted locally. + expect(committed.ok).toBe(false); + }); + expect(materialization.workspaceRootId).toBe(captured.root.rootId); + }); + }); + + it("sends nothing when a resource the body started fails to tear down", function* () { + let sent: Record[] = []; + let raised: unknown; + try { + yield* scoped(function* () { + const { captured, reads } = yield* startingTree(); + const transport = wire(ownerAnswers(captured.root.rootId)); + sent = transport.sent; + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + yield* transactRemotely(link, createTransactionGate(), function* (transaction) { + yield* transaction.journal.append(event("appended")); + // A resource whose teardown fails. The body finished, but everything + // it started did not, so the transaction has not finished either — + // and the failure surfaces as the scope unwinds rather than inside it. + yield* ensure(() => { + throw new Error("teardown failed"); + }); + return "done"; + }); + }); + } catch (error) { + raised = error; + } + expect(raised).toBeInstanceOf(Error); + expect(sent.some((request) => request["command"] === "commit")).toBe(false); + }); + + it("sends nothing when the transaction exceeds a local bound", function* () { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + const transport = wire(ownerAnswers("")); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); + let raised: unknown; + yield* scoped(function* () { + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + try { + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + // More retained mappings than one intent may carry. + enlist( + attempt, + Array.from({ length: 300 }, () => repositoryMapping()), + ); + return "done"; + }); + } catch (error) { + raised = error; + } + }); + expect(raised).toBeInstanceOf(Error); + expect(transport.sent.some((request) => request["command"] === "commit")).toBe(false); + expect(materialization.workspaceRootId).toBe(captured.root.rootId); + }); + + it("refuses a performed answer that names a root this proposal did not select", function* () { + const { captured, reads } = yield* startingTree(); + // An owner agreeing to something else is not an owner this runner can go + // on talking to: believing it would promote a Workspace nobody proposed. + const transport = wire(() => ({ + outcome: "performed", + value: { workspaceRootId: "f".repeat(64), journalEventIds: [] }, + })); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const committed = yield* link.commit({ + expectedWorkspaceRootId: captured.root.rootId, + expectedJournalEventId: null, + events: [], + publication: null, + mappings: [], + bytes: new Map(), + answer: null, + }); + expect(committed.ok).toBe(false); + }); + + it("refuses a performed answer that loses an event it was given", function* () { + const { captured, reads } = yield* startingTree(); + const transport = wire((request) => ({ + outcome: "performed", + value: { workspaceRootId: request["expectedWorkspaceRootId"], journalEventIds: [] }, + })); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const committed = yield* link.commit({ + expectedWorkspaceRootId: captured.root.rootId, + expectedJournalEventId: null, + events: [event("appended")], + publication: null, + mappings: [], + bytes: new Map(), + answer: null, + }); + // One identity per event, or the two sides disagree about what history + // this commit created. + expect(committed.ok).toBe(false); + }); + + it("seals a nested mapping value against later mutation", function* () { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + const transport = wire(ownerAnswers("")); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); + + const assertion = { kind: "acp-session", value: "admitted" }; + const identity = { + provider: "acp", + agentCommand: "/usr/bin/agent", + sessionIdentity: "session-1", + }; + yield* scoped(function* () { + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist(attempt, [ + { + kind: "agent-session", + record: { + ...identity, + sessionKey: agentSessionKey(identity), + policy: "strict", + assertion, + createdAt: "2026-09-03T00:00:00.000Z", + }, + }, + ]); + // The caller still holds the nested assertion object and edits it. + assertion.value = "changed after admission"; + return "done"; + }); + }); + + const mapping = memberList(lastCommit(transport.sent), "mappings")[0] ?? {}; + expect(text(member(member(mapping, "record"), "assertion"), "value")).toBe("admitted"); + }); + + it("commits the tree as it finally is, not as it was when enlisted", function* () { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + const sizes = new Map(); + const transport = wire(ownerAnswers("", sizes)); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); + + let atEnlistment = ""; + yield* scoped(function* () { + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + yield* until(writeFile(attempt.at("/NOTES.md"), "first\n", { mode: 0o644 })); + const committed = yield* transactRemotely( + link, + createTransactionGate(), + function* (_transaction, enlist) { + enlist(attempt); + atEnlistment = (yield* attempt.capture()).root.rootId; + // The body goes on working after designating the attempt. Sealing + // happens after teardown, so this is what gets proposed. + yield* until(writeFile(attempt.at("/NOTES.md"), "second\n", { mode: 0o644 })); + const staged = yield* attempt.capture(); + for (const [digest, content] of staged.contents) { + sizes.set(digest, content.manifestBytes.length); + } + for (const [digest, blob] of staged.blobs) { + sizes.set(digest, blob.length); + } + return "done"; + }, + ); + expect(committed).toMatchObject({ ok: true }); + }); + + // The root the owner was asked to publish is the final one, not the one the + // tree held when the body enlisted it. + const proposed = member(lastCommit(transport.sent), "publication"); + expect(proposed["proposedWorkspaceRootId"]).not.toBe(atEnlistment); + // And the accepted tree recaptures to exactly the root that was committed. + expect(materialization.workspaceRootId).toBe(proposed["proposedWorkspaceRootId"]); + expect(yield* until(readFile(materialization.at("/NOTES.md"), "utf8"))).toBe("second\n"); + }); + + it("refuses a second Workspace publication in one transaction", function* () { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + const transport = wire(ownerAnswers("")); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); + let raised: unknown; + yield* scoped(function* () { + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + try { + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist(attempt); + enlist(attempt); + return "done"; + }); + } catch (error) { + raised = error; + } + }); + // Two Workspaces proposed for one commit is a choice nobody may make on the + // run's behalf, so the transaction fails and nothing is sent. + expect(raised).toBeInstanceOf(Error); + expect(transport.sent.some((request) => request["command"] === "commit")).toBe(false); + }); +}); diff --git a/packages/workflow/tests/remote-read.test.ts b/packages/workflow/tests/remote-read.test.ts new file mode 100644 index 000000000..bff7e4342 --- /dev/null +++ b/packages/workflow/tests/remote-read.test.ts @@ -0,0 +1,785 @@ +/** + * Tier WRH — reading a run from an owner somewhere else. + * + * What is under test here is the runner's half: whether a private answer + * becomes a semantic value only after it has been proved to be one, and whether + * a channel that has stopped making sense is stopped rather than followed. + * + * The owner is a deterministic fake, deliberately. Command-specific parsing, + * refusal narrowing and journal reassembly are arithmetic over what arrived, + * and a fake can produce the answers a correct owner never would — a page that + * skips an event, a refusal category from another release, content that is not + * what it is named. What the real owner does with a real request is proved on + * real workerd, where the runtime is the thing being relied on. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import { scoped } from "effection"; +import { + cloudflareOwnerLink, + cloudflareReadLink, + cloudflareRunLink, + stageCloudflareContent, +} from "../src/cloudflare/client.ts"; +import { + WorkflowRecordMalformedError, + WorkflowRequestError, + WorkflowSchemaVersionError, + WorkflowStorageError, +} from "../src/storage/errors.ts"; +import { useRemoteRunDatabase } from "../src/remote/database.ts"; +import { MAX_MESSAGE_BYTES } from "../src/remote/client.ts"; +import type { Result } from "effection"; +import type { DefinitionRetrieval } from "../src/storage/record.ts"; +import { SCHEMA_VERSION } from "../src/sqlite/workflow-schema.ts"; +import { createTransactionGate, transactRemotely } from "../src/remote/collector.ts"; +import { encodeBase64 } from "../src/cloudflare/encoding.ts"; +import type { OwnerSocket, SocketListener } from "../src/remote/client.ts"; +import { OwnerLinkError, useOwnerConnection } from "../src/remote/client.ts"; +import { RemoteRecordError } from "../src/remote/records.ts"; +import { + EMPTY_WORKSPACE_MANIFEST, + EMPTY_WORKSPACE_ROOT_ID, + workspaceRootId, +} from "../src/deno/workspace/manifest.ts"; +import { EXECUTION_PAGE_BYTES, executionPageBytes } from "../src/cloudflare/commands.ts"; +import { WORKSPACE_ROOT_DOMAIN } from "../src/workspace/root-manifest.ts"; +import { sha256Hex } from "../src/workspace/sha256.ts"; + +const RUN_ID = "remote-run"; +const ROOT_MANIFEST = JSON.stringify({ + format: 1, + entries: [{ path: "/", kind: "directory", mode: 493, mtime: 0 }], +}); +const ROOT_ID = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${ROOT_MANIFEST}`); +const CONTENT = new TextEncoder().encode( + JSON.stringify({ version: 1, chunks: [{ hash: "0".repeat(64), size: 1 }] }), +); +const CONTENT_ID = sha256Hex(CONTENT); + +function event(name: string): string { + return serializeDurableEvent({ + type: "yield", + coroutineId: "root", + description: { type: "test", name }, + result: { status: "ok", value: name }, + }); +} + +function runRecord(): Record { + return { + runId: RUN_ID, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + status: "running", + createdAt: "2026-09-03T00:00:00.000Z", + updatedAt: "2026-09-03T00:00:00.000Z", + }; +} + +function object(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("expected an object"); + } + return Object.fromEntries(Object.entries(value)); +} + +function wire(answer: (request: Record) => Record) { + const listeners = new Map>(); + let closes = 0; + const socket: OwnerSocket = { + send(data: string): void { + const request = object(JSON.parse(data)); + const response = answer(request); + for (const listener of listeners.get("message") ?? []) { + listener({ data: JSON.stringify({ id: request["id"], ...response }) }); + } + }, + close(): void { + closes += 1; + }, + addEventListener(type, listener): void { + const found = listeners.get(type) ?? new Set(); + found.add(listener); + listeners.set(type, found); + }, + removeEventListener(type, listener): void { + listeners.get(type)?.delete(listener); + }, + }; + return { + socket, + get closes(): number { + return closes; + }, + get listeners(): number { + return [...listeners.values()].reduce((sum, found) => sum + found.size, 0); + }, + }; +} + +/** The frontier a database handle opens from, as the owner would answer it. */ +function frontierValue(): Record { + return { + record: runRecord(), + retrieval: null, + workspaceRootId: ROOT_ID, + journalEventId: null, + }; +} + +function ids(): () => string { + let id = 0; + return () => `request-${(id += 1)}`; +} + +/** The name one retained test event carries, read rather than asserted. */ +function effectName(entry: unknown): string { + if (entry === null || typeof entry !== "object" || !("description" in entry)) { + return ""; + } + const description = entry.description; + if (description === null || typeof description !== "object" || !("name" in description)) { + return ""; + } + const name: unknown = description["name"]; + return typeof name === "string" ? name : ""; +} + +function failure(error: unknown): string { + if (!(error instanceof OwnerLinkError)) { + throw new Error(`expected an OwnerLinkError, received ${String(error)}`); + } + return error.refusal; +} + +/** + * The parser's own failure, having proved it is one. + * + * The request whose answer could not be read keeps that failure rather than + * the channel's, because only the boundary above it can say what the value was + * supposed to mean. Reading it as a string would let an unrelated error pass + * for the category a test expected. + */ +function unreadable(error: unknown): string { + if (!(error instanceof RemoteRecordError)) { + throw new Error(`expected a RemoteRecordError, received ${String(error)}`); + } + return "malformed-record"; +} + +describe("semantic reads from a Cloudflare owner", () => { + it("uses the standard SHA-256 identity rather than an adapter-local digest", function* () { + // The published answers, including the two-block case the padding rule is + // easiest to get wrong on. + expect(sha256Hex("")).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + expect(sha256Hex("abc")).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + expect(sha256Hex("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq")).toBe( + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + ); + expect(sha256Hex(new Uint8Array(1000).fill(0x61))).toBe( + "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3", + ); + }); + + it("computes the identity the local host computes for the same root", function* () { + // The two hosts retain the same roots and must name them identically. This + // one is arithmetic in the language; the Deno host uses `node:crypto`. A + // difference here would be two hosts disagreeing about history. + expect(sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${ROOT_MANIFEST}`)).toBe( + workspaceRootId(ROOT_MANIFEST), + ); + expect(sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${EMPTY_WORKSPACE_MANIFEST}`)).toBe( + EMPTY_WORKSPACE_ROOT_ID, + ); + }); + + it("strictly parses the private staging decision", function* () { + const bytes = new TextEncoder().encode("staged"); + const digest = sha256Hex(bytes); + const transport = wire((request) => ({ + outcome: "performed", + value: { kind: request["kind"], digest: request["digest"], size: bytes.length }, + })); + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + expect(yield* stageCloudflareContent(connection, "stage", "blob", bytes)).toEqual({ + kind: "blob", + digest, + size: bytes.length, + }); + }); + }); + + it("parses an anchored frontier, a canonical root, and verified content", function* () { + const transport = wire((request) => { + if (request["command"] === "frontier") { + return { + outcome: "performed", + value: { + record: runRecord(), + retrieval: { + metadata: { locator: "somewhere" }, + revision: 1, + updatedAt: "2026-09-03T00:00:00.000Z", + }, + workspaceRootId: ROOT_ID, + journalEventId: "event-2", + }, + }; + } + if (request["command"] === "journal" && request["afterEventId"] === null) { + return { + outcome: "performed", + value: { + anchorEventId: "event-2", + afterEventId: null, + entries: [ + { + eventId: "event-1", + previousEventId: null, + record: event("one"), + workspaceRootId: ROOT_ID, + }, + ], + done: false, + }, + }; + } + if (request["command"] === "journal") { + return { + outcome: "performed", + value: { + anchorEventId: "event-2", + afterEventId: "event-1", + entries: [ + { + eventId: "event-2", + previousEventId: "event-1", + record: event("two"), + workspaceRootId: ROOT_ID, + }, + ], + done: true, + }, + }; + } + if (request["command"] === "root") { + return { + outcome: "performed", + value: { workspaceRootId: ROOT_ID, manifest: ROOT_MANIFEST }, + }; + } + return { + outcome: "performed", + value: { + kind: "manifest", + digest: CONTENT_ID, + size: CONTENT.length, + bytes: encodeBase64(CONTENT), + }, + }; + }); + + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + const reads = cloudflareReadLink(connection, ids(), RUN_ID); + const frontier = yield* reads.frontier(); + expect(frontier.entries.map((entry) => entry.eventId)).toEqual(["event-1", "event-2"]); + expect(frontier.workspaceRootId).toBe(ROOT_ID); + expect((yield* reads.root(ROOT_ID)).entries).toHaveLength(1); + expect( + (yield* reads.content(ROOT_ID, { kind: "manifest", digest: CONTENT_ID })).bytes, + ).toEqual(CONTENT); + }); + expect(transport.closes).toBe(1); + expect(transport.listeners).toBe(0); + }); + + it("closes on a journal page that does not continue its snapshot", function* () { + const anchored = (entries: Record[], done = true) => ({ + anchorEventId: "event-2", + afterEventId: null, + entries, + done, + }); + const entry = (eventId: string, previousEventId: string | null) => ({ + eventId, + previousEventId, + record: event(eventId), + workspaceRootId: ROOT_ID, + }); + + // Four ways one page can fail to be the continuation it claims to be. The + // structural consequence is one: the events never reach a caller, because + // a journal that is missing an event looks exactly like a shorter journal. + const pages: Record> = { + skipped: anchored([entry("event-2", "event-1")]), + "out of order": anchored([entry("event-2", null), entry("event-1", "event-2")], false), + duplicated: anchored([entry("event-1", null), entry("event-1", "event-1")], false), + "not terminal": anchored([entry("event-1", null)]), + }; + + for (const [description, page] of Object.entries(pages)) { + const transport = wire((request) => + request["command"] === "frontier" + ? { + outcome: "performed", + value: { + record: runRecord(), + retrieval: null, + workspaceRootId: ROOT_ID, + journalEventId: "event-2", + }, + } + : { outcome: "performed", value: page }, + ); + let raised: unknown; + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + try { + yield* cloudflareReadLink(connection, ids(), RUN_ID).frontier(); + } catch (error) { + raised = error; + } + }); + expect([description, unreadable(raised)]).toEqual([description, "malformed-record"]); + expect([description, transport.closes]).toEqual([description, 1]); + expect([description, transport.listeners]).toEqual([description, 0]); + } + }); + + it("closes on an unknown same-release refusal", function* () { + const transport = wire(() => ({ outcome: "refused", refusal: "command:newer-release" })); + let raised: unknown; + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + try { + yield* cloudflareReadLink(connection, ids(), RUN_ID).frontier(); + } catch (error) { + raised = error; + } + }); + expect(unreadable(raised)).toBe("malformed-record"); + expect(transport.closes).toBe(1); + }); + + it("closes on a retrieval answer describing a replacement nobody asked for", function* () { + // The contradiction is settled where the answer arrives, not by a caller + // noticing afterwards. Two sides that disagree about which replacement was + // performed have no shared state left to continue from. + const transport = wire(() => ({ + outcome: "performed", + value: { + retrieval: { + metadata: { locator: "something else entirely" }, + revision: 1, + updatedAt: "2026-09-04T00:00:01.000Z", + }, + }, + })); + let outcome: unknown; + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareRunLink(connection, ids(), RUN_ID); + outcome = yield* link.replaceRetrieval(ROOT_ID, '{"locator":"what was asked"}'); + }); + expect((outcome as { ok: boolean }).ok).toBe(false); + expect(transport.closes).toBe(1); + }); + + it("returns a malformed record to the caller, and leaves nothing usable behind", function* () { + // The whole point of carrying the parser's failure: the public boundary + // says the owner returned a record this build cannot read, which is what + // happened, rather than that the owner could not be reached. + const mutations: Record[] = []; + const transport = wire((request) => { + if (request["command"] === "frontier") { + return { outcome: "performed", value: frontierValue() }; + } + mutations.push(request); + return { + outcome: "performed", + value: { + retrieval: { + metadata: { locator: "something else entirely" }, + revision: 1, + updatedAt: "2026-09-04T00:00:01.000Z", + }, + }, + }; + }); + let refused: Result | undefined; + let after: Result | undefined; + let held: DefinitionRetrieval | undefined; + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + // One generator for both halves: two would mint the same correlation id + // and the connection would fail closed on the duplicate. + const next = ids(); + const link = cloudflareRunLink(connection, next, RUN_ID); + const database = yield* useRemoteRunDatabase(link, yield* link.frontierSnapshot()); + refused = yield* database.replaceRetrievalMetadata({ locator: "what was asked" }); + held = database.retrieval; + expect(mutations).toHaveLength(1); + after = yield* database.replaceRetrievalMetadata({ locator: "later" }); + // The channel is gone, so the second call never reached the owner. + expect(mutations).toHaveLength(1); + }); + expect(refused?.ok).toBe(false); + expect(refused?.ok === false && refused.error).toEqual( + expect.any(WorkflowRecordMalformedError), + ); + // Nothing private crossed with it. + expect(String(refused?.ok === false && refused.error)).not.toContain("something else"); + // The snapshot is what the frontier established: an answer about another + // value installs nothing, because it decides where the definition is read. + expect(held).toEqual(undefined); + expect(after?.ok).toBe(false); + expect(after?.ok === false && after.error).toEqual(expect.any(WorkflowStorageError)); + expect(transport.closes).toBe(1); + }); + + it("returns a request failure when the whole request cannot be carried", function* () { + // Metadata that fits the bound on its own and does not once the command + // around it and its correlation id are counted. The public boundary has to + // say the request was too large, not that the owner was unreachable. + const mutations: Record[] = []; + const transport = wire((request) => { + if (request["command"] === "frontier") { + return { outcome: "performed", value: frontierValue() }; + } + mutations.push(request); + // An honest owner: it performed exactly the replacement it was asked for. + return { + outcome: "performed", + value: { + retrieval: { + metadata: JSON.parse(String(request["metadata"])), + revision: 1, + updatedAt: "2026-09-04T00:00:01.000Z", + }, + }, + }; + }); + let refused: Result | undefined; + let accepted: Result | undefined; + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + // One generator for both halves: two would mint the same correlation id + // and the connection would fail closed on the duplicate. + const next = ids(); + const link = cloudflareRunLink(connection, next, RUN_ID); + const database = yield* useRemoteRunDatabase(link, yield* link.frontierSnapshot()); + refused = yield* database.replaceRetrievalMetadata({ + locator: "m".repeat(MAX_MESSAGE_BYTES - 64), + }); + // Never sent, so the owner has no idea this was asked. + expect(mutations).toEqual([]); + // The connection was not spent on it either: the next one goes through. + accepted = yield* database.replaceRetrievalMetadata({ locator: "small" }); + expect(mutations).toHaveLength(1); + }); + expect(refused?.ok).toBe(false); + expect(refused?.ok === false && refused.error).toEqual(expect.any(WorkflowRequestError)); + expect(String(refused?.ok === false && refused.error)).not.toContain("too-large"); + expect(accepted?.ok).toBe(true); + expect(transport.closes).toBe(1); + }); + + it("refuses a version spelling outside what a version can be", function* () { + // Zero is a partial initialization and anything past the carrier is + // damaged retained data. Neither is a version this build is behind, so a + // same-release owner never sends one and this client never reads one. + for (const refusal of [ + "storage:unsupported-version-v0", + "storage:unsupported-version-v99999999999", + ]) { + const transport = wire(() => ({ outcome: "refused", refusal })); + let outcome: unknown; + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + const next = ids(); + const link = cloudflareRunLink(connection, next, RUN_ID); + outcome = yield* link.readExecutions(); + }); + const failed = outcome as { ok: boolean; error: Error }; + expect([refusal, failed.ok]).toEqual([refusal, false]); + expect([refusal, failed.error]).toEqual([refusal, expect.any(WorkflowStorageError)]); + // Not a version report, and nothing of the spelling crossed. + expect(failed.error).not.toEqual(expect.any(WorkflowSchemaVersionError)); + expect(String(failed.error)).not.toContain("storage:"); + expect([refusal, transport.closes]).toEqual([refusal, 1]); + } + }); + + it("reports the schema version the owner actually read", function* () { + // A version this build cannot open is the one fact the refusal exists to + // carry. Reporting a placeholder would state something the owner never + // said, and a host deciding whether to upgrade would act on it. + // Seven, and a value wider than the grammar this refusal once had: both + // are versions the owner can recognize, so both must arrive exactly. + for (const stored of [7, 1_000_000]) { + const transport = wire(() => ({ + outcome: "refused", + refusal: `storage:unsupported-version-v${stored}`, + })); + let outcome: unknown; + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + const next = ids(); + const link = cloudflareRunLink(connection, next, RUN_ID); + outcome = yield* link.readExecutions(); + }); + const failed = outcome as { ok: boolean; error: Error }; + expect([stored, failed.ok]).toEqual([stored, false]); + expect([stored, failed.error]).toEqual([stored, expect.any(WorkflowSchemaVersionError)]); + const version = failed.error as WorkflowSchemaVersionError; + expect([version.stored, version.supported]).toEqual([stored, SCHEMA_VERSION]); + } + }); + + it("closes when content bytes disagree with the requested identity", function* () { + const transport = wire(() => ({ + outcome: "performed", + value: { + kind: "blob", + digest: CONTENT_ID, + size: 1, + bytes: encodeBase64(new Uint8Array([1])), + }, + })); + let raised: unknown; + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + try { + yield* cloudflareReadLink(connection, ids(), RUN_ID).content(ROOT_ID, { + kind: "blob", + digest: CONTENT_ID, + manifestDigest: CONTENT_ID, + }); + } catch (error) { + raised = error; + } + }); + expect(unreadable(raised)).toBe("malformed-record"); + expect(transport.closes).toBe(1); + }); + it("hands the collector one assembled frontier and no page mechanics", function* () { + const pages: Record = { + null: { + anchorEventId: "event-2", + afterEventId: null, + entries: [ + { + eventId: "event-1", + previousEventId: null, + record: event("one"), + workspaceRootId: ROOT_ID, + }, + ], + done: false, + }, + "event-1": { + anchorEventId: "event-2", + afterEventId: "event-1", + entries: [ + { + eventId: "event-2", + previousEventId: "event-1", + record: event("two"), + workspaceRootId: ROOT_ID, + }, + ], + done: true, + }, + }; + const transport = wire((request) => + request["command"] === "frontier" + ? { + outcome: "performed", + value: { + record: runRecord(), + retrieval: null, + workspaceRootId: ROOT_ID, + journalEventId: "event-2", + }, + } + : { outcome: "performed", value: pages[String(request["afterEventId"])] }, + ); + + let seen: unknown[] = []; + let committed: unknown; + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + const request = ids(); + const link = cloudflareOwnerLink( + connection, + cloudflareReadLink(connection, request, RUN_ID), + request, + ); + // Two pages went over the wire. What the body reads back is one journal: + // the collector is handed the assembled prefix and never learns that a + // page, a cursor or an anchor was involved. + const outcome = yield* transactRemotely(link, createTransactionGate(), function* (tx) { + seen = yield* tx.journal.readAll(); + return "done"; + }); + committed = outcome; + }); + + expect(seen).toHaveLength(2); + expect(seen.map(effectName)).toEqual(["one", "two"]); + // D2 has reads and no commit. The transaction returns the owner's refusal + // rather than a success nothing performed. + expect(committed).toMatchObject({ ok: false }); + }); + it("assembles an execution snapshot only from pages that describe it", function* () { + const record = (id: string) => ({ executionId: id, startedAt: "2026-09-04T00:00:00.000Z" }); + const page = (rows: unknown[], overrides: Record = {}) => ({ + outcome: "performed", + value: { runId: RUN_ID, anchor: 2, after: null, rows, done: true, ...overrides }, + }); + const row = (sequence: number, id: string) => ({ sequence, record: record(id) }); + + // Each of these is a page that does not describe the snapshot it claims. + // The structural consequence is one: no partial history is returned. + const refused: Record = { + "another run's history": page([row(1, "a"), row(2, "b")], { runId: "somebody-else" }), + "a terminal page short of its anchor": page([row(1, "a")]), + "a first row that is not the first": page([row(2, "b")]), + "a gap between rows": page([row(1, "a"), row(3, "c")]), + "a repeated row": page([row(1, "a"), row(1, "a")]), + "a row beyond the anchor": page([row(1, "a"), row(2, "b"), row(3, "c")]), + "an empty page of a non-empty snapshot": page([], { done: false }), + "an empty snapshot that carries rows": page([row(1, "a")], { anchor: null }), + "a cursor it was not asked to continue from": page([row(1, "a"), row(2, "b")], { after: 7 }), + "a record with a member the shape does not declare": page([ + { sequence: 1, record: { ...record("a"), note: "extra" } }, + ]), + "a record that stopped without saying how": page([ + { sequence: 1, record: { ...record("a"), stopStatus: "completed" } }, + ]), + // Measured the same way the owner measures it, over the same wrappers. + // A page past the bound is refused whole: no prefix of it is returned. + "a page past the byte bound": page([ + row(1, "a"), + { sequence: 2, record: record("b".repeat(EXECUTION_PAGE_BYTES)) }, + ]), + }; + + for (const [description, answer] of Object.entries(refused)) { + const transport = wire(() => answer as Record); + let outcome: unknown; + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareRunLink(connection, ids(), RUN_ID); + outcome = yield* link.readExecutions(); + }); + expect([description, (outcome as { ok: boolean }).ok]).toEqual([description, false]); + if (!(outcome as { ok: boolean }).ok) { + const failed = outcome as { error: Error }; + // A provider-neutral failure, with nothing private in it. + expect([description, failed.error]).toEqual([ + description, + expect.any(WorkflowStorageError), + ]); + expect(String(failed.error)).not.toContain("command:"); + } + } + }); + + it("accepts a page filled to the byte bound", function* () { + // The boundary itself, from the runner's side: one page whose serialized + // rows land at or just under the bound is honest and is assembled. If the + // two ends measured different things, this is the page they would + // disagree about. + const fill = (size: number) => ({ + sequence: 1, + record: { executionId: "e".repeat(size), startedAt: "2026-09-04T00:00:00.000Z" }, + }); + // The identity is ASCII, so one byte of it is one byte of the page and the + // largest that fits follows from the wrapper's own size. + const overhead = executionPageBytes([fill(0)]); + const largest = fill(EXECUTION_PAGE_BYTES - overhead); + expect(executionPageBytes([largest])).toBe(EXECUTION_PAGE_BYTES); + expect(executionPageBytes([fill(EXECUTION_PAGE_BYTES - overhead + 1)])).toBeGreaterThan( + EXECUTION_PAGE_BYTES, + ); + + const transport = wire(() => ({ + outcome: "performed", + value: { runId: RUN_ID, anchor: 1, after: null, rows: [largest], done: true }, + })); + let outcome: unknown; + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + const next = ids(); + outcome = yield* cloudflareRunLink(connection, next, RUN_ID).readExecutions(); + }); + const found = outcome as { ok: boolean; value: { executionId: string }[] }; + expect(found.ok).toBe(true); + expect(found.value.map((held) => held.executionId)).toEqual([largest.record.executionId]); + }); + + it("assembles an honest snapshot across pages, and an empty one", function* () { + const record = (id: string) => ({ executionId: id, startedAt: "2026-09-04T00:00:00.000Z" }); + const pages: Record> = { + null: { + outcome: "performed", + value: { + runId: RUN_ID, + anchor: 2, + after: null, + rows: [{ sequence: 1, record: record("first") }], + done: false, + }, + }, + "1": { + outcome: "performed", + value: { + runId: RUN_ID, + anchor: 2, + after: 1, + rows: [{ sequence: 2, record: record("second") }], + done: true, + }, + }, + }; + const transport = wire( + (request) => + pages[String(request["after"])] ?? { outcome: "refused", refusal: "storage:corrupt" }, + ); + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareRunLink(connection, ids(), RUN_ID); + const read = yield* link.readExecutions(); + expect(read.ok).toBe(true); + if (read.ok) { + expect(read.value.map((entry) => entry.executionId)).toEqual(["first", "second"]); + } + }); + + const empty = wire(() => ({ + outcome: "performed", + value: { runId: RUN_ID, anchor: null, after: null, rows: [], done: true }, + })); + yield* scoped(function* () { + const connection = yield* useOwnerConnection(empty.socket); + const link = cloudflareRunLink(connection, ids(), RUN_ID); + const read = yield* link.readExecutions(); + expect(read.ok && read.value).toEqual([]); + }); + }); +}); diff --git a/packages/workflow/tests/remote-recovery.test.ts b/packages/workflow/tests/remote-recovery.test.ts new file mode 100644 index 000000000..bced3b824 --- /dev/null +++ b/packages/workflow/tests/remote-recovery.test.ts @@ -0,0 +1,109 @@ +/** + * Tier WRH — what a remote run reports about the executor that came before it. + * + * What recovery *decides* — that a retained root `Close` restores the outcome + * it recorded, and that its absence leaves `interrupted` — is the shared + * policy's, proved on a real owner in + * `tests/cloudflare/remote-lifecycle.vitest.ts`. This is what a caller learns: + * that the execution recovery closed is reported beside the one that was begun, + * and that a run which kept a terminal outcome says so rather than looking + * freshly running. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { type Operation, scoped } from "effection"; +import { WorkflowLifecycle } from "../src/lifecycle/api.ts"; +import type { ExecutorLock } from "../src/lifecycle/api.ts"; +import type { WorkflowExecutionTransitions } from "../src/lifecycle/execution.ts"; +import { useRemoteLifecycle } from "../src/remote/lifecycle.ts"; +import { installedHost, RUN_ID, type Script } from "./support/remote-lifecycle-host.ts"; + +function* installed( + script: Script, + body: (transitions: WorkflowExecutionTransitions) => Operation, +): Operation { + return yield* scoped(function* () { + const transitions = yield* useRemoteLifecycle(installedHost(script)); + return yield* body(transitions); + }); +} + +function* acquired(): Operation { + const taken = yield* WorkflowLifecycle.operations.acquireExecutor(RUN_ID); + if (!taken.ok) { + throw taken.error; + } + if (taken.value.kind !== "acquired") { + throw new Error("expected the executor lock to be acquired"); + } + return taken.value.lock; +} + +describe("what a remote run reports about its previous executor", () => { + it("reports the execution recovery closed, beside the one it began", function* () { + const outcome = yield* installed({ recovered: "execution-before" }, function* (transitions) { + const lock = yield* acquired(); + return yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + }); + + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.value.recovered?.executionId).toBe("execution-before"); + // The one this acquisition began is its own, and a different execution. + expect(outcome.value.execution.executionId).not.toBe("execution-before"); + } + }); + + it("says nothing about recovery when there was none", function* () { + const outcome = yield* installed({}, function* (transitions) { + const lock = yield* acquired(); + return yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + }); + + expect(outcome.ok).toBe(true); + // Absent rather than null: a caller reads "nothing was recovered" from the + // member not being there at all. + expect(outcome.ok && "recovered" in outcome.value).toBe(false); + }); + + it("carries a replay as a replay rather than as a fresh run", function* () { + const outcome = yield* installed({ replay: true }, function* (transitions) { + const lock = yield* acquired(); + return yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + }); + + expect(outcome.ok).toBe(true); + expect(outcome.ok && outcome.value.replay).toBe(true); + }); + + it("settles the execution it began after a recovery, and only that one", function* () { + const asked: string[] = []; + const outcome = yield* installed( + { asked, recovered: "execution-before" }, + function* (transitions) { + const lock = yield* acquired(); + const begun = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + if (!begun.ok) { + throw begun.error; + } + return { + // The recovered execution belonged to an acquisition that is gone. + stale: yield* transitions.settle(lock, { + executionId: "execution-before", + status: "completed", + }), + own: yield* transitions.settle(lock, { + executionId: begun.value.execution.executionId, + status: "completed", + }), + asked: [...asked], + }; + }, + ); + + expect(outcome.stale.ok).toBe(false); + expect(outcome.own.ok).toBe(true); + expect(outcome.asked.filter((command) => command === "settle")).toHaveLength(1); + }); +}); diff --git a/packages/workflow/tests/remote-runner.test.ts b/packages/workflow/tests/remote-runner.test.ts new file mode 100644 index 000000000..542af43a8 --- /dev/null +++ b/packages/workflow/tests/remote-runner.test.ts @@ -0,0 +1,649 @@ +/** + * Tier WRH14 — the runner's four methods, and the handoff between two of them. + * + * A begin transition hands back a storage handle. An attachment needs the + * Workspace runtime for the *same* run, over the same connection — and two + * clients on two owners can hold handles whose run id, root and anchor are + * identical, so nothing a handle says about itself can establish that. What + * establishes it is where the handle came from. + * + * So this file is about which handles attach and which do not. An attachment + * that succeeds here has opened the run from the exact link its own acquisition + * produced, taken the provenance of that handle's own journal, and installed + * the coordinator for it — every one of which has to line up, or the attachment + * raises instead. What a real owner does with the commit such an attachment + * produces is proved against one in `tests/cloudflare/remote-workspace.vitest.ts`. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { Ok, type Operation, scoped, sleep, spawn, suspend } from "effection"; +import type { Json } from "@executablemd/durable-streams"; +import { cloudflareReadLink, cloudflareRunLink } from "../src/cloudflare/client.ts"; +import { cloudflareLifecycleLink } from "../src/cloudflare/lifecycle-link.ts"; +import { WorkflowLifecycle } from "../src/lifecycle/api.ts"; +import type { ExecutorLock } from "../src/lifecycle/api.ts"; +import type { WorkflowExecutionTransitions } from "../src/lifecycle/execution.ts"; +import type { WorkflowRunDatabase } from "../src/storage/api.ts"; +import { useRemoteWorkflowRunner } from "../src/deno/remote-runner.ts"; +import type { RemoteRunnerOwner, RemoteWorkflowRunner } from "../src/deno/remote-runner.ts"; +import type { RemoteReadPlane } from "../src/remote/read.ts"; +import { WorkflowRequestError } from "../src/storage/errors.ts"; +import { installedHost, type Script } from "./support/remote-lifecycle-host.ts"; +import { + document, + published, + RUN_ID, + scriptedOwner, + type ScriptedRetention, + startingTree, + useHostSpy, +} from "./support/remote-owner-script.ts"; +import { useOwnerConnection } from "../src/remote/client.ts"; +import { transactAgentSessions, workspaceHostFor } from "../src/workspace/effects.ts"; +import type { CapturedWorkspace } from "../src/remote/materialize.ts"; +import { locatorFingerprintOf } from "../src/composition/locator.ts"; +import { agentSessionKey } from "../src/storage/agent-session.ts"; +import { durableRun, type Workflow } from "@executablemd/durable-streams"; + +/** One scripted owner, and every run its acquisitions were opened for. */ +function ownerOf(script: Script = {}): { owner: RemoteRunnerOwner; acquisitions: string[] } { + const acquisitions: string[] = []; + const host = installedHost({ ...script, opened: acquisitions }); + return { + acquisitions, + owner: { + runId: RUN_ID, + admit: (runId: string) => host.admit(runId), + // deno-lint-ignore require-yield + *reads(runId: string) { + return Ok(readPlane(runId)); + }, + delivery: { + // deno-lint-ignore require-yield + *wait(): Operation { + throw new Error("PLANTED-DELIVERY-WAIT-REACHED"); + }, + // deno-lint-ignore require-yield + *retain(): Operation { + throw new Error("PLANTED-DELIVERY-RETAIN-REACHED"); + }, + }, + }, + }; +} + +/** + * One read plane, which answers nothing and takes nothing. + * + * What the tests below need of it is that installing it and asking it a + * question require no acquisition; what it would answer is the read plane's own + * contract and is proved where that is under test. + */ +function unanswered(): never { + throw new WorkflowRequestError("this scripted plane answers no read"); +} + +function readPlane(runId: string): RemoteReadPlane { + return { + runId, + // deno-lint-ignore require-yield + *inspect() { + return unanswered(); + }, + // deno-lint-ignore require-yield + *history() { + return unanswered(); + }, + // deno-lint-ignore require-yield + *forkSource() { + return unanswered(); + }, + }; +} + +/** One runner over one scripted owner. */ +function assembled(owner: RemoteRunnerOwner, scratch: string): Operation { + return useRemoteWorkflowRunner({ owner, scratchRoot: `/tmp/xmd-remote-runner-${scratch}` }); +} + +/** Take this run's acquisition, or say why it could not be taken. */ +function* acquired(): Operation { + const taken = yield* WorkflowLifecycle.operations.acquireExecutor(RUN_ID); + if (!taken.ok) { + throw taken.error; + } + if (taken.value.kind !== "acquired") { + throw new Error("expected the executor acquisition to be taken"); + } + return taken.value.lock; +} + +/** Begin one execution, and hand back the handle it produced. */ +function* opened( + transitions: WorkflowExecutionTransitions, + lock: ExecutorLock, +): Operation { + const begun = yield* transitions.begin(lock, { runId: RUN_ID, action: "resume" }); + if (!begun.ok) { + throw begun.error; + } + return begun.value.database; +} + +/** What an attachment runs. Reaching it at all is the claim. */ +// deno-lint-ignore require-yield +function* attached(): Operation { + return "attached"; +} + +/** Attach one handle through one runner, and report what came back. */ +function* attaching(runner: RemoteWorkflowRunner, handle: WorkflowRunDatabase): Operation { + try { + return yield* scoped(() => runner.attach(handle, attached())); + } catch (error) { + return error instanceof Error ? error.message : "other"; + } +} + +function planted(): never { + throw new Error("PLANTED-FOREIGN-HANDLE-READ"); +} + +/** A handle nothing opened: shaped like one, and one nothing may read. */ +function foreignHandle(): WorkflowRunDatabase { + return { + get record() { + return planted(); + }, + get retrieval() { + return planted(); + }, + get journal() { + return planted(); + }, + readJournalEntries: planted, + transact: planted, + replaceRetrievalMetadata: planted, + readDocumentExecutions: planted, + }; +} + +/** One runner over a scripted owner reached through the production client. */ +function* wired( + captured: CapturedWorkspace, + retained: ScriptedRetention = {}, +): Operation<{ + owner: ReturnType; + runner: RemoteWorkflowRunner; +}> { + const owner = scriptedOwner(captured, retained); + const connection = yield* useOwnerConnection(owner.socket); + let identifier = 0; + const next = () => `command-${(identifier += 1)}`; + const reads = cloudflareReadLink(connection, next, RUN_ID); + const runner = yield* useRemoteWorkflowRunner({ + owner: { + runId: RUN_ID, + // deno-lint-ignore require-yield + *admit() { + return Ok({ + link: cloudflareRunLink(connection, next, RUN_ID), + lifecycle: cloudflareLifecycleLink(connection, reads, next), + // deno-lint-ignore require-yield + *close(): Operation {}, + }); + }, + // deno-lint-ignore require-yield + *reads(runId: string) { + return Ok(readPlane(runId)); + }, + delivery: { + // deno-lint-ignore require-yield + *wait(): Operation { + throw new Error("PLANTED-DELIVERY-WAIT-REACHED"); + }, + // deno-lint-ignore require-yield + *retain(): Operation { + throw new Error("PLANTED-DELIVERY-RETAIN-REACHED"); + }, + }, + }, + scratchRoot: "/tmp/xmd-remote-runner-live", + }); + return { owner, runner }; +} + +describe("a runner for a run whose storage is somewhere else", () => { + it("attaches the handle its own lifecycle opened, and no other", function* () { + const outcome = yield* scoped(function* () { + const first = ownerOf(); + const second = ownerOf(); + const one = yield* assembled(first.owner, "one"); + const transitions = yield* one.useRunHost(); + const database = yield* opened(transitions, yield* acquired()); + // A second runner over a second owner, with an acquisition and a handle + // of its own. Its scripted owner answers with the same record, root and + // anchor, so the two handles agree about everything except where they + // came from. + return yield* scoped(function* () { + const other = yield* assembled(second.owner, "two"); + const theirs = yield* other.useRunHost(); + const another = yield* opened(theirs, yield* acquired()); + return { + own: yield* attaching(one, database), + theirs: yield* attaching(other, another), + crossed: yield* attaching(other, database), + back: yield* attaching(one, another), + foreign: yield* attaching(one, foreignHandle()), + acquisitions: [...first.acquisitions, ...second.acquisitions], + }; + }); + }); + // Attaching succeeded, which means the run was opened from the exact link + // this runner's acquisition produced, the provenance of that handle's own + // journal was taken, and the coordinator was installed for it. + expect(outcome.own).toBe("attached"); + expect(outcome.theirs).toBe("attached"); + // Neither runner can attach the other's handle, in either direction. + expect(outcome.crossed).toContain("not opened by this remote host"); + expect(outcome.back).toContain("not opened by this remote host"); + expect(outcome.foreign).toContain("not opened by this remote host"); + // One acquisition per runner, and neither of them for the other's run. + expect(outcome.acquisitions).toEqual([RUN_ID, RUN_ID]); + }); + + it("makes an authored File write a remote Workspace effect", function* () { + const outcome = yield* scoped(function* () { + const captured = yield* startingTree(); + const before = captured.root.rootId; + const { owner, runner } = yield* wired(captured); + const transitions = yield* runner.useRunHost(); + const database = yield* opened(transitions, yield* acquired()); + + // The ambient host filesystem, installed the way a runtime entrypoint + // installs it and outside the attachment. A workflow document must never + // reach it. + const host = yield* useHostSpy(); + + const output = yield* runner.attach( + database, + document( + ["# Remote", "", 'written by the document'].join("\n"), + database, + ), + ); + return { output: String(output), host, owner, before }; + }); + + // The document ran to completion — `` renders nothing, so what it + // wrote is visible in what the owner was asked to commit, below — and the + // ambient host filesystem was never asked for anything at all. + expect(outcome.output.trimEnd()).toBe("# Remote"); + expect(outcome.host).toEqual([]); + // It materialized the exact retained root, then proposed one commit: the + // new root and the journal row describing the effect, together. + const asked = outcome.owner.sent.map((request) => request["command"]); + expect(asked).toContain("mappings"); + expect(asked).toContain("root"); + const proposals = published(outcome.owner.commits); + expect(proposals).toHaveLength(1); + const intent = proposals[0] ?? {}; + expect(intent["expectedWorkspaceRootId"]).toBe(outcome.before); + // One transaction carried both halves: the file the document wrote, and + // the journal row describing the effect that wrote it. + expect(Array.isArray(intent["events"]) && intent["events"]).toHaveLength(1); + expect(String(intent["events"])).toContain("workspace_file"); + expect(JSON.stringify(intent["publication"])).toContain("/NOTES.md"); + // And the owner's frontier moved to what it published, which is what a + // later read of this run observes. + expect(outcome.owner.currentRoot).not.toBe(outcome.before); + expect(intent["publication"]).toEqual( + expect.objectContaining({ proposedWorkspaceRootId: outcome.owner.currentRoot }), + ); + }); + + it("installs the whole live set, and resolves a document's paths inside the run", function* () { + const outcome = yield* scoped(function* () { + const captured = yield* startingTree(); + const { owner, runner } = yield* wired(captured); + const transitions = yield* runner.useRunHost(); + const database = yield* opened(transitions, yield* acquired()); + const host = yield* useHostSpy(); + // `` is the lexical half of the composition and `` is the + // document filesystem: a write inside a directory the document named + // proves both, and proves the path resolved inside the run's own + // Workspace rather than against the host working directory above. + const output = yield* runner.attach( + database, + document( + [ + "# Remote", + "", + '', + "", + ' nested', + "", + "", + ].join("\n"), + database, + ), + ); + return { output: String(output), host, owner }; + }); + + expect(outcome.host).toEqual([]); + const proposals = published(outcome.owner.commits); + expect(proposals).toHaveLength(1); + // The file landed under the directory the document named, inside the run. + expect(JSON.stringify(proposals[0]?.["publication"])).toContain("/docs/inner.md"); + }); + + it("keeps a refused, failed or cancelled attachment to one owner transaction", function* () { + const outcomes = yield* scoped(function* () { + /** One document write, under an owner scripted to answer this way. */ + function* attempt( + script: (owner: ReturnType) => void, + body?: (result: string) => Operation, + ): Operation<{ said: string; commits: number; root: string; before: string }> { + return yield* scoped(function* () { + const captured = yield* startingTree(); + const { owner, runner } = yield* wired(captured); + const transitions = yield* runner.useRunHost(); + const database = yield* opened(transitions, yield* acquired()); + script(owner); + let said: string; + try { + const rendered = yield* runner.attach( + database, + document( + ["# Remote", "", 'written by the document'].join("\n"), + database, + ), + ); + said = body === undefined ? String(rendered) : yield* body(String(rendered)); + } catch (error) { + said = error instanceof Error ? `raised:${error.name}` : "raised:other"; + } + return { + said, + commits: published(owner.commits).length, + attempts: owner.commits.filter((intent) => intent["publication"] !== null).length, + root: owner.currentRoot, + before: captured.root.rootId, + }; + }); + } + + return { + // The owner refuses the commit: nothing is promoted, and the run is + // still on the root it started from. + refused: yield* attempt((owner) => owner.refuse("command:stale-root")), + // The answer never arrives: whether the owner committed is exactly what + // cannot be known, and nothing here claims it did. + lost: yield* attempt((owner) => owner.lose()), + // The document fails after its own effect committed. The effect's + // transaction is the one visible outcome; nothing else is sent. + failed: yield* attempt( + () => undefined, + // deno-lint-ignore require-yield + function* (): Operation { + throw new Error("PlantedDocumentFailure"); + }, + ), + }; + }); + + // A refused commit leaves the frontier where it was, and the run learns it + // rather than being told the write succeeded. + expect(outcomes.refused.root).toBe(outcomes.refused.before); + expect(outcomes.refused.commits).toBe(1); + expect(outcomes.refused.said).toContain("raised:"); + // A lost answer is the same: one attempt, and no claim either way. + expect(outcomes.lost.root).toBe(outcomes.lost.before); + expect(outcomes.lost.commits).toBe(1); + expect(outcomes.lost.said).toContain("raised:"); + // A document that failed afterwards published its effect and nothing else. + expect(outcomes.failed.commits).toBe(1); + expect(outcomes.failed.root).not.toBe(outcomes.failed.before); + expect(outcomes.failed.said).toBe("raised:Error"); + }); + + it("reads the retained Workspace an ephemeral attachment needs, and keeps nothing", function* () { + /** A repository this owner already retains, at a path the root contains. */ + const stored = { + record: { + name: "app", + locatorFingerprint: locatorFingerprintOf("https://git.example.invalid/octo/app.git"), + requestedBase: null, + creationCommit: "9".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", + checkoutPath: "/docs", + }, + locator: "https://git.example.invalid/octo/app.git", + }; + + const outcome = yield* scoped(function* () { + const captured = yield* startingTree(); + const { owner, runner } = yield* wired(captured, { repositories: [stored] }); + const transitions = yield* runner.useRunHost(); + const database = yield* opened(transitions, yield* acquired()); + return yield* runner.attach( + database, + (function* (): Operation> { + // Exactly what a Repository reattachment asks of the run: the record + // that names the checkout, and the bytes at it. No Deno lease, no + // Deno private workspace, and no transaction held while it reads. + const read = yield* workspaceHostFor(database).read(function* (view) { + const record = view.metadata.readRepository("app"); + const entries = yield* view.filesystem.readdir("/"); + const readme = yield* view.filesystem.readTextFile("/README.md"); + return { + named: record?.record.checkoutPath, + locator: record?.locator, + entries: entries.map((entry) => entry.name).toSorted(), + readme, + }; + }); + if (!read.ok) { + throw read.error; + } + return { ...read.value, commits: published(owner.commits).length }; + })(), + ); + }); + + // The retained record and the retained bytes, from the owner's own + // snapshot and the root it named. + expect(outcome["named"]).toBe("/docs"); + expect(outcome["locator"]).toBe("https://git.example.invalid/octo/app.git"); + expect(outcome["entries"]).toEqual(["README.md", "docs"]); + expect(outcome["readme"]).toBe("starting\n"); + // Nothing durable happened: no proposal, no publication, no mapping. + expect(outcome["commits"]).toBe(0); + }); + + it("retains an Agent-session mapping at the owner, in one transaction", function* () { + // The key is derived from the identity rather than chosen: a record whose + // key does not follow from what it names is one no owner retains. + const identity = { + provider: "acpx", + agentCommand: "/usr/bin/claude", + sessionIdentity: "expansion-1", + }; + const session = { + ...identity, + sessionKey: agentSessionKey(identity), + policy: "policy-1", + assertion: { kind: "acp", value: "conversation-1" }, + createdAt: "2026-09-10T00:00:00.000Z", + }; + + const outcomes = yield* scoped(function* () { + /** One Agent-session body, under an owner that already retains this. */ + function* attempt( + retained: ScriptedRetention, + body: (sessions: { + read(key: string): unknown; + commit(record: typeof session): void; + }) => Operation, + ): Operation<{ said: string; mappings: number; publications: number }> { + return yield* scoped(function* () { + const captured = yield* startingTree(); + const { owner, runner } = yield* wired(captured, retained); + const transitions = yield* runner.useRunHost(); + const database = yield* opened(transitions, yield* acquired()); + let said: string; + try { + said = yield* runner.attach( + database, + (function* (): Operation { + const committed = yield* transactAgentSessions(database, body); + return committed.ok ? committed.value : `refused:${committed.error.name}`; + })(), + ); + } catch (error) { + said = error instanceof Error ? `raised:${error.name}` : "raised:other"; + } + const mapped = owner.commits.filter((intent) => { + const mappings = intent["mappings"]; + return Array.isArray(mappings) && mappings.length > 0; + }); + return { + said, + mappings: mapped.length, + publications: published(owner.commits).length, + }; + }); + } + + return { + // Nothing retained yet: the mapping is staged and the owner commits it. + retained: yield* attempt({}, function* (sessions) { + sessions.commit(session); + return "committed"; + }), + // The same mapping already retained: reading it is enough, and there is + // nothing for the owner to decide. + already: yield* attempt({ agentSessions: [session] }, function* (sessions) { + return sessions.read(session.sessionKey) === undefined ? "absent" : "read"; + }), + // A different conversation under the same identity: refused where the + // rules live, and never sent. + conflicting: yield* attempt( + { agentSessions: [session] }, + // deno-lint-ignore require-yield + function* (sessions) { + sessions.commit({ ...session, assertion: { kind: "acp", value: "another" } }); + return "committed"; + }, + ), + // A body that failed after staging sends no mapping at all. + failed: yield* attempt( + {}, + // deno-lint-ignore require-yield + function* (sessions) { + sessions.commit(session); + throw new Error("PlantedAgentFailure"); + }, + ), + }; + }); + + // One mapping-only transaction, and no Workspace proposal with it. + expect(outcomes.retained.said).toBe("committed"); + expect(outcomes.retained.mappings).toBe(1); + expect(outcomes.retained.publications).toBe(0); + // Reading what the owner admitted stages nothing. + expect(outcomes.already.said).toBe("read"); + expect(outcomes.already.mappings).toBe(0); + // A conflicting assertion never replaces the retained one. + expect(outcomes.conflicting.said).toContain("refused:"); + expect(outcomes.conflicting.mappings).toBe(0); + // And a failure before the commit retains nothing. + expect(outcomes.failed.said).toContain("refused:"); + expect(outcomes.failed.mappings).toBe(0); + }); + + it("cancels an in-flight attachment without proposing anything", function* () { + const outcome = yield* scoped(function* () { + const captured = yield* startingTree(); + const { owner, runner } = yield* wired(captured); + const transitions = yield* runner.useRunHost(); + const database = yield* opened(transitions, yield* acquired()); + const reached = { inside: false }; + // The attachment is halted while the document is inside its own effect, + // which is where a real cancellation arrives: between the mutation and + // the commit the collector would have sent. + const running = yield* spawn(() => + runner.attach( + database, + (function* (): Operation { + const binding = workspaceHostFor(database); + yield* (function* (): Operation { + const effect = binding.create( + { type: "workspace", name: "cancelled" }, + function* (filesystem): Operation { + yield* filesystem.writeFile("/NOTES.md", "never committed\n", 0o644); + reached.inside = true; + // Nothing settles this: the halt below is what ends it. + yield* suspend(); + return "unreachable"; + }, + ); + function* workflow(): Workflow { + yield effect; + } + return yield* durableRun(workflow, { stream: database.journal }); + })(); + return "unreachable"; + })(), + ), + ); + // Let the effect get inside its mutation, then halt the attachment. + while (!reached.inside) { + yield* sleep(1); + } + yield* running.halt(); + return { owner, inside: reached.inside }; + }); + + // The document got as far as writing into its attempt, and the owner was + // never asked to commit any of it. + expect(outcome.inside).toBe(true); + expect(published(outcome.owner.commits)).toEqual([]); + // The attachment's own scope is over, so the temporary trees it + // materialized into are gone with it. + expect(outcome.owner.commits.every((intent) => intent["publication"] === null)).toBe(true); + }); + + it("reads and delivers without taking an acquisition", function* () { + const outcome = yield* scoped(function* () { + const scripted = ownerOf(); + const built = yield* assembled(scripted.owner, "planes"); + yield* built.useLifecycle(); + yield* built.useDelivery(); + const inspected = yield* trapped(WorkflowLifecycle.operations.inspect(RUN_ID)); + return { + inspected, + // Nothing was acquired to install either plane or to answer with them. + acquisitions: scripted.acquisitions, + }; + }); + // The scripted plane answers no read, which is the plane refusing rather + // than an acquisition that was never taken. + expect(outcome.inspected).toContain("answers no read"); + expect(outcome.acquisitions).toEqual([]); + }); +}); + +/** Run one operation and report what it refused with, if it refused. */ +function* trapped(operation: Operation): Operation { + try { + yield* operation; + return "answered"; + } catch (error) { + return error instanceof Error ? error.message : "other"; + } +} diff --git a/packages/workflow/tests/remote-staged-fork.test.ts b/packages/workflow/tests/remote-staged-fork.test.ts new file mode 100644 index 000000000..10e1d8860 --- /dev/null +++ b/packages/workflow/tests/remote-staged-fork.test.ts @@ -0,0 +1,285 @@ +/** + * Tier WRH — the candidate a remote fork is admitted by, assembled locally. + * + * A staged fork is not a run: nothing acquires anything, no owner is contacted, + * and no host discovers it. What it is, is the fork's own database, built from + * the snapshot the accepted no-acquisition plane returned and thrown away with + * the scope that asked for it. These are the observations that distinguish a + * real candidate from an interface: its identity, its inherited history in + * order with the records byte for byte, its Workspace root, its checkouts and + * its lineage, read back out of the database it handed over. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { Err, Ok, type Operation, type Result, scoped } from "effection"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import { exists } from "@effectionx/fs"; +import { useStorageRoot } from "./support/storage.ts"; +import { useWorkflowRunConnections } from "../src/deno/connections.ts"; +import { stageRemoteFork } from "../src/deno/remote-staging.ts"; +import { workflowForkStaging } from "../src/deno/path.ts"; +import { installRemoteWorkflowLifecycle } from "../src/deno/remote-host.ts"; +import { WorkflowRequestError } from "../src/storage/errors.ts"; +import type { WorkflowForkRequest } from "../src/lifecycle/execution.ts"; +import type { RemoteForkSource } from "../src/remote/read.ts"; +import { forkRunRecordEvent } from "../src/journal-events.ts"; +import { sha256Hex } from "../src/workspace/sha256.ts"; +import { WORKSPACE_ROOT_DOMAIN } from "../src/workspace/root-manifest.ts"; +import type { WorkflowRunDatabase } from "../src/storage/api.ts"; + +const SOURCE_RUN_ID = "6dktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; +const DESTINATION = "7ektgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; + +const FILE = new TextEncoder().encode("inherited by the fork"); +const BLOB = sha256Hex(FILE); +const CONTENT = new TextEncoder().encode( + JSON.stringify({ version: 1, chunks: [{ hash: BLOB, size: FILE.length }] }), +); +const MANIFEST = sha256Hex(CONTENT); +const ROOT_MANIFEST = JSON.stringify({ + format: 1, + entries: [ + { path: "/", kind: "directory", mode: 493, mtime: 0 }, + { + path: "/NOTES.md", + kind: "file", + mode: 420, + mtime: 0, + size: FILE.length, + manifest: MANIFEST, + hardlink: null, + }, + ], +}); +const ROOT = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${ROOT_MANIFEST}`); + +function event(name: string): string { + return serializeDurableEvent({ + type: "yield", + coroutineId: "root", + description: { type: "test", name }, + result: { status: "ok", value: name }, + }); +} + +/** One small committed source, as the accepted read plane returns it. */ +function source(): RemoteForkSource { + return { + sourceRunId: SOURCE_RUN_ID, + anchor: "a".repeat(64), + checkpointEventId: "event-second", + checkpointWorkspaceRootId: ROOT, + runRecordWorkspaceRootId: ROOT, + rootImportWorkspaceRootId: ROOT, + inherited: [ + { eventId: "event-first", record: event("first"), workspaceRootId: ROOT }, + { eventId: "event-second", record: event("second"), workspaceRootId: ROOT }, + ], + roots: [ + { + rootId: ROOT, + formatVersion: 1, + manifest: ROOT_MANIFEST, + manifestHashes: [MANIFEST], + blobHashes: [BLOB], + }, + ], + manifests: [{ hash: MANIFEST, size: FILE.length, lastSeen: 4, encoded: CONTENT }], + blobs: [{ hash: BLOB, size: FILE.length, lastSeen: 6, content: FILE }], + checkouts: [ + { + kind: "repository", + name: "alpha", + locator: "https://git.example.invalid/alpha.git", + locatorFingerprint: "b".repeat(64), + requestedBase: null, + creationCommit: "9".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", + checkoutPath: "/", + }, + ], + }; +} + +function request(): WorkflowForkRequest { + return { + runId: DESTINATION, + selection: { sourceRunId: SOURCE_RUN_ID, checkpointEventId: "event-second" }, + creation: { + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + retrieval: { kind: "git", remote: "origin" }, + }, + rootImport: { + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { status: "ok", value: { kind: "repository", path: "README.md", content: "# fork" } }, + }, + }; +} + +/** Stage one candidate and observe it, inside a scope that then ends. */ +function* staged( + root: string, + body: (database: WorkflowRunDatabase) => Operation, +): Operation { + return yield* scoped(function* () { + const connections = yield* useWorkflowRunConnections(); + const built = yield* stageRemoteFork(connections, root, request(), source(), { + runRecord: forkRunRecordEvent({ + runId: DESTINATION, + base: "main", + pinnedCommit: "0".repeat(40), + }), + rootImport: request().rootImport, + }); + if (!built.ok) { + throw built.error; + } + return yield* body(built.value); + }); +} + +describe("a remote fork's staged candidate", () => { + it("is a real database holding the fork's own identity and history", function* () { + const root = yield* useStorageRoot(); + const seen = yield* staged(root, function* (database) { + const history = yield* database.readJournalEntries(); + if (!history.ok) { + throw history.error; + } + return { + record: database.record, + events: history.value.map((entry) => entry.eventId), + events2: history.value.map((entry) => serializeDurableEvent(entry.event)), + }; + }); + + // Its own identity, not the source's. + expect(seen.record.runId).toBe(DESTINATION); + expect(seen.record.base).toBe("main"); + // Its own two head records, then the prefix it inherited, in order. + expect(seen.events).toHaveLength(4); + expect(seen.events.slice(2)).toEqual(["event-first", "event-second"]); + // The events the source retained, in the source's order. + expect(seen.events2.slice(2)).toEqual([event("first"), event("second")]); + }); + + it("restores the checkpoint's Workspace as the fork's own", function* () { + const root = yield* useStorageRoot(); + const seen = yield* staged(root, function* (database) { + const history = yield* database.readJournalEntries(); + if (!history.ok) { + throw history.error; + } + // Every retained row names the Workspace root it was written against; + // the fork's own rows name the checkpoint's. + return { + workspaceRootId: history.value.at(-1)?.workspaceRootId ?? "", + }; + }); + + expect(seen.workspaceRootId).toBe(ROOT); + }); + + it("keeps the retrieval its creation carried", function* () { + const root = yield* useStorageRoot(); + const held = yield* staged(root, function* (database) { + return database.retrieval; + }); + + expect(held?.metadata).toEqual({ kind: "git", remote: "origin" }); + }); + + it("takes no lock, and nothing discovers it", function* () { + const root = yield* useStorageRoot(); + const path = workflowForkStaging(root, DESTINATION); + const during = yield* staged(root, function* () { + return yield* exists(path); + }); + + // It was there while its scope was open, and the run's own path never was: + // staging assembles a candidate, not a run. + expect(during).toBe(true); + expect(yield* exists(path)).toBe(false); + }); + + it("replaces what an interrupted attempt left rather than continuing it", function* () { + const root = yield* useStorageRoot(); + const first = yield* staged(root, function* (database) { + const history = yield* database.readJournalEntries(); + return history.ok ? history.value.length : -1; + }); + // A second candidate at the same path is built from scratch, so it holds + // exactly what one assembly holds rather than two. + const second = yield* staged(root, function* (database) { + const history = yield* database.readJournalEntries(); + return history.ok ? history.value.length : -1; + }); + + expect(first).toBe(4); + expect(second).toBe(4); + }); + + it("is what the provider's own stageFork() returns, through the real host", function* () { + const root = yield* useStorageRoot(); + const opened: string[] = []; + const seen = yield* scoped(function* () { + const transitions = yield* installRemoteWorkflowLifecycle({ + root, + // Reaching an owner is the one thing a staged fork must never do, so + // this records any attempt and refuses. + *admit(runId: string) { + opened.push(runId); + return Err(new WorkflowRequestError("a staged fork admits nothing")); + }, + // deno-lint-ignore require-yield + *source(runId: string) { + return Ok({ + runId, + // deno-lint-ignore require-yield + *inspect(): Operation> { + throw new WorkflowRequestError("a staged fork inspects nothing"); + }, + // deno-lint-ignore require-yield + *history(): Operation> { + throw new WorkflowRequestError("a staged fork reads no history"); + }, + // deno-lint-ignore require-yield + *forkSource(): Operation> { + return Ok(source()); + }, + }); + }, + }); + const built = yield* transitions.stageFork(request()); + if (!built.ok) { + throw built.error; + } + const history = yield* built.value.readJournalEntries(); + return { + runId: built.value.record.runId, + events: history.ok ? history.value.map((entry) => entry.eventId) : [], + retrieval: built.value.retrieval?.metadata, + }; + }); + + // A real candidate came back from the production seam, with the same + // assembly the kernel produces. + expect(seen.runId).toBe(DESTINATION); + expect(seen.events.slice(2)).toEqual(["event-first", "event-second"]); + expect(seen.retrieval).toEqual({ kind: "git", remote: "origin" }); + // And nothing was acquired on the way. + expect(opened).toEqual([]); + }); +}); diff --git a/packages/workflow/tests/remote-storage.test.ts b/packages/workflow/tests/remote-storage.test.ts new file mode 100644 index 000000000..04972abb3 --- /dev/null +++ b/packages/workflow/tests/remote-storage.test.ts @@ -0,0 +1,220 @@ +/** + * Tier WRH — what the remote storage provider answers when the owner does not. + * + * The owner's own behavior is proved against a real Durable Object in + * `tests/cloudflare/remote-storage.vitest.ts`. These are the cases a real owner + * cannot produce: an answer this build cannot read, and a connection that ends + * before it answers. Both go through the production + * `cloudflareRunLink().open()` and the installed `WorkflowRunStorage`, because + * what is claimed is that neither escapes the provider's `Result`. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { type Operation, type Result, scoped } from "effection"; +import { cloudflareRunLink } from "../src/cloudflare/client.ts"; +import { type OwnerSocket, type SocketListener, useOwnerConnection } from "../src/remote/client.ts"; +import { useRemoteRunStorage } from "../src/remote/storage.ts"; +import type { CreateWorkflowRunRequest, WorkflowRunDatabase } from "../src/storage/api.ts"; +import { WorkflowRunStorage } from "../src/storage/api.ts"; +import { WorkflowRecordMalformedError, WorkflowStorageError } from "../src/storage/errors.ts"; + +const RUN_ID = "5cktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; + +function creation(): CreateWorkflowRunRequest { + return { + runId: RUN_ID, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + }; +} + +function runRecord(): Record { + return { + runId: RUN_ID, + definition: creation().definition, + base: "main", + props: {}, + status: "running", + createdAt: "2026-09-05T00:00:00.000Z", + updatedAt: "2026-09-05T00:00:00.000Z", + }; +} + +/** + * One request, read rather than believed. + * + * `JSON.parse` answers `unknown`, and this owner has to reflect a correlation + * id back off whatever it was sent. Checking that it is an object before + * reading one is what makes the reflection honest instead of a promise about + * what the client sends. + */ +function decoded(data: string): Record { + const value: unknown = JSON.parse(data); + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("expected the client to send one JSON object"); + } + return Object.fromEntries(Object.entries(value)); +} + +/** An owner whose answers a test writes, and which can simply stop answering. */ +function wire(answer: (request: Record) => Record | "lost") { + const sent: Record[] = []; + const listeners = new Map>(); + const socket: OwnerSocket = { + send(data: string): void { + const request = decoded(data); + sent.push(request); + const response = answer(request); + if (response === "lost") { + for (const listener of listeners.get("close") ?? []) { + listener({}); + } + return; + } + for (const listener of listeners.get("message") ?? []) { + listener({ data: JSON.stringify({ id: request["id"], ...response }) }); + } + }, + close(): void {}, + addEventListener(type, listener): void { + const found = listeners.get(type) ?? new Set(); + found.add(listener); + listeners.set(type, found); + }, + removeEventListener(type, listener): void { + listeners.get(type)?.delete(listener); + }, + }; + return { socket, sent }; +} + +/** The installed provider, over one scripted owner. */ +function* installed( + answer: (request: Record) => Record | "lost", + body: () => Operation, +): Operation { + return yield* scoped(function* () { + const transport = wire(answer); + const connection = yield* useOwnerConnection(transport.socket); + let identifier = 0; + yield* useRemoteRunStorage( + cloudflareRunLink(connection, () => `open-${(identifier += 1)}`, RUN_ID), + ); + return yield* body(); + }); +} + +function refused(result: Result): Error { + if (result.ok) { + throw new Error("expected a refused result"); + } + return result.error; +} + +describe("remote storage, when the owner answers badly", () => { + it("returns a malformed record for every open answer it cannot read", function* () { + // Each of these is a shape the owner could never build. What matters is + // that reading one is a refusal rather than a value, and that the refusal + // says the record was unreadable rather than repeating what was in it. + const answers: Record> = { + "an answer that both opened and refused": { + conflict: ["base"], + frontier: { + record: runRecord(), + retrieval: null, + workspaceRootId: "a".repeat(64), + journalEventId: null, + }, + }, + "a differing field this build does not read": { conflict: ["everything"], frontier: null }, + "no differing field at all": { conflict: [], frontier: null }, + "differing fields out of order": { conflict: ["props", "base"], frontier: null }, + "one differing field twice": { conflict: ["base", "base"], frontier: null }, + "a differing field that is not text": { conflict: [7], frontier: null }, + "a frontier naming another run": { + conflict: null, + frontier: { + record: { ...runRecord(), runId: "6dktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa" }, + retrieval: null, + workspaceRootId: "a".repeat(64), + journalEventId: null, + }, + }, + "an answer carrying neither": { conflict: null, frontier: null }, + }; + + for (const [description, value] of Object.entries(answers)) { + const outcome = yield* installed( + () => ({ outcome: "performed", value }), + () => WorkflowRunStorage.operations.create(creation()), + ); + const error = refused(outcome); + expect([description, error]).toEqual([description, expect.any(WorkflowRecordMalformedError)]); + // Nothing of the answer, and nothing of the protocol. + expect([description, String(error)]).not.toContain("everything"); + expect(String(error)).not.toContain("command:"); + expect(String(error)).not.toContain("6dktgrv"); + } + }); + + it("returns a provider failure when the connection ends before its answer", function* () { + const outcome = yield* installed( + () => "lost", + () => WorkflowRunStorage.operations.create(creation()), + ); + const error = refused(outcome); + // Inside the `Result` this interface promises, provider-neutral, and + // carrying no transport vocabulary. + expect(error).toEqual(expect.any(WorkflowStorageError)); + expect(String(error)).not.toContain("OwnerLinkError"); + expect(String(error)).not.toContain("command:"); + + const looked = yield* installed( + () => "lost", + () => WorkflowRunStorage.operations.lookup(RUN_ID), + ); + expect(refused(looked)).toEqual(expect.any(WorkflowStorageError)); + }); + + it("sends the request it parsed, not the object it was handed", function* () { + // A request that answers one identity while it is validated and another + // when it is read again. Only the parsed value may reach the owner. + let reads = 0; + const unstable = { + get runId(): string { + return RUN_ID; + }, + definition: creation().definition, + get base(): string { + reads += 1; + return reads > 1 ? "a-later-base" : "main"; + }, + props: {}, + }; + + let observed: unknown; + yield* installed( + (request) => { + if (request["command"] === "open") { + observed = request["creation"]; + } + return { outcome: "performed", value: { conflict: ["base"], frontier: null } }; + }, + () => WorkflowRunStorage.operations.create(unstable), + ); + + const sent = observed === null || typeof observed !== "object" ? {} : { ...observed }; + // The getter ran while the request was parsed, and what travelled is that + // reading. A later reading never reached the owner. + expect(reads).toBe(1); + expect(Reflect.get(sent, "base")).toBe("main"); + }); +}); diff --git a/packages/workflow/tests/remote-transaction.test.ts b/packages/workflow/tests/remote-transaction.test.ts new file mode 100644 index 000000000..1d19b4dd3 --- /dev/null +++ b/packages/workflow/tests/remote-transaction.test.ts @@ -0,0 +1,455 @@ +/** + * Tier WRH — `transact()` against an owner somewhere else. + * + * The contract is that arbitrary callback control flow stays legal while the + * commit stays atomic, and the way that is achieved is by never inferring what + * the body did: the body runs locally, and only what it enlisted is sent. So + * these tests are mostly about what does *not* travel — a body that suspends + * leaves no transaction open, a body that fails sends nothing, and a result the + * owner refused is not returned as a success. + * + * The link is a deterministic fake because Cloudflare mechanics are not the + * subject here. What the owner does with an intent is proven on real workerd; + * what the client sends is proven here. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { Err, Ok, sleep, spawn, withResolvers, type Operation, type Result } from "effection"; +import type { DurableEvent, DurableStream } from "@executablemd/durable-streams"; +import { + type CommitIntent, + createTransactionGate, + type OwnerLink, + RemoteTransactionError, + requireNoOpenTransaction, + type StartingFrontier, + transactRemotely, +} from "../src/remote/collector.ts"; +import type { CommitDecision } from "../src/remote/publication.ts"; + +/** + * What the transaction refused with, having proved it refused at all. + * + * A caught value is `unknown`; asserting it would let an unrelated failure read + * as the refusal a test expected. + */ +function refusalOf(error: unknown): string { + if (!(error instanceof RemoteTransactionError)) { + throw new Error(`expected a RemoteTransactionError, got ${String(error)}`); + } + return error.refusal; +} + +/** The name a test event carries, read rather than asserted. */ +function nameOf(entry: DurableEvent | undefined): string { + if (entry === undefined || !("description" in entry)) { + return ""; + } + const description = entry.description; + if (description === null || typeof description !== "object") { + return ""; + } + if (!("name" in description)) { + return ""; + } + const name: unknown = description["name"]; + return typeof name === "string" ? name : ""; +} + +/** + * The journal as an untrusted caller reaches it. + * + * The collector's job is to parse what it is handed, so a test that proves it + * refuses junk must be able to hand it junk. Widening the parameter is how that + * happens without manufacturing a value that claims to already be an event — + * asserting one would assert away the thing under test. + */ +function offering(journal: DurableStream): { append(event: unknown): Operation } { + return journal; +} + +/** Rename a test event in place, to prove the collector cloned it. */ +function rename(entry: DurableEvent, name: string): void { + if (!("description" in entry)) { + return; + } + const description = entry.description; + if (description !== null && typeof description === "object") { + Object.assign(description, { name }); + } +} + +function event(name: string): DurableEvent { + return { + type: "yield", + coroutineId: "root", + description: { type: "test", name }, + result: { status: "ok", value: name }, + }; +} + +/** What a correct owner would answer for this intent. */ +function decisionFor(intent: CommitIntent): CommitDecision { + return { + workspaceRootId: intent.publication?.proposedWorkspaceRootId ?? intent.expectedWorkspaceRootId, + journalEventIds: intent.events.map((_event, index) => `event-${index}`), + }; +} + +/** A test-supplied outcome, carried through with the decision it implies. */ +function mapDecision(result: Result, intent: CommitIntent): Result { + return result.ok ? Ok(decisionFor(intent)) : result; +} + +/** A link that records what it was asked, and answers how a test tells it to. */ +function link( + options: { + frontier?: StartingFrontier; + commit?: (intent: CommitIntent) => Result; + blockFrontier?: { operation: Operation }; + blockCommit?: { operation: Operation }; + } = {}, +) { + const sent: CommitIntent[] = []; + const starting: StartingFrontier = options.frontier ?? { + workspaceRootId: "root-a", + journalEventId: "event-0", + events: [event("already-there")], + }; + const owner: OwnerLink = { + *frontier(): Operation { + if (options.blockFrontier !== undefined) { + yield* options.blockFrontier.operation; + } + return starting; + }, + *commit(intent: CommitIntent): Operation> { + sent.push(intent); + if (options.blockCommit !== undefined) { + yield* options.blockCommit.operation; + } + return options.commit === undefined + ? Ok(decisionFor(intent)) + : mapDecision(options.commit(intent), intent); + }, + }; + return { owner, sent, starting }; +} + +describe("a remote transaction", () => { + it("sends one intent carrying what the body enlisted", function* () { + const { owner, sent } = link(); + const gate = createTransactionGate(); + + const result = yield* transactRemotely(owner, gate, function* (transaction) { + yield* transaction.journal.append(event("one")); + yield* transaction.journal.append(event("two")); + return "body value"; + }); + + expect(result.ok).toBe(true); + expect(result.ok && result.value).toBe("body value"); + expect(sent).toHaveLength(1); + expect(sent[0]?.expectedWorkspaceRootId).toBe("root-a"); + expect(sent[0]?.expectedJournalEventId).toBe("event-0"); + expect(sent[0]?.events).toHaveLength(2); + }); + + it("reads the starting prefix and its own appends, in order", function* () { + const { owner } = link(); + const gate = createTransactionGate(); + let seen: string[] = []; + + yield* transactRemotely(owner, gate, function* (transaction) { + yield* transaction.journal.append(event("mine")); + const all = yield* transaction.journal.readAll(); + seen = all.map(nameOf); + return undefined; + }); + + expect(seen).toEqual(["already-there", "mine"]); + }); + + it("lets the body cross a suspension point with no owner transaction open", function* () { + const { owner, sent } = link(); + const gate = createTransactionGate(); + + const result = yield* transactRemotely(owner, gate, function* (transaction) { + yield* transaction.journal.append(event("before")); + // Nothing is held on the owner while this waits, which is the whole + // reason the body runs here rather than inside a transaction. + yield* sleep(1); + yield* transaction.journal.append(event("after")); + return "crossed"; + }); + + expect(result.ok && result.value).toBe("crossed"); + expect(sent).toHaveLength(1); + expect(sent[0]?.events).toHaveLength(2); + }); + + it("sends nothing when the body fails", function* () { + const { owner, sent } = link(); + const gate = createTransactionGate(); + + let raised: unknown; + try { + yield* transactRemotely(owner, gate, function* (transaction) { + yield* transaction.journal.append(event("doomed")); + throw new Error("the body decided otherwise"); + }); + } catch (error) { + raised = error; + } + + expect(String(raised)).toContain("the body decided otherwise"); + expect(sent).toEqual([]); + expect(gate.open).toBe(false); + }); + + it("returns the owner's refusal rather than the body's value", function* () { + const { owner, sent } = link({ commit: () => Err(new Error("stale expected root")) }); + const gate = createTransactionGate(); + + const result = yield* transactRemotely(owner, gate, function* (transaction) { + yield* transaction.journal.append(event("hopeful")); + return "never returned"; + }); + + expect(result.ok).toBe(false); + expect(!result.ok && String(result.error)).toContain("stale expected root"); + expect(sent).toHaveLength(1); + }); + + it("refuses a transaction opened inside a transaction", function* () { + const { owner, sent } = link(); + const gate = createTransactionGate(); + + let raised: unknown; + try { + yield* transactRemotely(owner, gate, function* () { + yield* transactRemotely(owner, gate, function* () { + return undefined; + }); + return undefined; + }); + } catch (error) { + raised = error; + } + + expect(raised).toBeInstanceOf(RemoteTransactionError); + expect(refusalOf(raised)).toBe("nested-transaction"); + expect(sent).toEqual([]); + }); + + it("refuses an ordinary same-handle operation while a body is running", function* () { + const { owner } = link(); + const gate = createTransactionGate(); + let raised: unknown; + + yield* transactRemotely(owner, gate, function* () { + try { + requireNoOpenTransaction(gate); + } catch (error) { + raised = error; + } + return undefined; + }); + + expect(raised).toBeInstanceOf(RemoteTransactionError); + expect(refusalOf(raised)).toBe("operation-inside-body"); + // And the gate is closed again afterwards, so the next operation is fine. + requireNoOpenTransaction(gate); + }); + + it("refuses a transaction handle used after its body closed", function* () { + const { owner } = link(); + const gate = createTransactionGate(); + let escaped: { journal: { append(event: DurableEvent): Operation } } | undefined; + + yield* transactRemotely(owner, gate, function* (transaction) { + escaped = transaction; + return undefined; + }); + + let raised: unknown; + try { + yield* escaped!.journal.append(event("too late")); + } catch (error) { + raised = error; + } + expect(refusalOf(raised)).toBe("transaction-closed"); + }); + + it("owns the handle from before the first suspension until after the commit", function* () { + const held = withResolvers(); + const { owner, sent } = link({ blockFrontier: held }); + const gate = createTransactionGate(); + + const first = yield* spawn(() => + transactRemotely(owner, gate, function* () { + return "first"; + }), + ); + yield* sleep(0); + + // The first transaction is suspended inside `frontier()`. A second must not + // pass the gate and act from the same starting frontier. + let raised: unknown; + try { + yield* transactRemotely(owner, gate, function* () { + return "second"; + }); + } catch (error) { + raised = error; + } + expect(refusalOf(raised)).toBe("nested-transaction"); + + held.resolve(); + yield* first; + expect(sent).toHaveLength(1); + }); + + it("keeps the handle while the commit is still undecided", function* () { + const held = withResolvers(); + const { owner } = link({ blockCommit: held }); + const gate = createTransactionGate(); + + const first = yield* spawn(() => + transactRemotely(owner, gate, function* (transaction) { + yield* transaction.journal.append(event("one")); + return "first"; + }), + ); + yield* sleep(0); + + // The body has finished, but which state won is not yet established. + expect(gate.open).toBe(true); + let raised: unknown; + try { + requireNoOpenTransaction(gate); + } catch (error) { + raised = error; + } + expect(refusalOf(raised)).toBe("operation-inside-body"); + + held.resolve(); + yield* first; + expect(gate.open).toBe(false); + }); + + it("releases the handle however the transaction ends", function* () { + const gate = createTransactionGate(); + + const succeeded = link(); + yield* transactRemotely(succeeded.owner, gate, function* () { + return undefined; + }); + expect(gate.open).toBe(false); + + const refused = link({ commit: () => Err(new Error("refused")) }); + yield* transactRemotely(refused.owner, gate, function* () { + return undefined; + }); + expect(gate.open).toBe(false); + + const failed = link(); + try { + yield* transactRemotely(failed.owner, gate, function* () { + throw new Error("body failed"); + }); + } catch { + // The refusal is the subject of another test; this one is about the gate. + } + expect(gate.open).toBe(false); + + const broken: OwnerLink = { + *frontier(): Operation { + throw new Error("transport failed"); + }, + *commit(): Operation> { + return Ok({ workspaceRootId: "root-a", journalEventIds: [] }); + }, + }; + try { + yield* transactRemotely(broken, gate, function* () { + return undefined; + }); + } catch { + // Likewise. + } + expect(gate.open).toBe(false); + }); + + it("refuses an event it cannot admit, and sends nothing", function* () { + const { owner, sent } = link(); + const gate = createTransactionGate(); + + let raised: unknown; + try { + yield* transactRemotely(owner, gate, function* (transaction) { + yield* offering(transaction.journal).append({ nothing: true }); + return undefined; + }); + } catch (error) { + raised = error; + } + expect(refusalOf(raised)).toBe("malformed-event"); + expect(sent).toEqual([]); + }); + + it("refuses more bytes than one intent may carry", function* () { + const { owner, sent } = link(); + const gate = createTransactionGate(); + const wide = event("x".repeat(200_000)); + + let raised: unknown; + try { + yield* transactRemotely(owner, gate, function* (transaction) { + for (let index = 0; index < 40; index += 1) { + yield* transaction.journal.append(wide); + } + return undefined; + }); + } catch (error) { + raised = error; + } + expect(refusalOf(raised)).toBe("events-too-large"); + expect(sent).toEqual([]); + }); + + it("commits what it admitted, not what a reader mutated afterwards", function* () { + const { owner, sent } = link(); + const gate = createTransactionGate(); + + yield* transactRemotely(owner, gate, function* (transaction) { + yield* transaction.journal.append(event("admitted")); + // Read it back and edit what came out. The collector handed over a copy, + // so the intent still carries what `append()` admitted. + const read = yield* transaction.journal.readAll(); + const mine = read[read.length - 1]; + if (mine !== undefined) { + rename(mine, "changed by a reader"); + } + return undefined; + }); + + expect(nameOf(sent[0]?.events[0])).toBe("admitted"); + }); + + it("commits what it was handed, not what the caller mutated afterwards", function* () { + const { owner, sent } = link(); + const gate = createTransactionGate(); + const mutable = event("original"); + + yield* transactRemotely(owner, gate, function* (transaction) { + yield* transaction.journal.append(mutable); + rename(mutable, "changed"); + return undefined; + }); + + const committed = sent[0]?.events[0]; + expect(nameOf(committed)).toBe("original"); + }); +}); diff --git a/packages/workflow/tests/remote-workspace-files.test.ts b/packages/workflow/tests/remote-workspace-files.test.ts new file mode 100644 index 000000000..221280586 --- /dev/null +++ b/packages/workflow/tests/remote-workspace-files.test.ts @@ -0,0 +1,181 @@ +/** + * Tier WRH — what the runner's Workspace filesystem will act on. + * + * The attempt is a real directory on a host that has an outside, and a symbolic + * link is a path the kernel follows on its own. So these use real temporary + * files and the production adapter: a fake filesystem would follow whatever the + * fake decided to follow, which is the one thing under test. + * + * The rule is the host provider's, stated in `packages/runtime/host-files.ts`: + * a complete `..` segment leaves and `..notes.md` does not; an operation about + * a link does not follow it; and a link's target is a Workspace path, so an + * absolute one names the Workspace root rather than the machine's. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { type Operation, until } from "effection"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { useRunnerTrees } from "../src/deno/remote-files.ts"; +import { createRemoteWorkspaceFilesystem } from "../src/deno/remote-workspace-files.ts"; +import type { WorkspaceFilesystem } from "../src/workspace/filesystem.ts"; + +const SECRET = "the file outside\n"; + +interface Scene { + readonly files: WorkspaceFilesystem; + readonly attempt: string; + readonly outside: string; +} + +/** + * An attempt directory, and a separate directory it must never reach. + * + * Both are real, and the outside one holds a file whose bytes are recognizable: + * an escape that succeeded would return exactly them. + */ +function* scene(): Operation { + const trees = yield* useRunnerTrees(); + const attempt = yield* trees.create("attempt"); + const outside = yield* trees.create("outside"); + yield* until(writeFile(`${outside}/secret.txt`, SECRET, { mode: 0o644 })); + yield* until(mkdir(`${attempt}/docs`, { mode: 0o755 })); + yield* until(writeFile(`${attempt}/docs/inside.txt`, "inside\n", { mode: 0o644 })); + const files = createRemoteWorkspaceFilesystem( + (logical) => (logical === "/" ? attempt : `${attempt}${logical}`), + () => {}, + ); + return { files, attempt, outside }; +} + +/** What an operation refused with, having proved it refused at all. */ +function* refusal(operation: Operation): Operation { + try { + yield* operation; + return "it was allowed"; + } catch (error) { + return String(error); + } +} + +describe("the runner's Workspace filesystem", () => { + it("follows a link inside the attempt, and leaves it a link", function* () { + const { files, attempt } = yield* scene(); + yield* until(symlink("docs/inside.txt", `${attempt}/here`)); + + expect(yield* files.readTextFile("/here")).toBe("inside\n"); + // The contract of each: one is about the file, the other about the entry. + expect((yield* files.stat("/here")).kind).toBe("file"); + expect((yield* files.lstat("/here")).kind).toBe("symlink"); + expect(yield* files.readlink("/here")).toBe("docs/inside.txt"); + + yield* files.writeFile("/here", "through the link\n", 0o644); + // The file the link names changed; the link is still a link. + expect(yield* until(readFile(`${attempt}/docs/inside.txt`, "utf8"))).toBe("through the link\n"); + expect((yield* files.lstat("/here")).kind).toBe("symlink"); + }); + + it("reads a Workspace-absolute link target against the attempt, not the host", function* () { + const { files, attempt } = yield* scene(); + // The target is a Workspace path. Interpreted by the kernel it would be a + // machine path; interpreted here it is this attempt's own `/docs`. + yield* until(symlink("/docs/inside.txt", `${attempt}/logical`)); + expect(yield* files.readTextFile("/logical")).toBe("inside\n"); + }); + + it("exposes nothing through a final link that leaves the attempt", function* () { + const { files, attempt, outside } = yield* scene(); + yield* until(symlink(`${outside}/secret.txt`, `${attempt}/escape`)); + yield* until(symlink("../../../../etc/hosts", `${attempt}/relative`)); + + for (const path of ["/escape", "/relative"]) { + // The host-absolute target is a Workspace path here, so it names nothing; + // the relative one climbs out of the tree and is refused. Neither is a + // way to the bytes, which is the claim. + expect([path, yield* refusal(files.readTextFile(path))]).not.toEqual([ + path, + "it was allowed", + ]); + expect(yield* refusal(files.readTextFile(path))).not.toContain("the file outside"); + yield* refusal(files.writeFile(path, "overwritten\n")); + yield* refusal(files.chmod(path, 0o600)); + } + // Neither the outside file nor the link it went through changed. + expect(yield* until(readFile(`${outside}/secret.txt`, "utf8"))).toBe(SECRET); + expect(yield* files.readlink("/escape")).toBe(`${outside}/secret.txt`); + }); + + it("reaches nothing through an ancestor link that leaves the attempt", function* () { + const { files, attempt, outside } = yield* scene(); + yield* until(symlink(outside, `${attempt}/door`)); + yield* until(symlink("../..", `${attempt}/up`)); + + for (const path of ["/door/secret.txt", "/up/anything"]) { + expect(yield* refusal(files.readTextFile(path))).not.toContain("the file outside"); + yield* refusal(files.writeFile(path, "created\n")); + yield* refusal(files.mkdir(path, { recursive: true })); + yield* refusal(files.remove(path)); + yield* refusal(files.chmod(path, 0o600)); + yield* refusal(files.rename("/docs/inside.txt", path)); + yield* refusal(files.link("/docs/inside.txt", path)); + } + expect(yield* until(readFile(`${outside}/secret.txt`, "utf8"))).toBe(SECRET); + // And the file that was there to move is still where it was. + expect(yield* files.readTextFile("/docs/inside.txt")).toBe("inside\n"); + }); + + it("contains both ends of a rename and a hardlink", function* () { + const { files, attempt, outside } = yield* scene(); + yield* until(symlink(outside, `${attempt}/door`)); + + expect(yield* refusal(files.rename("/docs/inside.txt", "/../moved"))).toContain( + "outside the tree", + ); + expect(yield* refusal(files.link("/docs/inside.txt", "/../linked"))).toContain( + "outside the tree", + ); + yield* refusal(files.rename("/door/secret.txt", "/taken")); + yield* refusal(files.link("/door/secret.txt", "/taken")); + // Nothing arrived, and nothing left. + expect(yield* refusal(files.readTextFile("/taken"))).not.toContain("the file outside"); + expect(yield* files.readTextFile("/docs/inside.txt")).toBe("inside\n"); + }); + + it("does not turn a dangling outward link into a way to write outside", function* () { + const { files, attempt, outside } = yield* scene(); + yield* until(symlink(`${outside}/absent.txt`, `${attempt}/dangling`)); + yield* refusal(files.writeFile("/dangling", "created outside\n")); + // Nothing was created where the link pointed. + expect(yield* refusal(until(readFile(`${outside}/absent.txt`, "utf8")))).toContain("ENOENT"); + // The link is still exactly what it was. + expect(yield* files.readlink("/dangling")).toBe(`${outside}/absent.txt`); + }); + + it("admits an ordinary name beginning with two dots, and refuses a whole segment", function* () { + const { files } = yield* scene(); + yield* files.writeFile("/..notes.md", "two dots is a name\n", 0o644); + expect(yield* files.readTextFile("/..notes.md")).toBe("two dots is a name\n"); + expect(yield* files.readTextFile("/docs/../..notes.md")).toBe("two dots is a name\n"); + + for (const path of ["/..", "/../escaped", "/docs/../../escaped", ""]) { + expect([path, yield* refusal(files.writeFile(path, "no"))]).toEqual([ + path, + expect.stringContaining("outside the tree this invocation owns"), + ]); + } + }); + + it("says nothing about the host in what it refuses with", function* () { + const { files, attempt, outside } = yield* scene(); + yield* until(symlink(`${outside}/secret.txt`, `${attempt}/escape`)); + const reported = [ + yield* refusal(files.readTextFile("/escape")), + yield* refusal(files.readTextFile("/../escaped")), + yield* refusal(files.readTextFile("/docs/absent.txt")), + ].join("\n"); + // Not where this invocation put its tree, and not where a link pointed. + expect(reported).not.toContain(attempt); + expect(reported).not.toContain(outside); + expect(reported).not.toContain("secret.txt"); + }); +}); diff --git a/packages/workflow/tests/remote-workspace.test.ts b/packages/workflow/tests/remote-workspace.test.ts new file mode 100644 index 000000000..7e851de57 --- /dev/null +++ b/packages/workflow/tests/remote-workspace.test.ts @@ -0,0 +1,933 @@ +/** + * Tier WRH — the runner's Workspace coordinator, end to end. + * + * What this is about is ordering and authority, not arithmetic. The Files are + * real: a documented failure has to leave a directory that recaptures to the + * root it started from, and a fake cannot settle that. The owner is a scripted + * connection, because what crosses it here is what the coordinator decided — + * whether the atomic commit is really atomic is proved on real workerd, where + * atomicity is real. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import type { DurableStream } from "@executablemd/durable-streams"; +import { + durableRun, + establishJournalProvenance, + InMemoryStream, + type DurableEvent, + type Json, + type JournalProvenance, + type Workflow, +} from "@executablemd/durable-streams"; +import { type Operation, scoped, sleep, spawn, until } from "effection"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { runnerFiles, useRunnerTrees } from "../src/deno/remote-files.ts"; +import { encodeBase64 } from "../src/cloudflare/encoding.ts"; +import { createRemoteWorkspaceFilesystem } from "../src/deno/remote-workspace-files.ts"; +import { captureWorkspace, type CapturedWorkspace } from "../src/remote/materialize.ts"; +import type { RemoteContent, RemoteContentRequest, RemoteReadLink } from "../src/remote/read.ts"; +import type { RemoteFrontierSnapshot } from "../src/remote/read.ts"; +import type { RemoteInvocationSnapshot } from "../src/remote/records.ts"; +import { useRemoteRunDatabase } from "../src/remote/database.ts"; +import { cloudflareRunLink, cloudflareReadLink } from "../src/cloudflare/client.ts"; +import { type OwnerSocket, type SocketListener, useOwnerConnection } from "../src/remote/client.ts"; +import type { WorkspaceFilesystem } from "../src/workspace/filesystem.ts"; +import type { WorkspaceMetadata } from "../src/workspace/metadata.ts"; +import { + createRemoteWorkspaceEffect, + type RemoteRun, + type RemoteRunOptions, + type RemoteWorkspaceMutation, + useRemoteRun, + type RemoteWorkspaceRuntime, + useRemoteWorkspaceEffects, + withRemoteWorkspaceEffects, +} from "../src/remote/workspace.ts"; +import { routeRemoteRunJournal } from "../src/remote/journal-route.ts"; +import { createInvocationMappings } from "../src/remote/mappings.ts"; +import { JournaledEffectFailure } from "../src/workspace/failure.ts"; +import { parseWorkspaceRootManifest } from "../src/workspace/root-manifest.ts"; +import { locatorFingerprintOf } from "../src/composition/locator.ts"; +import { agentSessionKey, resolveAgentSession } from "../src/storage/agent-session.ts"; +import type { WorkflowRunDatabase } from "../src/storage/api.ts"; + +const RUN_ID = "remote-run"; +const LOCATOR = "https://git.example.invalid/octo/app.git"; + +function reject(reason: string): never { + throw new Error(reason); +} + +/** A refusal the effect publishes rather than raises, as a document's would be. */ +class DocumentedFailure extends JournaledEffectFailure { + override name = "DocumentedFailure"; +} + +function runRecord() { + return { + runId: RUN_ID, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + status: "running", + createdAt: "2026-09-03T00:00:00.000Z", + updatedAt: "2026-09-03T00:00:00.000Z", + }; +} + +function repository(name = "app") { + return { + record: { + name, + locatorFingerprint: locatorFingerprintOf(LOCATOR), + requestedBase: null, + creationCommit: "9".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + checkoutPath: `/${name}`, + }, + locator: LOCATOR, + }; +} + +function emptySnapshot(workspaceRootId: string): RemoteInvocationSnapshot { + return { + workspaceRootId, + journalEventId: null, + repositories: [], + worktrees: [], + agentSessions: [], + }; +} + +/** A connection whose answers a test writes, and which records what it was sent. */ +function wire(answer: (request: Record) => Record) { + const sent: Record[] = []; + const listeners = new Map>(); + const socket: OwnerSocket = { + send(data: string): void { + const request = JSON.parse(data) as Record; + sent.push(request); + const response = answer(request); + if (response["outcome"] === "lost") { + // The connection went while the answer was in flight. + for (const listener of listeners.get("close") ?? []) { + listener({}); + } + return; + } + for (const listener of listeners.get("message") ?? []) { + listener({ data: JSON.stringify({ id: request["id"], ...response }) }); + } + }, + close(): void {}, + addEventListener(type, listener): void { + const found = listeners.get(type) ?? new Set(); + found.add(listener); + listeners.set(type, found); + }, + removeEventListener(type, listener): void { + listeners.get(type)?.delete(listener); + }, + }; + return { socket, sent }; +} + +/** The owner's answers for one starting tree, and what it was asked to commit. */ +function ownerOf( + captured: CapturedWorkspace, + snapshot: () => RemoteInvocationSnapshot | { refused: string }, +) { + const commits: Record[] = []; + let refusal: string | undefined; + let lost = false; + return { + commits, + refuse(reason: string): void { + refusal = reason; + }, + lose(): void { + lost = true; + }, + get lost(): boolean { + return lost; + }, + answer(request: Record): Record { + const command = request["command"]; + if (command === "mappings") { + const value = snapshot(); + return "refused" in value + ? { outcome: "performed", value } + : { outcome: "performed", value }; + } + if (command === "frontier") { + return { + outcome: "performed", + value: { + record: runRecord(), + retrieval: null, + workspaceRootId: captured.root.rootId, + journalEventId: null, + }, + }; + } + if (command === "root") { + return { + outcome: "performed", + value: { + workspaceRootId: captured.root.rootId, + manifest: captured.root.manifest, + }, + }; + } + if (command === "content") { + const digest = String(request["digest"]); + const bytes = + request["kind"] === "manifest" + ? captured.contents.get(digest)?.manifestBytes + : captured.blobs.get(digest); + if (bytes === undefined) { + throw new Error("asked for content this owner does not hold"); + } + return { + outcome: "performed", + value: { + kind: request["kind"], + digest, + size: bytes.length, + bytes: encodeBase64(bytes), + }, + }; + } + if (command === "stage") { + const encoded = String(request["bytes"] ?? ""); + const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; + return { + outcome: "performed", + value: { + kind: request["kind"], + digest: request["digest"], + size: (encoded.length / 4) * 3 - padding, + }, + }; + } + commits.push(request); + if (lost) { + return { outcome: "lost" }; + } + if (refusal !== undefined) { + return { outcome: "refused", refusal }; + } + const publication = request["publication"]; + const events = Array.isArray(request["events"]) ? request["events"] : []; + return { + outcome: "performed", + value: { + workspaceRootId: + publication === null || publication === undefined + ? request["expectedWorkspaceRootId"] + : (publication as Record)["proposedWorkspaceRootId"], + journalEventIds: events.map((_entry, index) => `event-${index}`), + }, + }; + }, + }; +} + +/** A small starting tree, captured so a scripted owner can serve it. */ +function* startingTree(): Operation { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const root = yield* trees.create("source"); + yield* until(writeFile(`${root}/README.md`, "starting\n", { mode: 0o644 })); + yield* until(mkdir(`${root}/docs`, { mode: 0o755 })); + return yield* captureWorkspace( + files, + (logical) => (logical === "/" ? root : `${root}${logical}`), + reject, + ); +} + +/** + * The constructor takes one link and no separate read input. + * + * A type-level assertion rather than a runtime one, because that is where the + * property lives: adding a `reads` member back to `RemoteRunOptions` — the + * shape this correction removed — stops this file compiling. + */ +type NoSeparateReads = "reads" extends keyof RemoteRunOptions ? never : true; +const ONE_LINK: NoSeparateReads = true; + +/** + * One scripted owner and a live connection to it, with nothing built on top. + * + * The harness below opens a binding; this stops short of that, so a test can + * hold two owners and ask what each one was actually sent. + */ +function* owner(captured: CapturedWorkspace) { + const scripted = ownerOf(captured, () => emptySnapshot(captured.root.rootId)); + const requests: Record[] = []; + const transport = wire((request) => { + requests.push(request); + return scripted.answer(request); + }); + const connection = yield* useOwnerConnection(transport.socket); + let identifier = 0; + return { + connection, + requests, + next: () => `owner-${(identifier += 1)}`, + commits: scripted.commits, + }; +} + +interface Harness { + readonly run: RemoteRun; + readonly commits: Record[]; + refuse(reason: string): void; + lose(): void; + readonly sent: Record[]; + readonly captured: CapturedWorkspace; +} + +/** + * Everything one remote invocation needs, wired the way a host would wire it. + * + * Deliberately the production pieces: the real client over a scripted socket, + * the real database handle, the real coordinator and the real native adapters. + * A test that assembled a simpler stand-in would prove that the stand-in works. + */ +function* harness( + snapshot: (rootId: string) => RemoteInvocationSnapshot | { refused: string } = emptySnapshot, + shared?: CapturedWorkspace, +): Operation { + const captured = shared ?? (yield* startingTree()); + const owner = ownerOf(captured, () => snapshot(captured.root.rootId)); + const transport = wire((request) => owner.answer(request)); + const connection = yield* useOwnerConnection(transport.socket); + let identifier = 0; + const next = () => `request-${(identifier += 1)}`; + // The production constructor: one link, and the handle, the routed journal + // and the provenance made together from it. + const run = yield* useRemoteRun({ + link: cloudflareRunLink(connection, next, RUN_ID), + files: runnerFiles(), + trees: yield* useRunnerTrees(), + createFilesystem: (at, authorize) => createRemoteWorkspaceFilesystem(at, authorize), + }); + return { + run, + commits: owner.commits, + refuse: owner.refuse, + lose: owner.lose, + sent: transport.sent, + captured, + }; +} + +/** + * One invocation, with its own journal and its own provenance. + * + * Separate per call because that is what a host does: a run's journal is + * established once per live session, and an invocation that reused another + * one's would be publishing into a journal it does not belong to. It also lets + * a later invocation observe what an earlier one left — which is the only way + * to see the accepted Workspace, since the coordinator owns its trees and + * removes them when it is done. + */ +function* invocation( + held: Harness, + name: string, + mutate: RemoteWorkspaceMutation, +): Operation<{ raised: unknown; events: DurableEvent[] }> { + return yield* scoped(function* () { + yield* useRemoteWorkspaceEffects(held.run); + const effect = createRemoteWorkspaceEffect(held.run, { type: "workspace", name }, mutate); + function* workflow(): Workflow { + yield effect; + } + const raised = yield* trapped( + withRemoteWorkspaceEffects(held.run, durableRun(workflow, { stream: held.run.journal })), + ); + // Read back through the run's own journal, which is the owner's. An owner + // this harness scripted to refuse cannot answer that read, and the tests + // that ask for one are not the tests that scripted a refusal. + let events: DurableEvent[] = []; + try { + events = yield* held.run.journal.readAll(); + } catch { + events = []; + } + return { raised, events }; + }); +} + +/** A mutation that touches nothing: these tests are about who may run one. */ +// deno-lint-ignore require-yield +function* own(): Operation { + return "ran"; +} + +function yielded(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter((event) => event.type === "yield"); +} + +/** + * The commands that carried a Workspace effect, out of everything the owner was + * asked to commit. + * + * A run's journal lives on its owner, so an ordinary append — the root `Close` + * of the invocation below, for one — reaches the owner as a commit of its own. + * That is the run being persisted where it belongs, and it is not what these + * tests are counting: what they are counting is how many times the coordinator + * proposed a Workspace transaction. + */ +function workspaceIntents(commits: readonly Record[]): Record[] { + return commits.filter((intent) => { + const events = intent["events"]; + return ( + Array.isArray(events) && + events.some((event) => { + const parsed: unknown = typeof event === "string" ? JSON.parse(event) : event; + const description = + parsed !== null && typeof parsed === "object" ? Reflect.get(parsed, "description") : null; + return ( + description !== null && + typeof description === "object" && + Reflect.get(description, "type") === "workspace" + ); + }) + ); + }); +} + +describe("the runner's Workspace coordinator", () => { + it("commits Files, one mapping and the filtered result as one intent", function* () { + const held = yield* harness(); + const { raised, events } = yield* invocation( + held, + "write", + function* (filesystem, metadata): Operation { + yield* filesystem.writeFile("/NOTES.md", "written by the effect\n", 0o644); + yield* filesystem.mkdir("/app", { mode: 0o755 }); + metadata.insertRepository(repository()); + // Read-your-writes: its own insert, before anything is committed. + return metadata.readRepository("app")?.record.checkoutPath ?? "missing"; + }, + ); + expect(raised).toBe(undefined); + + // Exactly one intent, carrying all three things together. + const intents = workspaceIntents(held.commits); + expect(intents).toHaveLength(1); + const intent = intents[0] ?? {}; + expect(intent["expectedWorkspaceRootId"]).toBe(held.captured.root.rootId); + const mappings = intent["mappings"]; + expect(Array.isArray(mappings) && mappings).toHaveLength(1); + expect((mappings as Record[])[0]?.["kind"]).toBe("repository"); + expect(intent["publication"]).not.toBe(null); + // The result travelled in this same intent rather than through the + // ordinary journal, so nothing was written before the owner agreed. + expect(Array.isArray(intent["events"]) && intent["events"]).toHaveLength(1); + expect(yielded(events)).toHaveLength(0); + }); + + it("journals a documented failure against the unchanged root, and keeps nothing", function* () { + const held = yield* harness(); + const { raised } = yield* invocation( + held, + "refuse", + function* (filesystem, metadata): Operation { + yield* filesystem.writeFile("/SCRATCH.md", "discarded\n", 0o644); + yield* filesystem.remove("/README.md"); + metadata.insertRepository(repository()); + throw new DocumentedFailure("this Workspace effect refused"); + }, + ); + expect(String(raised)).toContain("this Workspace effect refused"); + + // One commit, and it proposes nothing about the Workspace. + const intents = workspaceIntents(held.commits); + expect(intents).toHaveLength(1); + const intent = intents[0] ?? {}; + expect(intent["publication"]).toBe(null); + expect(intent["mappings"]).toEqual([]); + expect(intent["expectedWorkspaceRootId"]).toBe(held.captured.root.rootId); + expect(Array.isArray(intent["events"]) && intent["events"]).toHaveLength(1); + + // That the owner still holds the starting root after this is a claim + // about storage, and it is made against real owner storage in + // `remote-workspace.vitest.ts`. What is settled here is that nothing was + // proposed: no publication, no mapping, and the root this commit expected + // is the one the invocation was admitted from. + }); + + it("prevents the document from running when the admitted root is unreachable", function* () { + const held = yield* harness(() => emptySnapshot("f".repeat(64))); + let executed = 0; + // deno-lint-ignore require-yield + yield* invocation(held, "unreachable", function* (): Operation { + executed += 1; + return "ran"; + }); + // Materialization is before the transaction, so this never reaches the + // anchor check — and it must still leave the run exactly as it was. + expect(executed).toBe(0); + expect(held.commits).toEqual([]); + }); + + it("refuses before the document runs when the run moved since admission", function* () { + // The root still materializes, so the invocation gets all the way to the + // transaction; the journal anchor is what has moved. Nothing later could + // notice on its own — both answers were true when they were given. + const held = yield* harness((rootId) => ({ + ...emptySnapshot(rootId), + journalEventId: "event-from-another-moment", + })); + let executed = 0; + // deno-lint-ignore require-yield + const { raised } = yield* invocation(held, "drifted", function* (): Operation { + executed += 1; + return "ran"; + }); + expect(String(raised)).toContain("moved past"); + expect(executed).toBe(0); + expect(held.commits).toEqual([]); + }); + + it("leaves the accepted Workspace alone when the owner refuses the commit", function* () { + const held = yield* harness(); + held.refuse("command:stale-root"); + const { raised } = yield* invocation(held, "refused", function* (filesystem): Operation { + yield* filesystem.writeFile("/NOTES.md", "written by the effect\n", 0o644); + return "ran"; + }); + expect(raised).not.toBe(undefined); + // The owner said no, so nothing is promoted and nothing private crossed. + expect(String(raised)).not.toContain("command:"); + expect(workspaceIntents(held.commits)).toHaveLength(1); + }); + + it("cannot pair one run's handle with another run's link, journal or provenance", function* () { + // Two owners, deliberately begun from the same root and the same empty + // journal. Every structural value they hold is equal; only the objects + // differ, and only the objects decide. + const tree = yield* startingTree(); + const a = yield* harness(emptySnapshot, tree); + const b = yield* harness(emptySnapshot, tree); + expect(a.run.database.record.runId).toBe(b.run.database.record.runId); + + // An effect made against A, coordinated under B. + yield* scoped(function* () { + yield* useRemoteWorkspaceEffects(b.run); + const effect = createRemoteWorkspaceEffect(a.run, { type: "workspace", name: "a" }, own); + function* workflow(): Workflow { + yield effect; + } + const raised = yield* trapped( + withRemoteWorkspaceEffects(b.run, durableRun(workflow, { stream: b.run.journal })), + ); + expect(String(raised)).toContain("foreign"); + }); + + // A's coordinator installed, B's binding asked to use it. + yield* scoped(function* () { + yield* useRemoteWorkspaceEffects(a.run); + const effect = createRemoteWorkspaceEffect(b.run, { type: "workspace", name: "b" }, own); + function* workflow(): Workflow { + yield effect; + } + const raised = yield* trapped( + withRemoteWorkspaceEffects(b.run, durableRun(workflow, { stream: b.run.journal })), + ); + expect(String(raised)).toContain("no remote Workspace coordinator is installed"); + }); + + // B throughout, running over A's journal. The provenance is A's. + yield* scoped(function* () { + yield* useRemoteWorkspaceEffects(b.run); + const effect = createRemoteWorkspaceEffect(b.run, { type: "workspace", name: "c" }, own); + function* workflow(): Workflow { + yield effect; + } + const raised = yield* trapped( + withRemoteWorkspaceEffects(b.run, durableRun(workflow, { stream: a.run.journal })), + ); + expect(String(raised)).toContain("provenance"); + }); + + // A value shaped like a binding is not one. + const forged = { database: b.run.database, journal: b.run.journal }; + expect( + String(yield* trapped(useRemoteWorkspaceEffects(forged as unknown as RemoteRun))), + ).toContain("not a remote run this build opened"); + + // Neither owner was asked for anything, and neither journal moved. + expect([a.commits, b.commits]).toEqual([[], []]); + expect(yielded(yield* a.run.journal.readAll())).toEqual([]); + expect(yielded(yield* b.run.journal.readAll())).toEqual([]); + }); + + it("cannot be opened from one owner's reads and another owner's commits", function* () { + // The construction the correction closes. Two owners, deliberately begun + // from one captured tree, so their root, anchor and run record are equal + // and only the objects differ. Before this, `useRemoteRun` took the + // database/commit link and the Workspace read link separately, and this + // combination produced a legitimate binding: the invocation would be + // admitted from A's mappings and content and commit its result to B. + const tree = yield* startingTree(); + const a = yield* owner(tree); + const b = yield* owner(tree); + + // Each link is built from one connection and carries its own reads, so + // reading through one reaches that owner and no other. + const linkA = cloudflareRunLink(a.connection, a.next, RUN_ID); + const linkB = cloudflareRunLink(b.connection, b.next, RUN_ID); + yield* linkA.invocationSnapshot(); + expect(a.requests.map((request) => request["command"])).toEqual(["mappings"]); + expect(b.requests).toEqual([]); + + expect(ONE_LINK).toBe(true); + const options: RemoteRunOptions = { + link: linkB, + files: runnerFiles(), + trees: yield* useRunnerTrees(), + createFilesystem: (at, authorize) => createRemoteWorkspaceFilesystem(at, authorize), + }; + + let executed = 0; + const run = yield* useRemoteRun(options); + yield* useRemoteWorkspaceEffects(run); + const effect = createRemoteWorkspaceEffect( + run, + { type: "workspace", name: "one-owner" }, + function* (filesystem): Operation { + executed += 1; + yield* filesystem.writeFile("/NOTES.md", "written by the effect\n", 0o644); + return "ran"; + }, + ); + function* workflow(): Workflow { + yield effect; + } + yield* withRemoteWorkspaceEffects(run, durableRun(workflow, { stream: run.journal })); + + // Everything the invocation read and everything it committed went to B. + // A answered the one snapshot this test asked it for directly, and + // nothing else: no root, no content, no staging, no commit. + expect(executed).toBe(1); + expect(a.requests.map((request) => request["command"])).toEqual(["mappings"]); + expect(workspaceIntents(b.commits)).toHaveLength(1); + // A was never asked for anything else either — no read, no staging and + // no commit reached it after the one snapshot above. + expect(a.requests.map((request) => request["command"])).toEqual(["mappings"]); + }); + + it("refuses before the split, not after: the other run's journal stays empty", function* () { + // The discriminator. Before this correction, B's transaction would enlist + // the Workspace while the publication appended through A's journal, and a + // refusal from B would leave A holding an event for a commit that never + // happened. The refusal has to come first. + const tree = yield* startingTree(); + const a = yield* harness(emptySnapshot, tree); + const b = yield* harness(emptySnapshot, tree); + b.refuse("command:stale-root"); + + yield* scoped(function* () { + yield* useRemoteWorkspaceEffects(b.run); + const effect = createRemoteWorkspaceEffect( + b.run, + { type: "workspace", name: "split" }, + function* (filesystem): Operation { + yield* filesystem.writeFile("/NOTES.md", "written by the effect\n", 0o644); + return "ran"; + }, + ); + function* workflow(): Workflow { + yield effect; + } + const raised = yield* trapped( + withRemoteWorkspaceEffects(b.run, durableRun(workflow, { stream: a.run.journal })), + ); + expect(String(raised)).toContain("provenance"); + }); + + // No commit reached either owner, and A holds no event for work that + // happened somewhere else. + expect([a.commits, b.commits]).toEqual([[], []]); + expect(yielded(yield* a.run.journal.readAll())).toEqual([]); + }); + + it("refuses a binding whose scope has closed", function* () { + let retained: RemoteRun | undefined; + yield* scoped(function* () { + retained = (yield* harness()).run; + }); + if (retained === undefined) { + throw new Error("expected a binding"); + } + // The value outlived the scope that opened it; what it names did not. + const held = retained; + expect((yield* held.database.replaceRetrievalMetadata({ a: 1 })).ok).toBe(false); + const raised = yield* trapped( + scoped(function* () { + yield* useRemoteWorkspaceEffects(held); + const effect = createRemoteWorkspaceEffect(held, { type: "workspace", name: "late" }, own); + function* workflow(): Workflow { + yield effect; + } + return yield* withRemoteWorkspaceEffects( + held, + durableRun(workflow, { stream: held.journal }), + ); + }), + ); + expect(raised).not.toBe(undefined); + }); + + it("refuses a Files capability kept past the invocation that owned it", function* () { + const held = yield* harness(); + let escaped: WorkspaceFilesystem | undefined; + let metadata: WorkspaceMetadata | undefined; + // deno-lint-ignore require-yield + yield* invocation(held, "captured", function* (filesystem, held): Operation { + escaped = filesystem; + metadata = held; + return "ran"; + }); + const wrote = yield* trapped(escaped?.writeFile("/LATE.md", "too late") ?? sleep(0)); + expect(String(wrote)).toContain("stale"); + expect(() => metadata?.insertRepository(repository())).toThrow(); + }); + + it("authorizes only paths beneath the attempt this invocation owns", function* () { + const held = yield* harness(); + const refused: string[] = []; + // deno-lint-ignore require-yield + const { raised } = yield* invocation(held, "escape", function* (filesystem): Operation { + return yield* (function* (): Operation { + for (const path of ["/../escaped", "/docs/../../escaped"]) { + const failure = yield* trapped(filesystem.writeFile(path, "outside")); + refused.push(String(failure)); + } + // Both ends of a rename: checking one would let the other leave. + refused.push(String(yield* trapped(filesystem.rename("/README.md", "/../moved")))); + // The Workspace root itself is a directory this invocation owns. + const entries = yield* filesystem.readdir("/"); + return entries.map((entry) => entry.name).toSorted(); + })(); + }); + expect(raised).toBe(undefined); + expect(refused).toHaveLength(3); + for (const failure of refused) { + expect(failure).toContain("outside the tree this invocation owns"); + } + }); + + it("claims nothing when the answer to its commit is lost", function* () { + const held = yield* harness(); + held.lose(); + const { raised } = yield* invocation(held, "lost", function* (filesystem): Operation { + yield* filesystem.writeFile("/NOTES.md", "written by the effect\n", 0o644); + return "ran"; + }); + // Whether the owner committed is exactly what cannot be known from here. + // What must not happen is claiming it did. + expect(raised).not.toBe(undefined); + expect(String(raised)).not.toContain("command:"); + expect(workspaceIntents(held.commits)).toHaveLength(1); + }); + + it("sends nothing and keeps nothing when the invocation is cancelled", function* () { + const held = yield* harness(); + yield* useRemoteWorkspaceEffects(held.run); + const effect = createRemoteWorkspaceEffect( + held.run, + { type: "workspace", name: "cancelled" }, + function* (filesystem): Operation { + yield* filesystem.writeFile("/SLOW.md", "in progress\n", 0o644); + yield* sleep(10_000); + return "never"; + }, + ); + function* workflow(): Workflow { + yield effect; + } + const task = yield* spawn(() => + withRemoteWorkspaceEffects(held.run, durableRun(workflow, { stream: held.run.journal })), + ); + yield* sleep(0); + yield* task.halt(); + // Cancellation is control flow: nothing was claimed, and nothing was sent. + expect(held.commits).toEqual([]); + expect(yielded(yield* held.run.journal.readAll())).toHaveLength(0); + }); +}); + +describe("what one invocation retains", () => { + function view(snapshot: RemoteInvocationSnapshot) { + return createInvocationMappings(snapshot, () => {}); + } + + it("reconciles a compatible same-name Repository without staging it again", function* () { + const mappings = view({ ...emptySnapshot("a".repeat(64)), repositories: [repository()] }); + // The retained row is what a same-name read answers with. + expect(mappings.metadata.readRepository("app")?.locator).toBe(LOCATOR); + mappings.metadata.insertRepository(repository()); + expect(mappings.deltas()).toEqual([]); + yield* sleep(0); + }); + + it("refuses a same-name Repository that is not the same Repository", function* () { + const mappings = view({ ...emptySnapshot("a".repeat(64)), repositories: [repository()] }); + const conflicting = repository(); + expect(() => + mappings.metadata.insertRepository({ + ...conflicting, + record: { ...conflicting.record, creationCommit: "1".repeat(40) }, + }), + ).toThrow(); + // A conflict never replaces what is already there. + expect(mappings.metadata.readRepository("app")?.record.creationCommit).toBe("9".repeat(40)); + expect(mappings.deltas()).toEqual([]); + yield* sleep(0); + }); + + it("shows an invocation its own inserts and stages each exactly once", function* () { + const mappings = view(emptySnapshot("a".repeat(64))); + mappings.metadata.insertRepository(repository()); + mappings.metadata.insertRepository(repository()); + expect(mappings.metadata.readRepository("app")?.record.name).toBe("app"); + mappings.metadata.insertWorktree({ + repositoryName: "app", + name: "feature", + requestedBranch: "feature", + requestedBase: null, + creationCommit: "9".repeat(40), + checkoutPath: "/app-feature", + }); + const deltas = mappings.deltas(); + // Parents before children, whatever order they were staged in. + expect(deltas.map((delta) => delta.kind)).toEqual(["repository", "worktree"]); + yield* sleep(0); + }); + + it("keeps a Repository locator out of every value the record carries", function* () { + const mappings = view(emptySnapshot("a".repeat(64))); + mappings.metadata.insertRepository(repository()); + const [delta] = mappings.deltas(); + expect(delta?.kind).toBe("repository"); + if (delta?.kind === "repository") { + expect(delta.locator).toBe(LOCATOR); + // The record names the fingerprint and never the bytes. + expect(JSON.stringify(delta.record)).not.toContain("git.example.invalid"); + } + yield* sleep(0); + }); + + it("refuses a Worktree with no Repository and a path this build does not admit", function* () { + const mappings = view(emptySnapshot("a".repeat(64))); + expect(() => + mappings.metadata.insertWorktree({ + repositoryName: "missing", + name: "feature", + requestedBranch: "feature", + requestedBase: null, + creationCommit: "9".repeat(40), + checkoutPath: "/app-feature", + }), + ).toThrow(); + mappings.metadata.insertRepository(repository()); + expect(() => + mappings.metadata.insertWorktree({ + repositoryName: "app", + name: "feature", + requestedBranch: "feature", + requestedBase: null, + creationCommit: "9".repeat(40), + checkoutPath: "not-a-workspace-path", + }), + ).toThrow(); + yield* sleep(0); + }); + + it("resolves an Agent session by the shared rules and stages it once", function* () { + const identity = { + provider: "claude", + agentCommand: "claude", + sessionIdentity: "expansion-1", + }; + const mappings = view(emptySnapshot("a".repeat(64))); + const sessionKey = agentSessionKey(identity); + // Nothing retained and nothing asserted: this is a new conversation. + expect(resolveAgentSession(undefined, "reattach", [], identity).kind).toBe("create"); + + // The pre-commit window: exactly one canonical assertion reconciles it. + const reconciled = resolveAgentSession( + mappings.agentSessions.read(sessionKey), + "reattach", + [{ kind: "session-id", value: "abc" }], + identity, + ); + expect(reconciled.kind).toBe("reattach"); + if (reconciled.kind === "reattach") { + mappings.agentSessions.commit(reconciled.record); + mappings.agentSessions.commit(reconciled.record); + } + expect(mappings.deltas().map((delta) => delta.kind)).toEqual(["agent-session"]); + + // A retained mapping the provider now contradicts refuses rather than + // starting a replacement conversation. + const retained = mappings.agentSessions.read(sessionKey); + expect(retained).not.toBe(undefined); + expect(() => + resolveAgentSession(retained, "reattach", [{ kind: "session-id", value: "other" }], identity), + ).toThrow(); + expect(() => resolveAgentSession(retained, "reattach", [], identity)).toThrow(); + yield* sleep(0); + }); + + it("refuses more mappings, and more mapping bytes, than one commit may carry", function* () { + const byCount = view(emptySnapshot("a".repeat(64))); + expect(() => { + for (let index = 0; index < 512; index += 1) { + byCount.metadata.insertRepository(repository(`app-${String(index).padStart(4, "0")}`)); + } + }).toThrow(); + + // Few enough to pass the count, large enough that no message could carry + // them. Bounding one without the other would leave the other reachable. + const byBytes = view(emptySnapshot("a".repeat(64))); + expect(() => { + for (let index = 0; index < 64; index += 1) { + const wide = repository(`wide-${String(index).padStart(4, "0")}`); + byBytes.metadata.insertRepository({ + ...wide, + record: { + ...wide.record, + creationCommit: "9".repeat(40), + primaryBranch: "b".repeat(8192), + }, + }); + } + }).toThrow(); + yield* sleep(0); + }); +}); + +function* trapped(operation: Operation): Operation { + try { + yield* operation; + return undefined; + } catch (error) { + return error; + } +} diff --git a/packages/workflow/tests/replay-inputs.test.ts b/packages/workflow/tests/replay-inputs.test.ts new file mode 100644 index 000000000..84239ae98 --- /dev/null +++ b/packages/workflow/tests/replay-inputs.test.ts @@ -0,0 +1,1177 @@ +/** + * Tier WRH12 — what a completed run replays on, and where it comes from. + * + * A completed replay imports nothing and performs nothing, so everything it is + * held to has to come from what the run already retains. Two halves of that are + * settled here, over values, because both hosts reach the same function: which + * root document the recorded result was a result of, and which component + * imports the history is allowed to contain. + * + * The load-bearing word throughout is *definition*. Journal data supplies the + * bytes; the immutable run record supplies the identity those bytes have to + * agree with. A history that names another document, another path or another + * object id is refused before anything is replayed from it — and every refusal + * says so without repeating a path, a source or a recorded value. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import type { Operation, Result } from "effection"; +import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import { retainedReplay } from "../src/replay.ts"; +import { rootOutcome } from "../src/lifecycle/policy.ts"; +import { DOCUMENT_FAILED } from "../src/lifecycle/policy.ts"; +import type { RetainedReplay } from "../src/replay.ts"; +import { WorkflowReplayHistoryError } from "../src/replay.ts"; +import { WorkflowBundleHistoryError } from "../src/bundle.ts"; +import { forkRunRecordEvent } from "../src/journal-events.ts"; +import type { JournalEntry } from "../src/storage/api.ts"; +import type { WorkflowComponentEntry } from "../src/storage/definition.ts"; +import type { + WorkflowRunRecord, + WorkflowRunStatus, + WorkflowStopReason, +} from "../src/storage/record.ts"; + +const ROOT_ID = "a".repeat(64); +const COMMIT = "0".repeat(40); +const SOURCE = "# Retained\n\ndone.\n"; + +/** + * The same document, with a section in it. + * + * An exact target is verified against the retained document, so a fixture that + * records one has to record a document that offers it. `SOURCE` offers none — + * which is what makes it the right document for a recorded selection *failure*. + */ +const SECTIONED = "# Retained\n\ndone.\n\n## Stage\n\nstaged.\n"; + +function record( + overrides: { + readonly status?: WorkflowRunStatus; + readonly stopReason?: WorkflowStopReason; + readonly rootDocumentPath?: string; + readonly objectFormat?: "sha1" | "sha256"; + readonly components?: readonly WorkflowComponentEntry[]; + } = {}, +): WorkflowRunRecord { + const components = overrides.components; + const stopReason = overrides.stopReason; + return { + runId: "replay-1", + definition: { + version: 1, + kind: "git", + objectFormat: overrides.objectFormat ?? "sha1", + objectId: COMMIT, + rootDocumentPath: overrides.rootDocumentPath ?? "flows/root.md", + ...(components === undefined ? {} : { components }), + }, + base: "main", + props: {}, + status: overrides.status ?? "completed", + ...(stopReason === undefined ? {} : { stopReason }), + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +let identity = 0; + +function entry(event: DurableEvent): JournalEntry { + identity += 1; + return { eventId: `event-${identity}`, event, workspaceRootId: ROOT_ID }; +} + +/** The root import canonical execution records, as this run recorded it. */ +function rootImport(value: Json): DurableEvent { + return { + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { status: "ok", value }, + }; +} + +/** One bundled component import, as canonical execution records one. */ +function componentImport(name: string, value: Json): DurableEvent { + return { + type: "yield", + coroutineId: "root", + description: { type: "import_component", name }, + result: { status: "ok", value }, + }; +} + +function rootClose(value: Json): DurableEvent { + return { type: "close", coroutineId: "root", result: { status: "ok", value } }; +} + +/** A document that decided it failed, as canonical core records one. */ +function documentFailure(output = ""): Json { + const message = "the document refused"; + return { status: "err", output, error: { name: "Error", message, segment: { message } } }; +} + +/** The terminal core writes when it fails before importing a root document. */ +function preRootClose(path: string, source: string, target: string | null): DurableEvent { + const message = "refused before the root import"; + return rootClose({ + status: "err", + output: "", + error: { name: "Error", message, segment: { message } }, + root_binding: { path, source, target }, + }); +} + +/** An ordinary completed history: the import, then the result. */ +function completedHistory( + path = "flows/root.md", + content = SOURCE, + extra: readonly DurableEvent[] = [], +): JournalEntry[] { + return [ + entry(rootImport({ kind: "repository", path, content })), + ...extra.map((event) => entry(event)), + entry(rootClose({ status: "ok", output: "done.\n", value: "done.\n" })), + ]; +} + +function reason(outcome: Result): string { + if (outcome.ok) { + throw new Error("expected the retained state to be refused"); + } + expect(outcome.error).toEqual(expect.any(WorkflowReplayHistoryError)); + return outcome.error.message; +} + +function admitted(outcome: Result): RetainedReplay { + if (!outcome.ok) { + throw outcome.error; + } + return outcome.value; +} + +/** The reason a failure no retained row identifies is named by. */ +const HOST: WorkflowStopReason = { kind: "host", code: DOCUMENT_FAILED }; + +/** The run record canonical execution retains before it imports anything. */ +const RUN_RECORD = forkRunRecordEvent({ runId: "replay-1", base: "main", pinnedCommit: COMMIT }); + +/** Run every admission one installation carries over one retained history. */ +function* admit( + installations: readonly ExecutionInstallation[], + retained: readonly DurableEvent[], +): Operation { + for (const installation of installations) { + for (const admission of installation.admissions ?? []) { + try { + yield* admission(retained); + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + } + } + return undefined; +} + +describe("the root a completed run replays on", () => { + // deno-lint-ignore require-yield + it("is the document the retained import selected", function* () { + const replay = admitted(retainedReplay(record(), completedHistory())); + + expect(replay.root).toEqual({ path: "flows/root.md", source: SOURCE, retained: true }); + }); + + // deno-lint-ignore require-yield + it("carries the exact target the import resolved to", function* () { + const history = [ + entry( + rootImport({ + kind: "repository", + path: "flows/root.md", + content: SECTIONED, + target: "Stage", + }), + ), + entry(rootClose({ status: "ok", output: "", value: "" })), + ]; + + expect(admitted(retainedReplay(record(), history)).root).toEqual({ + path: "flows/root.md", + source: SECTIONED, + retained: true, + target: "Stage", + }); + }); + + // deno-lint-ignore require-yield + it("carries the selector a recorded failed selection was asked for", function* () { + const history = [ + entry( + rootImport({ + kind: "target-failure", + path: "flows/root.md", + content: SOURCE, + // The record as the selector actually fails against this document: + // it offers no targets at all, so the catalog is empty. + failure: { kind: "no-match", selector: "Missing*", matches: [], available: [] }, + }), + ), + entry(rootClose(documentFailure())), + ]; + + expect( + admitted(retainedReplay(record({ status: "failed", stopReason: HOST }), history)).root, + ).toEqual({ + path: "flows/root.md", + source: SOURCE, + retained: true, + target: "Missing*", + }); + }); + + // deno-lint-ignore require-yield + it("comes from the terminal when the run failed before importing anything", function* () { + const history = [entry(preRootClose("flows/root.md", SOURCE, null))]; + + // The document failed, so the run failed, and no retained row says where — + // which is exactly what the one categorical code is for. + expect( + admitted(retainedReplay(record({ status: "failed", stopReason: HOST }), history)).root, + ).toEqual({ + path: "flows/root.md", + source: SOURCE, + retained: true, + }); + }); + + // deno-lint-ignore require-yield + it("is refused when it is not the document the definition names", function* () { + const shifted = completedHistory("flows/other.md"); + expect(reason(retainedReplay(record(), shifted))).toContain( + "not the document its definition names", + ); + + const bound = [entry(preRootClose("flows/other.md", SOURCE, null))]; + expect(reason(retainedReplay(record({ status: "failed", stopReason: HOST }), bound))).toContain( + "not the document its definition names", + ); + }); +}); + +describe("retained state that describes no completed run", () => { + // deno-lint-ignore require-yield + it("refuses a run that has not ended", function* () { + const live: readonly WorkflowRunStatus[] = ["running", "suspended", "interrupted", "cancelled"]; + for (const status of live) { + const outcome = retainedReplay(record({ status }), completedHistory()); + expect([status, reason(outcome).includes("not terminal")]).toEqual([status, true]); + } + }); + + // deno-lint-ignore require-yield + it("refuses a lifecycle row that claims completion with no recorded result", function* () { + const history = [ + entry(rootImport({ kind: "repository", path: "flows/root.md", content: SOURCE })), + ]; + + expect(reason(retainedReplay(record(), history))).toContain("records no document result"); + }); + + // deno-lint-ignore require-yield + it("refuses a history that continues past the result it records", function* () { + const after = entry({ + type: "yield", + coroutineId: "root", + description: { type: "workspace", name: "write" }, + result: { status: "ok", value: "later" }, + }); + + expect(reason(retainedReplay(record(), [...completedHistory(), after]))).toContain( + "continues past the document result", + ); + expect( + reason( + retainedReplay(record(), [ + ...completedHistory(), + entry(rootClose({ status: "ok", output: "", value: "" })), + ]), + ), + ).toContain("continues past the document result"); + }); + + // deno-lint-ignore require-yield + it("refuses a history recording more than one root import", function* () { + const history = [ + entry(rootImport({ kind: "repository", path: "flows/root.md", content: SOURCE })), + entry(rootImport({ kind: "repository", path: "flows/root.md", content: SOURCE })), + entry(rootClose({ status: "ok", output: "", value: "" })), + ]; + + expect(reason(retainedReplay(record(), history))).toContain("more than one root document"); + }); + + // deno-lint-ignore require-yield + it("refuses a root import this version cannot read", function* () { + const unreadable: Json[] = [ + { kind: "repository", path: "flows/root.md" }, + { kind: "repository", path: "flows/root.md", content: SOURCE, extra: 1 }, + { kind: "repository", path: "flows/root.md", content: SOURCE, target: 7 }, + { kind: "registered", origin: "somewhere", reserved: false }, + { kind: "workflow", path: "flows/root.md", sourceHash: "abc", content: SOURCE }, + "not an object at all", + ]; + + for (const value of unreadable) { + const history = [ + entry(rootImport(value)), + entry(rootClose({ status: "ok", output: "", value: "" })), + ]; + const said = reason(retainedReplay(record(), history)); + expect([JSON.stringify(value), said.includes("cannot be read by this version")]).toEqual([ + JSON.stringify(value), + true, + ]); + } + }); + + // deno-lint-ignore require-yield + it("refuses a root import that failed, and a terminal carrying no binding", function* () { + const failed = [ + entry({ + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { status: "err", error: { name: "Error", message: "gone" } }, + }), + entry(rootClose({ status: "ok", output: "", value: "" })), + ]; + expect(reason(retainedReplay(record(), failed))).toContain("cannot be read by this version"); + + const unbound = [entry(rootClose({ status: "ok", output: "", value: "" }))]; + expect(reason(retainedReplay(record(), unbound))).toContain("cannot be read by this version"); + }); + + // deno-lint-ignore require-yield + it("says what refused without repeating anything the history held", function* () { + const planted = "PLANTED-SECRET-VALUE"; + const history = [ + entry(rootImport({ kind: "repository", path: `flows/${planted}.md`, content: planted })), + entry(rootClose({ status: "ok", output: planted, value: planted })), + ]; + + const said = reason(retainedReplay(record(), history)); + expect(said).not.toContain(planted); + expect(said.length).toBeLessThan(300); + }); +}); + +describe("the lifecycle row and the recorded result have to agree", () => { + /** One history whose root result is the outcome this case is about. */ + function ending(close: DurableEvent): { entries: JournalEntry[]; closeEventId: string } { + const entries = [ + entry(rootImport({ kind: "repository", path: "flows/root.md", content: SOURCE })), + entry(close), + ]; + return { entries, closeEventId: entries[1]?.eventId ?? "" }; + } + + const failure = { name: "Error", message: "the run did not finish" }; + + // deno-lint-ignore require-yield + it("accepts a successful result under a completed run", function* () { + const { entries } = ending(rootClose({ status: "ok", output: "", value: "" })); + + expect(admitted(retainedReplay(record(), entries)).root.path).toBe("flows/root.md"); + }); + + // deno-lint-ignore require-yield + it("accepts a failed result under a failed run naming that exact event", function* () { + const { entries, closeEventId } = ending({ + type: "close", + coroutineId: "root", + result: { status: "err", error: failure }, + }); + + const outcome = retainedReplay( + record({ status: "failed", stopReason: { kind: "journal", eventId: closeEventId } }), + entries, + ); + expect(admitted(outcome).root.path).toBe("flows/root.md"); + }); + + // deno-lint-ignore require-yield + it("refuses every pairing the settled lifecycle cannot produce", function* () { + const errored: DurableEvent = { + type: "close", + coroutineId: "root", + result: { status: "err", error: failure }, + }; + const cancelled: DurableEvent = { + type: "close", + coroutineId: "root", + result: { status: "cancelled" }, + }; + const succeeded = rootClose({ status: "ok", output: "", value: "" }); + + const cases: { says: string; close: DurableEvent; status: WorkflowRunStatus; reason?: true }[] = + [ + { says: "a successful result under a failed run", close: succeeded, status: "failed" }, + { says: "a failed result under a completed run", close: errored, status: "completed" }, + { says: "a cancelled result under a completed run", close: cancelled, status: "completed" }, + { says: "a cancelled result under a failed run", close: cancelled, status: "failed" }, + { says: "a failed run naming no reason at all", close: errored, status: "failed" }, + { + says: "a failed run naming another event", + close: errored, + status: "failed", + reason: true, + }, + ]; + + for (const { says, close, status, reason: elsewhere } of cases) { + const { entries } = ending(close); + const outcome = retainedReplay( + record({ + status, + ...(elsewhere === true + ? { stopReason: { kind: "journal", eventId: "event-somewhere-else" } } + : {}), + }), + entries, + ); + expect([says, reason(outcome).includes("describe different outcomes")]).toEqual([says, true]); + } + }); + + // deno-lint-ignore require-yield + it("refuses a failed run whose reason is a host code rather than the event", function* () { + const { entries } = ending({ + type: "close", + coroutineId: "root", + result: { status: "err", error: failure }, + }); + + const outcome = retainedReplay( + record({ status: "failed", stopReason: { kind: "host", code: "document-execution-failed" } }), + entries, + ); + expect(reason(outcome)).toContain("describe different outcomes"); + }); + + // deno-lint-ignore require-yield + it("refuses a completed run carrying a stop reason of its own", function* () { + const { entries, closeEventId } = ending(rootClose({ status: "ok", output: "", value: "" })); + + const outcome = retainedReplay( + record({ status: "completed", stopReason: { kind: "journal", eventId: closeEventId } }), + entries, + ); + expect(reason(outcome)).toContain("describe different outcomes"); + }); +}); + +/** One retained effect, settled the way its own operation settled. */ +function effect(name: string, settled: "ok" | "err"): DurableEvent { + return { + type: "yield", + coroutineId: "root", + description: { type: "call", name }, + result: + settled === "ok" + ? { status: "ok", value: name } + : { status: "err", error: { message: `${name} failed`, name: "Error" } }, + }; +} + +describe("what the root recorded, as one outcome", () => { + // deno-lint-ignore require-yield + it("reads the document's own result, not the coroutine's settlement", function* () { + // The coroutine returned, so its settlement is `ok`. What it returned is a + // document that failed, and that is what the run is. + const failing = [ + entry(rootImport({ kind: "repository", path: "flows/root.md", content: SOURCE })), + entry(rootClose(documentFailure("partial\n"))), + ]; + expect( + admitted(retainedReplay(record({ status: "failed", stopReason: HOST }), failing)).root, + ).toEqual({ path: "flows/root.md", source: SOURCE, retained: true }); + // And a completed row over the same history is the disagreement. + expect(reason(retainedReplay(record(), failing))).toContain("describe different outcomes"); + }); + + // deno-lint-ignore require-yield + it("names the exact retained row the failure stopped at", function* () { + const rows = [ + entry(rootImport({ kind: "repository", path: "flows/root.md", content: SOURCE })), + entry(effect("early", "err")), + entry(effect("between", "ok")), + entry(effect("late", "err")), + entry(rootClose(documentFailure("partial\n"))), + ]; + const late = rows[3]?.eventId ?? ""; + const early = rows[1]?.eventId ?? ""; + const between = rows[2]?.eventId ?? ""; + + // The last row that failed, and only that one. + expect( + admitted( + retainedReplay( + record({ status: "failed", stopReason: { kind: "journal", eventId: late } }), + rows, + ), + ).root.path, + ).toBe("flows/root.md"); + + const wrong: WorkflowStopReason[] = [ + { kind: "journal", eventId: early }, + { kind: "journal", eventId: between }, + { kind: "journal", eventId: "event-somewhere-else" }, + HOST, + { kind: "host", code: "invented-code" }, + ]; + for (const stopReason of wrong) { + const outcome = retainedReplay(record({ status: "failed", stopReason }), rows); + expect([ + JSON.stringify(stopReason), + reason(outcome).includes("describe different outcomes"), + ]).toEqual([JSON.stringify(stopReason), true]); + } + // And a failure that names nothing at all. + expect(reason(retainedReplay(record({ status: "failed" }), rows))).toContain( + "describe different outcomes", + ); + }); + + // deno-lint-ignore require-yield + it("refuses a document result this version cannot read", function* () { + const message = "the document refused"; + const malformed: Json[] = [ + { status: "err" }, + { status: "err", output: "" }, + { status: "err", output: "", error: { name: "Error", message } }, + { status: "err", output: "", error: { name: "Error", message, segment: {} } }, + { status: "err", output: 7, error: { name: "Error", message, segment: { message } } }, + { status: "ok", output: "" }, + { status: "ok", output: "", value: "", extra: 1 }, + { status: "abandoned", output: "", value: "" }, + "not an object at all", + ]; + + for (const value of malformed) { + const history = [ + entry(rootImport({ kind: "repository", path: "flows/root.md", content: SOURCE })), + entry(rootClose(value)), + ]; + const said = reason(retainedReplay(record(), history)); + expect([JSON.stringify(value), said.includes("cannot be read by this version")]).toEqual([ + JSON.stringify(value), + true, + ]); + // And it says nothing about what it read. + expect(said).not.toContain("abandoned"); + } + }); + + // deno-lint-ignore require-yield + it("recognizes the exact terminal core writes before it imports anything", function* () { + const message = "refused before the root import"; + const binding = { path: "flows/root.md", source: SOURCE, target: null }; + const failure = { name: "Error", message, segment: { message } }; + + // The one form core can produce here, and the run it describes. + const valid = [ + entry(rootClose({ status: "err", output: "", error: failure, root_binding: binding })), + ]; + expect(retainedReplay(record({ status: "failed", stopReason: HOST }), valid).ok).toBe(true); + + const impossible: Json[] = [ + // A binding that is not one. + { status: "err", output: "", error: failure, root_binding: 7 }, + { status: "err", output: "", error: failure, root_binding: [] }, + // A binding missing a member, carrying an extra one, or mistyping one. + { + status: "err", + output: "", + error: failure, + root_binding: { path: "flows/root.md", source: SOURCE }, + }, + { status: "err", output: "", error: failure, root_binding: { ...binding, extra: 1 } }, + { status: "err", output: "", error: failure, root_binding: { ...binding, path: 7 } }, + { status: "err", output: "", error: failure, root_binding: { ...binding, source: 7 } }, + { status: "err", output: "", error: failure, root_binding: { ...binding, target: 7 } }, + // A binding on a result core could not have written it beside: nothing is + // rendered before the root import, no segment failed, and a failure that + // aggregated others got past it. + { status: "err", output: "partial\n", error: failure, root_binding: binding }, + { + status: "err", + output: "", + error: { name: "Error", message, segment: { message: "something else" } }, + root_binding: binding, + }, + { + status: "err", + output: "", + error: { name: "Error", message, segment: { message, source: "flows/root.md" } }, + root_binding: binding, + }, + { + status: "err", + output: "", + error: { name: "Error", message, segment: { message }, errors: [] }, + root_binding: binding, + }, + { status: "ok", output: "", value: "", root_binding: binding }, + ]; + + for (const value of impossible) { + const said = reason( + retainedReplay(record({ status: "failed", stopReason: HOST }), [entry(rootClose(value))]), + ); + expect([JSON.stringify(value), said.includes("cannot be read by this version")]).toEqual([ + JSON.stringify(value), + true, + ]); + } + }); + + // deno-lint-ignore require-yield + it("correlates the terminal with the import history around it", function* () { + const message = "refused before the root import"; + const bound = { + status: "err", + output: "", + error: { name: "Error", message, segment: { message } }, + root_binding: { path: "flows/root.md", source: SOURCE, target: null }, + }; + const imported = rootImport({ kind: "repository", path: "flows/root.md", content: SOURCE }); + const succeeded = { status: "ok", output: "done.\n", value: "done.\n" }; + + // The two histories any execution can produce. + const canonical: { says: string; entries: JournalEntry[]; status: WorkflowRunStatus }[] = [ + { + says: "imported, then a result", + entries: [entry(imported), entry(rootClose(succeeded))], + status: "completed", + }, + { + says: "no import, and the bound failure", + entries: [entry(rootClose(bound))], + status: "failed", + }, + ]; + for (const { says, entries, status } of canonical) { + expect([says, rootOutcome(entries)?.kind]).toEqual([says, "outcome"]); + const outcome = retainedReplay( + record({ status, ...(status === "failed" ? { stopReason: HOST } : {}) }), + entries, + ); + expect([says, outcome.ok]).toEqual([says, true]); + } + + // And the ones none can. A binding is written only by a run that imported + // nothing; an ordinary result is written only by one that imported. + const impossible: { says: string; entries: JournalEntry[]; status: WorkflowRunStatus }[] = [ + { + says: "imported, then the bound failure", + entries: [entry(imported), entry(rootClose(bound))], + status: "failed", + }, + { + says: "no import, and an ordinary failure", + entries: [entry(rootClose(documentFailure("partial\n")))], + status: "failed", + }, + { + says: "no import, and a success", + entries: [entry(rootClose(succeeded))], + status: "completed", + }, + { + says: "an import that failed, then a result", + entries: [ + entry({ + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { status: "err", error: { message: "gone", name: "Error" } }, + }), + entry(rootClose(succeeded)), + ], + status: "completed", + }, + { + says: "an import another coroutine recorded", + entries: [ + entry({ + type: "yield", + coroutineId: "child", + description: { type: "import_component", name: "__root__" }, + result: { + status: "ok", + value: { kind: "repository", path: "flows/root.md", content: SOURCE }, + }, + }), + entry(rootClose(succeeded)), + ], + status: "completed", + }, + ]; + for (const { says, entries, status } of impossible) { + // Stale recovery classifies it as damage rather than publishing from it. + expect([says, rootOutcome(entries)?.kind]).toEqual([says, "damaged"]); + // And admission refuses it rather than building a root from it. + const said = reason( + retainedReplay( + record({ status, ...(status === "failed" ? { stopReason: HOST } : {}) }), + entries, + ), + ); + expect([says, said.includes("cannot be read by this version")]).toEqual([says, true]); + } + }); + + // deno-lint-ignore require-yield + it("counts every event that names the root import, whoever recorded it", function* () { + const succeeded = { status: "ok", output: "done.\n", value: "done.\n" }; + const selection = { kind: "repository", path: "flows/root.md", content: SOURCE }; + const owned = entry(rootImport(selection)); + + /** The same import, recorded under another coroutine. */ + function claimed(coroutineId: string): DurableEvent { + return { + type: "yield", + coroutineId, + description: { type: "import_component", name: "__root__" }, + result: { status: "ok", value: selection }, + }; + } + + const histories: { says: string; entries: JournalEntry[] }[] = [ + { + says: "a child recorded a second one", + entries: [owned, entry(claimed("child")), entry(rootClose(succeeded))], + }, + { + says: "a child recorded the only one", + entries: [entry(claimed("child")), entry(rootClose(succeeded))], + }, + ]; + + for (const { says, entries } of histories) { + // Uniqueness is asked of the name, not of the ownership: a second + // account of the run's own entry is a history canonical core refuses. + expect([says, rootOutcome(entries)?.kind]).toEqual([says, "damaged"]); + expect([says, retainedReplay(record(), entries).ok]).toEqual([says, false]); + } + + // And the one history this rules out nothing about. + expect(rootOutcome([owned, entry(rootClose(succeeded))])?.kind).toBe("outcome"); + expect(retainedReplay(record(), [owned, entry(rootClose(succeeded))]).ok).toBe(true); + }); + + // deno-lint-ignore require-yield + it("holds a settled root import to the selection it must contain", function* () { + const succeeded = { status: "ok", output: "done.\n", value: "done.\n" }; + const unreadable: Json[] = [ + // The content the record must hold, missing. + { kind: "repository", path: "flows/root.md" }, + // A member this form does not have. + { kind: "repository", path: "flows/root.md", content: SOURCE, extra: 1 }, + // A target that is not one. + { kind: "repository", path: "flows/root.md", content: SOURCE, target: 7 }, + // A recorded selection failure with no selector to replay. + { + kind: "target-failure", + path: "flows/root.md", + content: SOURCE, + failure: { kind: "no-match", matches: [], available: [] }, + }, + // A selection kind a root import never records. + { kind: "workflow", path: "flows/root.md", sourceHash: "abc", content: SOURCE }, + ]; + + for (const value of unreadable) { + const entries = [entry(rootImport(value)), entry(rootClose(succeeded))]; + // The lifecycle refuses to call the terminal authoritative… + expect([JSON.stringify(value), rootOutcome(entries)?.kind]).toEqual([ + JSON.stringify(value), + "damaged", + ]); + // …and admission refuses through that same judgment. + const said = reason(retainedReplay(record(), entries)); + expect([JSON.stringify(value), said.includes("cannot be read by this version")]).toEqual([ + JSON.stringify(value), + true, + ]); + } + + // A settlement that failed records no selection at all, and one whose + // settlement cannot be read records none either. + const failed = [ + entry({ + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { status: "err", error: { message: "gone", name: "Error" } }, + }), + entry(rootClose(succeeded)), + ]; + expect(rootOutcome(failed)?.kind).toBe("damaged"); + expect(retainedReplay(record(), failed).ok).toBe(false); + }); + + // deno-lint-ignore require-yield + it("verifies a recorded selection against the document it recorded", function* () { + const succeeded = { status: "ok", output: "done.\n", value: "done.\n" }; + const unreadable: { says: string; selection: Json }[] = [ + // An exact target the retained document does not offer. Well-formed, and + // a selection that never happened. + { + says: "a target this document has none of", + selection: { + kind: "repository", + path: "flows/root.md", + content: SECTIONED, + target: "Missing", + }, + }, + // The same target, spelled a way canonical encoding does not use. + { + says: "a target that is not canonically encoded", + selection: { + kind: "repository", + path: "flows/root.md", + content: SECTIONED, + target: "stage", + }, + }, + { + says: "a target carrying an encoding the canonical form never has", + selection: { + kind: "repository", + path: "flows/root.md", + content: SECTIONED, + target: "Stage%20", + }, + }, + ]; + + for (const { says, selection } of unreadable) { + const entries = [entry(rootImport(selection)), entry(rootClose(succeeded))]; + expect([says, rootOutcome(entries)?.kind]).toEqual([says, "damaged"]); + expect([says, reason(retainedReplay(record(), entries)).includes("cannot be read")]).toEqual([ + says, + true, + ]); + } + + // Markdown the canonical parser refuses is a document nothing could have + // been selected in. Each boundary gets its own copy of it: `gray-matter` + // caches an empty parse for a source whose frontmatter it just threw on, so + // the refusal is what the *first* read of those bytes answers, and two + // reads of one document would not be two reads of one refusal. + const unparsed = (frontmatter: string): JournalEntry[] => [ + entry( + rootImport({ + kind: "repository", + path: "flows/root.md", + content: `---\n${frontmatter}\n---\n\n# Root\n`, + }), + ), + entry(rootClose(succeeded)), + ]; + expect(rootOutcome(unparsed("returns: [lifecycle"))?.kind).toBe("damaged"); + expect(reason(retainedReplay(record(), unparsed("returns: [admission")))).toContain( + "cannot be read", + ); + + // And the exact target this document does offer, still admitted. + const resolved = [ + entry( + rootImport({ + kind: "repository", + path: "flows/root.md", + content: SECTIONED, + target: "Stage", + }), + ), + entry(rootClose(succeeded)), + ]; + expect(rootOutcome(resolved)?.kind).toBe("outcome"); + expect(admitted(retainedReplay(record(), resolved)).root).toEqual({ + path: "flows/root.md", + source: SECTIONED, + retained: true, + target: "Stage", + }); + }); + + // deno-lint-ignore require-yield + it("holds a recorded selection failure to the failure it re-derives", function* () { + /** The record `"Missing"` actually produces against `SECTIONED`. */ + const derived = { kind: "no-match", selector: "Missing", matches: [], available: ["Stage"] }; + + function failed(failure: Json): JournalEntry[] { + return [ + entry( + rootImport({ + kind: "target-failure", + path: "flows/root.md", + content: SECTIONED, + failure, + }), + ), + entry(rootClose(documentFailure())), + ]; + } + + const forged: { says: string; failure: Json }[] = [ + // A selector is not a failure record, however plausible. + { says: "the selector alone", failure: { selector: "Missing" } }, + { says: "a member short", failure: { kind: "no-match", selector: "Missing", matches: [] } }, + { + says: "a member more", + failure: { ...derived, type: "executablemd.document-target-failure" }, + }, + // The document does fail this selector, but not this way. + { says: "another kind", failure: { ...derived, kind: "invalid-selector" } }, + { says: "another catalog", failure: { ...derived, available: ["Stage", "Other"] } }, + { says: "an emptied catalog", failure: { ...derived, available: [] } }, + { says: "matches nothing matched", failure: { ...derived, matches: ["Stage"] } }, + // A selector whose real outcome is not a failure at all. + { says: "a selector that resolves", failure: { ...derived, selector: "Stage" } }, + ]; + + for (const { says, failure } of forged) { + const entries = failed(failure); + expect([says, rootOutcome(entries)?.kind]).toEqual([says, "damaged"]); + expect([says, reason(retainedReplay(record(), entries)).includes("cannot be read")]).toEqual([ + says, + true, + ]); + } + + // The re-derived record, admitted, and replayed as the same request. + const coherent = failed(derived); + expect(rootOutcome(coherent)).toEqual({ kind: "outcome", status: "failed", reason: HOST }); + expect( + admitted(retainedReplay(record({ status: "failed", stopReason: HOST }), coherent)).root, + ).toEqual({ + path: "flows/root.md", + source: SECTIONED, + retained: true, + target: "Missing", + }); + }); + + // deno-lint-ignore require-yield + it("agrees only with the terminal a failed selection can reach", function* () { + const failure = { + kind: "no-match", + selector: "Missing", + matches: [], + available: ["Stage"], + }; + const selection: Json = { + kind: "target-failure", + path: "flows/root.md", + content: SECTIONED, + failure, + }; + + // A selection that named no target is raised out of the root import, so the + // document never ran. A successful result over it is two histories. + const succeeded = [ + entry(rootImport(selection)), + entry(rootClose({ status: "ok", output: "done.\n", value: "done.\n" })), + ]; + expect(rootOutcome(succeeded)?.kind).toBe("damaged"); + expect(retainedReplay(record(), succeeded).ok).toBe(false); + + // The failed terminal it can reach, with the reason that exact row names. + const failedRun = [entry(rootImport(selection)), entry(rootClose(documentFailure()))]; + expect(rootOutcome(failedRun)).toEqual({ kind: "outcome", status: "failed", reason: HOST }); + expect(retainedReplay(record({ status: "failed", stopReason: HOST }), failedRun).ok).toBe(true); + }); + + // deno-lint-ignore require-yield + it("admits the shapes canonical execution actually writes", function* () { + const message = "the document refused"; + const written: { value: Json; status: WorkflowRunStatus }[] = [ + { value: { status: "ok", output: "done\n", value: "done\n" }, status: "completed" }, + { value: { status: "ok", output: "", value: null }, status: "completed" }, + { + value: { + status: "err", + output: "partial\n", + error: { + name: "Error", + message, + segment: { message, source: "flows/root.md" }, + cause: "because", + errors: [{ name: "Error", message }], + }, + }, + status: "failed", + }, + ]; + + for (const { value, status } of written) { + const history = [ + entry(rootImport({ kind: "repository", path: "flows/root.md", content: SOURCE })), + entry(rootClose(value)), + ]; + const outcome = retainedReplay( + record({ status, ...(status === "failed" ? { stopReason: HOST } : {}) }), + history, + ); + expect([JSON.stringify(value), outcome.ok]).toEqual([JSON.stringify(value), true]); + } + }); +}); + +/** + * The object ids `git hash-object -t blob` gives these exact bytes. + * + * Committed constants rather than a computation, because a test that derived + * them from the same function it is checking would agree with itself. They came + * from Git, and `packages/workflow/tests/git-blob.test.ts` holds the arithmetic + * to Git's own answers and to FIPS 180-4's published ones. + */ +const STAGED = "staged.\n"; +const STAGED_SHA1 = "4eb53b7fd720524e22040757b43e821f817ff0eb"; +const STAGED_SHA256 = "bee278bf729e0ac11f0bd6bf2ec94b1536d51883bd6e426ac32ec0a94afe76ca"; +const UNUSED_SHA1 = "0b42d358385c85db1957138c7a200ad153514209"; +/** Four characters and seven bytes, so Git's framing cannot use the string length. */ +const WIDE = "caf\u00e9 \u{1f409}\n"; +const WIDE_SHA1 = "c4ae463ec163e7b0b1a47ca6f0d5a2205d3643dc"; + +describe("the bundle a completed replay is held to", () => { + const declared: readonly WorkflowComponentEntry[] = [ + { name: "Stage", path: "flows/Stage.md", sourceHash: STAGED_SHA1 }, + { name: "Unused", path: "flows/Unused.md", sourceHash: UNUSED_SHA1 }, + ]; + + /** One retained import of the declared `Stage`, as canonical execution wrote it. */ + function staged(overrides: Record = {}): DurableEvent { + return componentImport("Stage", { + kind: "workflow", + path: "flows/Stage.md", + sourceHash: STAGED_SHA1, + content: STAGED, + ...overrides, + }); + } + + it("grants no authority to import anything", function* () { + const replay = admitted(retainedReplay(record({ components: declared }), completedHistory())); + + // Two installations, and neither offers an execution view: a completed + // replay resolves no name, so there is nothing for one to resolve against. + expect(replay.installations).toHaveLength(2); + for (const installation of replay.installations) { + expect(installation.bundle).toBe(undefined); + expect(installation.components).toBe(undefined); + } + }); + + it("admits an import whose bytes are the object the definition names", function* () { + const replay = admitted(retainedReplay(record({ components: declared }), completedHistory())); + + expect(yield* admit(replay.installations, [RUN_RECORD, staged()])).toBe(undefined); + }); + + it("admits the same under sha256, and where bytes outnumber characters", function* () { + const cases: { format: "sha1" | "sha256"; hash: string; content: string }[] = [ + { format: "sha256", hash: STAGED_SHA256, content: STAGED }, + { format: "sha1", hash: WIDE_SHA1, content: WIDE }, + ]; + + for (const { format, hash, content } of cases) { + const components: readonly WorkflowComponentEntry[] = [ + { name: "Stage", path: "flows/Stage.md", sourceHash: hash }, + ]; + const replay = admitted( + retainedReplay(record({ components, objectFormat: format }), completedHistory()), + ); + const held = componentImport("Stage", { + kind: "workflow", + path: "flows/Stage.md", + sourceHash: hash, + content, + }); + expect([format, yield* admit(replay.installations, [RUN_RECORD, held])]).toEqual([ + format, + undefined, + ]); + + // The same identity, other bytes. Repeating an object id is not being it. + const altered = componentImport("Stage", { + kind: "workflow", + path: "flows/Stage.md", + sourceHash: hash, + content: `${content}ALTERED\n`, + }); + expect(yield* admit(replay.installations, [RUN_RECORD, altered])).toEqual( + expect.any(WorkflowBundleHistoryError), + ); + } + }); + + it("refuses altered bytes under the object id the definition declares", function* () { + const replay = admitted(retainedReplay(record({ components: declared }), completedHistory())); + // The exact declared path and object id, and content that is not that + // object. This is the record a history rewritten in place would carry. + const altered = staged({ content: "ALTERED\n" }); + + const refused = yield* admit(replay.installations, [RUN_RECORD, altered]); + expect(refused).toEqual(expect.any(WorkflowBundleHistoryError)); + // And it says nothing about what it read. + expect(String(refused)).not.toContain("ALTERED"); + expect(String(refused)).not.toContain("flows/Stage.md"); + }); + + it("refuses one recorded under another path, hash, name or kind", function* () { + const replay = admitted(retainedReplay(record({ components: declared }), completedHistory())); + const wrong: DurableEvent[] = [ + staged({ path: "flows/Elsewhere.md" }), + // A different object id, and content that really is that object: the + // definition still does not name it. + componentImport("Stage", { + kind: "workflow", + path: "flows/Stage.md", + sourceHash: UNUSED_SHA1, + content: "never imported.\n", + }), + componentImport("Undeclared", { + kind: "workflow", + path: "flows/Stage.md", + sourceHash: STAGED_SHA1, + content: STAGED, + }), + componentImport("Stage", { + kind: "repository", + path: "flows/Stage.md", + content: STAGED, + }), + staged({ content: 7 }), + ]; + + for (const event of wrong) { + const refused = yield* admit(replay.installations, [RUN_RECORD, event]); + expect(refused).toEqual(expect.any(WorkflowBundleHistoryError)); + } + }); + + it("leaves a declared member the history never imported alone", function* () { + const replay = admitted(retainedReplay(record({ components: declared }), completedHistory())); + + // `Unused` is declared and was never imported. Nothing reads it, nothing + // fetches it, and its absence from the history is not a refusal. + expect(yield* admit(replay.installations, [RUN_RECORD])).toBe(undefined); + }); +}); diff --git a/packages/workflow/tests/software-factory-run-id.test.ts b/packages/workflow/tests/software-factory-run-id.test.ts new file mode 100644 index 000000000..2e259b567 --- /dev/null +++ b/packages/workflow/tests/software-factory-run-id.test.ts @@ -0,0 +1,148 @@ +/** + * Tier WRH — the run id one GitHub issue is addressed by. + * + * The derivation is the whole of "one issue, one run": every host that admits + * the same issue has to arrive at the same 52 characters without asking anybody, + * and no value that moves while the work is going on may take part. The fixed + * vectors below were computed independently of this implementation, which is + * what makes them evidence that a second implementation would agree rather than + * a restatement of what this code happens to do. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + admitFactoryRunSubject, + deriveFactoryRunId, + FactoryRunSubjectError, +} from "@executablemd/workflow/software-factory"; +// The encoder is an internal algorithm rather than a public promise, so the +// RFC 4648 vectors below reach it directly. Everything else in this file goes +// through the published product API, which is what a host actually holds. +import { base32Unpadded, factoryRunIdPreimage } from "../src/software-factory/run-id.ts"; + +/** An opaque node id of the shape GitHub's GraphQL API returns for an issue. */ +const NODE = "I_kwDOABCD12M5abcdef"; + +/** + * Computed outside this implementation, from the specified bytes. + * + * `sha256("github-issue-v1" || 0x00 || authority || 0x00 || nodeId)`, then + * lowercase unpadded RFC 4648 Base32 over all 32 bytes. + */ +const VECTORS = [ + { + authority: "github.com", + issueNodeId: NODE, + runId: "5cktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa", + }, + { + authority: "github.example.com:8443", + issueNodeId: NODE, + runId: "h7dgqsvqzv4p5k2hp2zebemci65qhinpdkxwk5d4caglndiw2xya", + }, + { + authority: "github.com", + issueNodeId: "I_kwDOABCD12M5abcdeg", + runId: "unydmnzwowpjcoua2tyza2topyivbbnm65ddklfjgs5yvlqiwqvq", + }, +] as const; + +/** The canonical authority a subject is admitted under, through the public seam. */ +function authorityOf(value: string): string { + return admitFactoryRunSubject({ authority: value, issueNodeId: NODE }).authority; +} + +function reason(body: () => unknown): string { + try { + body(); + } catch (error) { + if (error instanceof FactoryRunSubjectError) { + return error.reason; + } + throw error; + } + throw new Error("expected a FactoryRunSubjectError"); +} + +describe("the factory run id", () => { + it("derives the specified bytes for known subjects", function* () { + for (const vector of VECTORS) { + const runId = yield* deriveFactoryRunId({ + authority: vector.authority, + issueNodeId: vector.issueNodeId, + }); + expect(runId).toEqual(vector.runId); + expect(runId.length).toEqual(52); + expect(/^[a-z2-7]{52}$/.test(runId)).toEqual(true); + } + }); + + it("derives the same id twice for one subject", function* () { + const once = yield* deriveFactoryRunId({ authority: "github.com", issueNodeId: NODE }); + const again = yield* deriveFactoryRunId({ authority: "GITHUB.COM", issueNodeId: NODE }); + expect(again).toEqual(once); + }); + + it("separates the authority from the node id", function* () { + // Without the NUL separators these two subjects would share a preimage. + const left = yield* deriveFactoryRunId({ authority: "github.com", issueNodeId: "ab" }); + const right = yield* deriveFactoryRunId({ authority: "github.co", issueNodeId: "mab" }); + expect(left).not.toEqual(right); + }); + + it("writes the scheme tag, both separators and both inputs", function* () { + const bytes = new Uint8Array( + factoryRunIdPreimage({ authority: "github.com", issueNodeId: "x" }), + ); + expect(new TextDecoder().decode(bytes)).toEqual("github-issue-v1\0github.com\0x"); + expect([...bytes].filter((byte) => byte === 0).length).toEqual(2); + }); + + it("folds case and keeps a non-default port", function* () { + expect(authorityOf("GitHub.Com")).toEqual("github.com"); + expect(authorityOf("GitHub.Example.COM:8443")).toEqual("github.example.com:8443"); + }); + + it("refuses every part an authority may not carry", function* () { + expect(reason(() => authorityOf(""))).toEqual("authority-empty"); + expect(reason(() => authorityOf("https://github.com"))).toEqual("authority-has-scheme"); + expect(reason(() => authorityOf("user@github.com"))).toEqual("authority-has-userinfo"); + expect(reason(() => authorityOf("github.com/octo"))).toEqual("authority-has-path"); + expect(reason(() => authorityOf("github.com/"))).toEqual("authority-has-path"); + expect(reason(() => authorityOf("github.com?a=b"))).toEqual("authority-has-query"); + expect(reason(() => authorityOf("github.com#top"))).toEqual("authority-has-fragment"); + expect(reason(() => authorityOf("git hub.com"))).toEqual("authority-has-whitespace"); + expect(reason(() => authorityOf("-github.com"))).toEqual("authority-malformed-host"); + expect(reason(() => authorityOf("github.com:https"))).toEqual("authority-malformed-port"); + expect(reason(() => authorityOf("github.com:0"))).toEqual("authority-malformed-port"); + expect(reason(() => authorityOf("github.com:70000"))).toEqual("authority-malformed-port"); + }); + + it("refuses a default port written out, so one deployment has one spelling", function* () { + expect(reason(() => authorityOf("github.com:443"))).toEqual("authority-default-port"); + }); + + it("compares a node id byte for byte", function* () { + const subject = admitFactoryRunSubject({ authority: "github.com", issueNodeId: "Ab_C" }); + expect(subject.issueNodeId).toEqual("Ab_C"); + expect( + reason(() => admitFactoryRunSubject({ authority: "github.com", issueNodeId: "" })), + ).toEqual("node-id-empty"); + expect( + reason(() => admitFactoryRunSubject({ authority: "github.com", issueNodeId: "a\0b" })), + ).toEqual("node-id-has-nul"); + }); + + it("encodes Base32 to the RFC 4648 alphabet without padding", function* () { + // RFC 4648 §10 test vectors, lowercased and unpadded. + const encode = (text: string) => base32Unpadded(new TextEncoder().encode(text)); + expect(encode("")).toEqual(""); + expect(encode("f")).toEqual("my"); + expect(encode("fo")).toEqual("mzxq"); + expect(encode("foo")).toEqual("mzxw6"); + expect(encode("foob")).toEqual("mzxw6yq"); + expect(encode("fooba")).toEqual("mzxw6ytb"); + expect(encode("foobar")).toEqual("mzxw6ytboi"); + }); +}); diff --git a/packages/workflow/tests/support/remote-lifecycle-host.ts b/packages/workflow/tests/support/remote-lifecycle-host.ts new file mode 100644 index 000000000..543809183 --- /dev/null +++ b/packages/workflow/tests/support/remote-lifecycle-host.ts @@ -0,0 +1,450 @@ +/** + * A scripted host for the remote lifecycle provider. + * + * Stands in for reaching an owner, not for the owner: what it proves is what + * the provider decides before and after a command — which lock authorizes, + * which run is addressed, what reaches the transport at all. Whether an owner + * transaction is atomic, whether admission contends, and whether an association + * survives hibernation are owner facts and are proved against a real Durable + * Object instead. + */ + +import { ensure, Err, Ok, type Operation, type Result } from "effection"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import type { + RemoteBeginCommand, + RemoteForkContinuation, + RemoteBegun, + RemoteExecutorConnection, + RemoteForkCommit, + RemoteForkPart, + RemoteLifecycleAnswer, + RemoteLifecycleLink, +} from "../../src/remote/lifecycle-link.ts"; +import type { RemoteLifecycleHost } from "../../src/remote/lifecycle.ts"; +import type { + RemoteForkSource, + RemoteFrontierSnapshot, + RemoteReadPlane, +} from "../../src/remote/read.ts"; +import type { WorkflowRunDatabase } from "../../src/storage/api.ts"; +import type { WorkflowForkRequest } from "../../src/lifecycle/execution.ts"; +import type { + DocumentExecutionCompletion, + DocumentExecutionRecord, + WorkflowRunRecord, +} from "../../src/storage/record.ts"; +import { + WorkflowRequestError, + WorkflowRunConflictError, + WorkflowTransactionError, +} from "../../src/storage/errors.ts"; +import type { RemoteWorkspaceLink } from "../../src/remote/database.ts"; +import type { RemoteRetainedAnswer } from "../../src/remote/answer-link.ts"; + +export const RUN_ID = "5cktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; +export const ROOT = "a".repeat(64); + +/** What this host does when it is asked, and what it records while it does. */ +export interface Script { + /** Every command the provider actually sent. */ + readonly asked?: string[]; + /** Every run an acquisition was opened for. */ + readonly opened?: string[]; + /** Every run an acquisition was given back for at scope exit. */ + readonly closed?: string[]; + /** Every run whose connection was ended early, before its scope did. */ + readonly retired?: string[]; + /** Answer admission with a live executor rather than a connection. */ + readonly admit?: "already-running"; + /** Answer a begin with one of the run's own conditions. */ + readonly begin?: "cancelled" | "resume-failed"; + /** Report that recovery closed this execution on the way in. */ + readonly recovered?: string; + /** Answer a begin with a run that kept its terminal state. */ + readonly replay?: boolean; + /** Answer a fork commit with a conflict. */ + readonly forkConflict?: readonly string[]; + /** The source snapshot a fork reads, when a test supplies one. */ + readonly source?: RemoteForkSource; + /** Every fork part the provider offered, in the order it offered them. */ + readonly staged?: RemoteForkPart[]; + /** Every fork commit the provider asked for. */ + readonly commits?: RemoteForkCommit[]; + /** Every command identity the provider addressed, in order. */ + readonly commands?: string[]; + /** Every command identity one staged fork part was offered under. */ + readonly parts?: string[]; + /** Every retrieval value a begin carried. */ + readonly retrievals?: (unknown | null)[]; + /** Answer every fork commit with a definitive conflict. */ + readonly forkRefuses?: boolean; + /** Answer the first fork commit by saying its transfer is not here. */ + readonly needsTransfer?: Set; + /** + * Held open until a test releases it, so a begin can be caught in flight. + * + * The owner has already decided by the time this is reached: what is caught + * is the answer on its way back, which is the only moment an interrupted + * mutation is genuinely ambiguous. + */ + readonly gate?: { wait(): Operation }; + /** The same, for a fork's final mutation: decided, and answer in flight. */ + readonly commitGate?: { wait(): Operation }; + /** The same, for reading the source — where nothing has been mutated yet. */ + readonly sourceGate?: { wait(): Operation }; + /** The same, for offering a staged part. */ + readonly stageGate?: { wait(): Operation }; + /** Every execution this owner actually began, as opposed to re-answered. */ + readonly decided?: string[]; + /** Every run whose source was resolved. */ + readonly sourced?: string[]; + /** Whether a destination already holds this fork, so no source is needed. */ + readonly continues?: boolean; + /** Command identities whose first answer is lost after the owner commits. */ + readonly loseAnswer?: Set; + /** What an owner decided for a command identity, once it has decided. */ + readonly committed?: Map; + /** + * Every identity this host was asked to carry twice on one connection. + * + * The real client refuses that before the owner sees it, so a provider that + * reuses an answered name gets a channel failure instead of a decision. This + * host holds the same rule, and records every violation so a test can say + * that none happened rather than only that the call came out right. + */ + readonly reused?: string[]; +} + +export function record(): WorkflowRunRecord { + return { + runId: RUN_ID, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +export function frontier(): RemoteFrontierSnapshot { + return { + record: record(), + retrieval: undefined, + workspaceRootId: ROOT, + journalEventId: null, + entries: [], + }; +} + +function execution(executionId: string): DocumentExecutionRecord { + return { executionId, startedAt: "2026-01-01T00:00:00.000Z" }; +} + +function begun(executionId: string, script: Script = {}): RemoteBegun { + return { + frontier: frontier(), + execution: execution(executionId), + replay: script.replay === true, + recovered: script.recovered === undefined ? null : execution(script.recovered), + }; +} + +/** A link that answers the one read the provider makes of it. */ +function link(): RemoteWorkspaceLink { + const unsupported = () => { + throw new WorkflowRequestError("this scripted link answers no such operation"); + }; + return { + // deno-lint-ignore require-yield + *frontierSnapshot(): Operation { + return frontier(); + }, + // deno-lint-ignore require-yield + *pendingAnswer(): Operation> { + // These are lifecycle scripts. Nothing is delivered to their runs, and a + // link that answered otherwise would end a wait no script reaches. + return Ok(undefined); + }, + // deno-lint-ignore require-yield + *frontier(): Operation { + return unsupported(); + }, + // deno-lint-ignore require-yield + *commit(): Operation { + return unsupported(); + }, + // deno-lint-ignore require-yield + *replaceRetrieval(): Operation { + return unsupported(); + }, + // deno-lint-ignore require-yield + *readExecutions(): Operation { + return unsupported(); + }, + // deno-lint-ignore require-yield + *open(): Operation { + return unsupported(); + }, + // deno-lint-ignore require-yield + *invocationSnapshot(): Operation { + return unsupported(); + }, + // deno-lint-ignore require-yield + *root(): Operation { + return unsupported(); + }, + // deno-lint-ignore require-yield + *content(): Operation { + return unsupported(); + }, + }; +} + +function lifecycle(script: Script): RemoteLifecycleLink { + let minted = 0; + // One connection's own correlation ids. The real client keeps exactly this + // set — the ids it is waiting on and the ids it has already settled — and + // refuses to send either again, so an identity that has been answered can + // never carry another question on this connection. + const spoken = new Set(); + function reused(commandId: string): Result | undefined { + if (!spoken.has(commandId)) { + spoken.add(commandId); + return undefined; + } + script.reused?.push(commandId); + // What `OwnerConnection.ask()` raises for a duplicate id, as the adapter + // translates it: the command never reaches an owner, and the caller is + // told only that the channel could not carry it. + return Err(new WorkflowTransactionError("this run's owner could not be reached.")); + } + return { + // deno-lint-ignore require-yield + *begin(request: RemoteBeginCommand): Operation>> { + const again = reused>(request.commandId); + if (again !== undefined) { + return again; + } + script.asked?.push("begin"); + script.commands?.push(request.commandId); + script.retrievals?.push(request.retrieval ?? null); + if (script.loseAnswer?.has(request.commandId) === true) { + // The owner committed and the answer never arrived. + script.loseAnswer.delete(request.commandId); + script.decided?.push(request.executionId); + script.committed?.set(request.commandId, begun(request.executionId, script)); + return Err(new WorkflowTransactionError("the connection ended before it answered.")); + } + const already = script.committed?.get(request.commandId); + if (already !== undefined) { + // The same question again: the decision it already made. + return Ok({ kind: "performed", value: already }); + } + if (script.begin !== undefined) { + return Ok({ kind: "refused", refusal: script.begin }); + } + minted += 1; + const decided = begun(request.executionId, script); + script.decided?.push(request.executionId); + // Decided, and retained under the identity it was asked by, before the + // answer starts back. A gate here catches the one moment that matters: + // the owner has committed and the caller does not know it yet. + script.committed?.set(request.commandId, decided); + if (script.gate !== undefined) { + yield* script.gate.wait(); + } + return Ok({ kind: "performed", value: decided }); + }, + // deno-lint-ignore require-yield + *settle( + commandId: string, + _completion: DocumentExecutionCompletion, + ): Operation> { + const again = reused(commandId); + if (again !== undefined) { + return again; + } + script.asked?.push("settle"); + script.commands?.push(commandId); + return Ok(frontier()); + }, + // deno-lint-ignore require-yield + *cancel(commandId: string): Operation>> { + const again = reused>(commandId); + if (again !== undefined) { + return again; + } + script.asked?.push("cancel"); + script.commands?.push(commandId); + if (script.loseAnswer?.has(commandId) === true) { + script.loseAnswer.delete(commandId); + return Err(new WorkflowTransactionError("the connection ended before it answered.")); + } + return Ok({ kind: "performed", value: record() }); + }, + *stageForkPart(commandId: string, part: RemoteForkPart): Operation> { + const again = reused(commandId); + if (again !== undefined) { + return again; + } + script.asked?.push("fork-stage"); + script.staged?.push(part); + script.parts?.push(commandId); + if (script.stageGate !== undefined) { + yield* script.stageGate.wait(); + } + return Ok(undefined); + }, + // deno-lint-ignore require-yield + // deno-lint-ignore require-yield + *continueFork( + continuation: RemoteForkContinuation, + ): Operation | "absent">> { + const again = reused | "absent">(continuation.commandId); + if (again !== undefined) { + return again; + } + script.asked?.push("fork-continue"); + script.commands?.push(continuation.commandId); + const held = script.continues; + if (held === undefined) { + // Nothing there to continue, which sends the caller to the source. + return Ok("absent"); + } + return Ok({ kind: "performed", value: begun(continuation.executionId, script) }); + }, + + *commitFork( + commit: RemoteForkCommit, + ): Operation | "needs-transfer">> { + const again = reused | "needs-transfer">(commit.commandId); + if (again !== undefined) { + return again; + } + script.asked?.push("fork"); + script.commits?.push(commit); + if (script.forkConflict !== undefined) { + return Err(new WorkflowRequestError("this destination is another run")); + } + void minted; + script.commands?.push(commit.commandId); + if (script.loseAnswer?.has(commit.commandId) !== true) { + // Everything below either re-answers a decision this owner already + // made or makes one now. A gate belongs after that, for the same + // reason a begin's does. + const already = script.committed?.get(commit.commandId); + const answering = + script.needsTransfer?.has(commit.commandId) === true || script.forkRefuses === true; + if (already === undefined && !answering) { + script.decided?.push(commit.executionId); + script.committed?.set(commit.commandId, begun(commit.executionId, script)); + } + if (script.commitGate !== undefined) { + yield* script.commitGate.wait(); + } + } + if (script.loseAnswer?.has(commit.commandId) === true) { + script.loseAnswer.delete(commit.commandId); + // The mutation committed unless this scenario says it never did. + if (script.needsTransfer?.has(commit.commandId) !== true) { + script.decided?.push(commit.executionId); + script.committed?.set(commit.commandId, begun(commit.executionId, script)); + } + return Err(new WorkflowTransactionError("the connection ended before it answered.")); + } + if (script.needsTransfer?.has(commit.commandId) === true) { + script.needsTransfer.delete(commit.commandId); + return Ok | "needs-transfer">("needs-transfer"); + } + if (script.forkRefuses === true) { + return Err(new WorkflowRunConflictError(commit.runId, ["definition"])); + } + const already = script.committed?.get(commit.commandId); + if (already !== undefined) { + return Ok({ kind: "performed", value: already }); + } + return Ok({ kind: "performed", value: begun(commit.executionId, script) }); + }, + }; +} + +/** The provider's host, scripted. */ +export function installedHost(script: Script): RemoteLifecycleHost { + let executions = 0; + let commands = 0; + return { + *admit(runId: string): Operation> { + if (script.admit === "already-running") { + return Ok("already-running"); + } + script.opened?.push(runId); + const connection: RemoteExecutorConnection = { + link: link(), + lifecycle: lifecycle(script), + // deno-lint-ignore require-yield + *close(): Operation { + script.retired?.push(runId); + }, + }; + yield* ensureClosed(script, runId); + return Ok(connection); + }, + // deno-lint-ignore require-yield + *source(runId: string): Operation> { + script.sourced?.push(runId); + const held = script.source; + if (held === undefined) { + return Err(new WorkflowRequestError("this scripted host holds no source")); + } + return Ok({ + runId, + // deno-lint-ignore require-yield + *inspect(): Operation> { + throw new WorkflowRequestError("this scripted plane answers no inspection"); + }, + // deno-lint-ignore require-yield + *history(): Operation> { + throw new WorkflowRequestError("this scripted plane answers no history"); + }, + *forkSource(): Operation> { + if (script.sourceGate !== undefined) { + yield* script.sourceGate.wait(); + } + return Ok(held); + }, + }); + }, + // deno-lint-ignore require-yield + *stage( + _request: WorkflowForkRequest, + _source: RemoteForkSource, + _head: { readonly runRecord: DurableEvent; readonly rootImport: DurableEvent }, + ): Operation> { + return Err(new WorkflowRequestError("this scripted host stages nothing")); + }, + ids: { + execution: () => { + executions += 1; + return `execution-${executions}`; + }, + command: () => { + commands += 1; + return `command-${commands}`; + }, + }, + }; +} + +function* ensureClosed(script: Script, runId: string): Operation { + yield* ensure(function* () { + script.closed?.push(runId); + }); +} diff --git a/packages/workflow/tests/support/remote-owner-script.ts b/packages/workflow/tests/support/remote-owner-script.ts new file mode 100644 index 000000000..4389b4b1c --- /dev/null +++ b/packages/workflow/tests/support/remote-owner-script.ts @@ -0,0 +1,415 @@ +/** + * One owner, scripted at the wire, and what a document needs around it. + * + * Shared because two suites ask the same question of the same production stack + * from two different heights: the configured public host, and the runner it + * assembles. A second copy of this owner would be a second protocol, and the + * day the two disagreed one of those suites would be proving nothing. + */ + +import { type Operation, scoped, until } from "effection"; +import { mkdir, writeFile } from "node:fs/promises"; +import { collect, execute, inlineSource } from "@executablemd/core"; +import { API, useHostFiles } from "@executablemd/runtime"; +import type { HostFilesEvent } from "@executablemd/runtime"; +import type { Json } from "@executablemd/durable-streams"; +import { decodeBase64, encodeBase64 } from "../../src/cloudflare/encoding.ts"; +import type { OwnerSocket, SocketListener } from "../../src/remote/client.ts"; +import { captureWorkspace, type CapturedWorkspace } from "../../src/remote/materialize.ts"; +import { runnerFiles, useRunnerTrees } from "../../src/deno/remote-files.ts"; +import type { WorkflowRunDatabase } from "../../src/storage/api.ts"; + +/** The run every scripted owner here answers about. */ +export const RUN_ID = "5cktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; + +/** How many entries one anchored page carries, so paging is exercised at all. */ +const JOURNAL_PAGE = 2; + +/** + * One owner, scripted at the wire. + * + * Everything above it is production code: the real client, the real lifecycle, + * the real database handle, the real coordinator and the real runner + * facilities. What this stands in for is the object that would answer — so what + * a test can say afterwards is what the runner actually sent it, and what it + * committed. + */ +export interface ScriptedRetention { + /** Repositories this owner already retains, as its snapshot reports them. */ + readonly repositories?: readonly Record[]; + /** Agent sessions this owner already retains. */ + readonly agentSessions?: readonly Record[]; +} + +export function scriptedOwner(captured: CapturedWorkspace, retained: ScriptedRetention = {}) { + const sent: Record[] = []; + const answered: Record[] = []; + const commits: Record[] = []; + let currentRoot = captured.root.rootId; + let currentManifest = captured.root.manifest; + let refusal: string | undefined; + let lost = false; + // What this owner has accepted, as it would then hold it. A commit that is + // performed moves the root, keeps the bytes it was staged, and merges the + // mappings it validated — so a later coherent snapshot answers with what the + // run actually became rather than with what it started as. + const blobs = new Map(captured.blobs); + const manifests = new Map(); + for (const [digest, content] of captured.contents) { + manifests.set(digest, content.manifestBytes); + } + const staged = new Map(); + /** + * The filtered journal, as the owner keeps it. + * + * Each entry carries the id this owner minted for it and the Workspace root + * the transaction that appended it selected — the publication's proposed root + * when it published one, and the root the transaction expected when it did + * not. A root and a mapping beside an empty journal is not a state a real + * owner can reach, so this keeps all three or none. + */ + const journal: { eventId: string; record: string; workspaceRootId: string }[] = []; + let minted = 0; + const repositories = new Map>(); + const worktrees = new Map>(); + const sessions = new Map>(); + for (const stored of retained.repositories ?? []) { + const record = stored["record"]; + if (record !== null && typeof record === "object") { + repositories.set(String(Reflect.get(record, "name")), stored); + } + } + for (const record of retained.agentSessions ?? []) { + sessions.set(String(record["sessionKey"]), record); + } + + function frontier(): Record { + return { + record: { + runId: RUN_ID, + definition: { + version: 1, + kind: "git", + objectFormat: "sha1", + objectId: "0".repeat(40), + rootDocumentPath: "README.md", + }, + base: "main", + props: {}, + status: "running", + createdAt: "2026-09-10T00:00:00.000Z", + updatedAt: "2026-09-10T00:00:00.000Z", + }, + retrieval: null, + workspaceRootId: currentRoot, + journalEventId: journal.at(-1)?.eventId ?? null, + }; + } + + function begun(executionId: string): Record { + return { + frontier: frontier(), + // Exactly the members an execution that has not stopped declares. + execution: { executionId, startedAt: "2026-09-10T00:00:01.000Z" }, + replay: false, + recovered: null, + }; + } + + function answer(request: Record): Record { + const command = request["command"]; + if (command === "open" || command === "frontier") { + return { outcome: "performed", value: frontier() }; + } + if (command === "begin") { + // A lifecycle answer is one of three fields and never two: the value, a + // refusal, or the immutable fields a creation conflicts on. + return { + outcome: "performed", + value: { + conflict: null, + refusal: null, + value: begun(String(request["executionId"])), + }, + }; + } + if (command === "mappings") { + return { + outcome: "performed", + value: { + workspaceRootId: currentRoot, + journalEventId: journal.at(-1)?.eventId ?? null, + repositories: [...repositories.values()], + worktrees: [...worktrees.values()], + agentSessions: [...sessions.values()], + }, + }; + } + if (command === "journal") { + // One anchored page per request, continuing exactly where the client + // says it is. The anchor is the terminal event the reader started from, + // and the page is capped at it however far the journal has since run: + // a page that ran past the anchor would hand a reader history that was + // not there when it decided where the end was. + // + // A cursor is admitted only where the real owner admits one — strictly + // before the anchor, which `readJournalPage()` enforces by refusing + // `afterSequence >= anchorSequence`. A cursor *at* the anchor is a reader + // asking for a page after the end of its own snapshot, and answering it + // with an empty page here would let a client pass this suite while the + // owner it will actually talk to refuses. + const anchorEventId = String(request["anchorEventId"] ?? ""); + const afterEventId = request["afterEventId"] ?? null; + const anchor = journal.findIndex((entry) => entry.eventId === anchorEventId); + if (anchor === -1) { + throw new Error("the runner anchored a read to an event this owner never minted"); + } + const after = + afterEventId === null ? -1 : journal.findIndex((entry) => entry.eventId === afterEventId); + if (afterEventId !== null && after === -1) { + throw new Error("the runner asked to continue from an event this owner never minted"); + } + if (after >= anchor) { + throw new Error("a journal cursor is outside its anchored snapshot"); + } + const from = after + 1; + const page = journal.slice(from, Math.min(from + JOURNAL_PAGE, anchor + 1)); + return { + outcome: "performed", + value: { + anchorEventId, + afterEventId, + entries: page.map((entry, index) => ({ + eventId: entry.eventId, + previousEventId: index === 0 ? afterEventId : (page[index - 1]?.eventId ?? null), + record: entry.record, + workspaceRootId: entry.workspaceRootId, + })), + done: from + page.length >= anchor + 1, + }, + }; + } + if (command === "root") { + return { + outcome: "performed", + value: { workspaceRootId: currentRoot, manifest: currentManifest }, + }; + } + if (command === "content") { + const digest = String(request["digest"]); + const bytes = request["kind"] === "manifest" ? manifests.get(digest) : blobs.get(digest); + if (bytes === undefined) { + throw new Error("asked for content this owner does not hold"); + } + return { + outcome: "performed", + value: { + kind: request["kind"], + digest, + size: bytes.length, + bytes: encodeBase64(bytes), + }, + }; + } + if (command === "stage") { + const encoded = String(request["bytes"] ?? ""); + const bytes = decodeBase64(encoded); + // Held until a commit adopts them, exactly as staged bytes are: a + // proposal that is refused leaves nothing behind. + staged.set(`${String(request["kind"])}:${String(request["digest"])}`, bytes); + return { + outcome: "performed", + value: { kind: request["kind"], digest: request["digest"], size: bytes.length }, + }; + } + if (command === "settle") { + return { outcome: "performed", value: { status: "completed" } }; + } + commits.push(request); + const publication = request["publication"]; + // Scripted for the Workspace proposal rather than for every append: an + // owner that refused the run's own journal rows would end the document + // before it ever reached the effect under test. + const proposing = publication !== null && publication !== undefined; + if (lost && proposing) { + return { outcome: "lost" }; + } + if (refusal !== undefined && proposing) { + return { outcome: "refused", refusal }; + } + const events = Array.isArray(request["events"]) ? request["events"] : []; + // The owner publishes what it validated, and everything moves with it: the + // pointer, the content it was staged, and the mappings it accepted. A + // later snapshot then answers with the run as it now is. + if (publication !== null && publication !== undefined) { + currentRoot = String(Reflect.get(publication, "proposedWorkspaceRootId")); + currentManifest = String(Reflect.get(publication, "proposedManifest")); + const held = Reflect.get(publication, "content"); + for (const piece of Array.isArray(held) ? held : []) { + const kind = String(Reflect.get(piece, "kind")); + const digest = String(Reflect.get(piece, "digest")); + const bytes = staged.get(`${kind}:${digest}`); + if (bytes === undefined) { + throw new Error(`the runner published ${kind} ${digest} without staging it`); + } + (kind === "manifest" ? manifests : blobs).set(digest, bytes); + } + } + for (const mapping of Array.isArray(request["mappings"]) ? request["mappings"] : []) { + const kind = String(Reflect.get(mapping, "kind")); + const record = Reflect.get(mapping, "record"); + if (record === null || typeof record !== "object") { + throw new Error("the runner proposed a mapping with no record"); + } + if (kind === "repository") { + // Retained the way a snapshot reports one: the record, and the locator + // beside it, which the owner keeps out of the record itself. + repositories.set(String(Reflect.get(record, "name")), { + record, + locator: Reflect.get(mapping, "locator") ?? null, + }); + } + if (kind === "worktree") { + worktrees.set( + `${String(Reflect.get(record, "repositoryName"))}/${String(Reflect.get(record, "name"))}`, + record as Record, + ); + } + if (kind === "agent-session") { + sessions.set(String(Reflect.get(record, "sessionKey")), record as Record); + } + } + staged.clear(); + // Appended in the same step that moved the root and merged the mappings: + // the events, each carrying the root this transaction selected. What is + // answered is what this transaction minted and nothing else — one identity + // per proposed event, in order, and an empty list for a commit that + // proposed none, which is what a mappings-only intent is. + const appended: string[] = []; + for (const record of events) { + minted += 1; + const eventId = `owner-event-${minted}`; + appended.push(eventId); + journal.push({ eventId, record: String(record), workspaceRootId: currentRoot }); + } + return { + outcome: "performed", + value: { workspaceRootId: currentRoot, journalEventIds: appended }, + }; + } + + const listeners = new Map>(); + const socket: OwnerSocket = { + send(data: string): void { + const request: Record = JSON.parse(data); + sent.push(request); + const response = answer(request); + // What it answered, beside what it was asked. A test asserting on the + // shape of an answer should read the answer rather than re-derive it. + answered.push({ id: request["id"], command: request["command"], ...response }); + if (response["outcome"] === "lost") { + for (const listener of listeners.get("close") ?? []) { + listener({}); + } + return; + } + for (const listener of listeners.get("message") ?? []) { + listener({ data: JSON.stringify({ id: request["id"], ...response }) }); + } + }, + close(): void {}, + addEventListener(type, listener): void { + const found = listeners.get(type) ?? new Set(); + found.add(listener); + listeners.set(type, found); + }, + removeEventListener(type, listener): void { + listeners.get(type)?.delete(listener); + }, + }; + + return { + socket, + sent, + answered, + commits, + get currentRoot(): string { + return currentRoot; + }, + /** The Agent-session mappings this owner retains, as it would report them. */ + agentSessions(): readonly Record[] { + return [...sessions.values()].map((record) => ({ ...record })); + }, + /** The filtered journal this owner retains, as it would answer a read. */ + entries(): readonly { eventId: string; record: string; workspaceRootId: string }[] { + return journal.map((entry) => ({ ...entry })); + }, + refuse(reason: string): void { + refusal = reason; + }, + lose(): void { + lost = true; + }, + }; +} + +/** A small starting tree, captured so the scripted owner can serve it. */ +export function* startingTree(): Operation { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); + const root = yield* trees.create("source"); + yield* until(writeFile(`${root}/README.md`, "starting\n", { mode: 0o644 })); + yield* until(mkdir(`${root}/docs`, { mode: 0o755 })); + return yield* captureWorkspace( + files, + (logical) => (logical === "/" ? root : `${root}${logical}`), + (reason) => { + throw new Error(reason); + }, + ); +} + +/** + * The ambient host filesystem a runtime entrypoint installs, watched. + * + * The real provider rather than a stand-in, at the position a host installs it + * and with a working directory a workflow run must never resolve against. What + * a test says afterwards is whether a document reached it at all. + */ +export function* useHostSpy(): Operation { + const seen: HostFilesEvent[] = []; + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return "/nowhere-the-workflow-may-reach"; + }, + }, + { at: "min" }, + ); + yield* useHostFiles({ observe: (event) => seen.push(event) }); + return seen; +} + +/** + * The commits that proposed a Workspace, out of everything the owner was asked + * to commit. + * + * A run's journal lives on its owner, so every ordinary append — the root + * import, a component import, the terminal — reaches it as a commit of its own. + * What a Workspace effect adds to one is the publication, and that is what + * these tests are counting. + */ +export function published(commits: readonly Record[]): Record[] { + return commits.filter((intent) => { + const publication = intent["publication"]; + return publication !== null && publication !== undefined; + }); +} + +/** One authored document, executed as this run's root inside the attachment. */ +export function document(source: string, database: WorkflowRunDatabase): Operation { + return scoped(function* () { + return yield* collect(yield* execute({ ...inlineSource(source), stream: database.journal })); + }); +} diff --git a/packages/workflow/tests/support/restart-child.ts b/packages/workflow/tests/support/restart-child.ts index 54fc21ca7..a43cef602 100644 --- a/packages/workflow/tests/support/restart-child.ts +++ b/packages/workflow/tests/support/restart-child.ts @@ -26,8 +26,8 @@ import { appendFile } from "node:fs/promises"; import process from "node:process"; -import { durableCall, durableRun } from "@executablemd/durable-streams"; -import type { Workflow } from "@executablemd/durable-streams"; +import { createDurableOperation, durableCall, durableRun } from "@executablemd/durable-streams"; +import type { Json, Workflow } from "@executablemd/durable-streams"; import { main, until } from "effection"; import { WorkflowLifecycle, WorkflowStorageError } from "../../mod.ts"; import { useWorkflowRunHost } from "../../deno.ts"; @@ -40,14 +40,31 @@ const DEFINITION = { rootDocumentPath: "workflows/release.md", } as const; +/** What this run's document is, as its own history records it. */ +const SOURCE = "# Release\n"; + /** * Three durable operations, so replay has an order to preserve. * * Each one's side effect is a line in the marker file, which is what makes * "did this run again" observable from outside the process. + * + * The root's own import comes first and the result is the document result + * canonical execution returns, because a workflow run's journal is a document + * execution's: the lifecycle reads the root import and that result to decide + * what the run became, and a history missing either is one no execution + * produces. Both are durable, so a second process restores them rather than + * recording them again — which is the thing this file exists to observe. */ -function work(marker: string): () => Workflow { - return function* (): Workflow { +function work(marker: string): () => Workflow { + return function* (): Workflow { + yield createDurableOperation( + { type: "import_component", name: "__root__" }, + // deno-lint-ignore require-yield + function* (): Workflow { + return { kind: "repository", path: DEFINITION.rootDocumentPath, content: SOURCE }; + }, + ); const first = yield* durableCall("first", function* () { yield* until(appendFile(marker, "first\n")); return "one"; @@ -60,7 +77,8 @@ function work(marker: string): () => Workflow { yield* until(appendFile(marker, "third\n")); return "three"; }); - return [first, second, third].join(","); + const rendered = [first, second, third].join(","); + return { status: "ok", output: rendered, value: rendered }; }; } @@ -97,7 +115,11 @@ main(function* () { } const { database, execution } = opened.value; - const value = yield* durableRun(work(marker), { stream: database.journal }); + const result = yield* durableRun(work(marker), { stream: database.journal }); + const value = + typeof result === "object" && result !== null && !Array.isArray(result) + ? Reflect.get(result, "value") + : undefined; const settled = yield* transitions.settle(executorLock, { executionId: execution.executionId, diff --git a/packages/workflow/tests/workflow-export.test.ts b/packages/workflow/tests/workflow-export.test.ts index e327b27e5..3f0552b5b 100644 --- a/packages/workflow/tests/workflow-export.test.ts +++ b/packages/workflow/tests/workflow-export.test.ts @@ -460,7 +460,7 @@ function base64(content: Uint8Array): string { function artifactWorkspace( artifact: VerifiedXmdArtifact, rootId: string, -): { nodes: Map; entries: WorkspaceRootEntry[] } { +): { nodes: Map; entries: readonly WorkspaceRootEntry[] } { const root = artifact.roots.find((candidate) => candidate.rootId === rootId); if (root === undefined) { throw new Error(`the artifact holds no Workspace root ${rootId}`); diff --git a/packages/workflow/tests/workflow-lifecycle-control.test.ts b/packages/workflow/tests/workflow-lifecycle-control.test.ts index a20d634fe..03ed71af5 100644 --- a/packages/workflow/tests/workflow-lifecycle-control.test.ts +++ b/packages/workflow/tests/workflow-lifecycle-control.test.ts @@ -174,10 +174,26 @@ describe("Tier WLC — cancellation and deletion", () => { transitions, { runId: "closed-1", action: "start", creation: creation() }, function* (begun) { + // The history canonical execution writes: the root's own import, then + // the document result it returned. Both halves matter — the result's + // own status is what says whether the document completed or failed, + // and an ordinary result is one a run records after importing. + yield* begun.database.journal.append({ + type: "yield", + coroutineId: "root", + description: { type: "import_component", name: "__root__" }, + result: { + status: "ok", + value: { kind: "repository", path: "workflow.md", content: "rendered" }, + }, + }); yield* begun.database.journal.append({ type: "close", coroutineId: "root", - result: { status: "ok", value: "rendered" }, + result: { + status: "ok", + value: { status: "ok", output: "rendered", value: "rendered" }, + }, }); }, ); diff --git a/packages/workflow/tests/workflow-run-journal.test.ts b/packages/workflow/tests/workflow-run-journal.test.ts index f541a0acd..dc469a450 100644 --- a/packages/workflow/tests/workflow-run-journal.test.ts +++ b/packages/workflow/tests/workflow-run-journal.test.ts @@ -1594,6 +1594,7 @@ describe("Tier WJ — surviving a process", () => { // same sequence, read by a process that never saw them written. expect(after.events).toEqual(before.events); expect(after.events.map((event: { name?: string }) => event.name)).toEqual([ + "__root__", "first", "second", "third", diff --git a/packages/workflow/tests/workflow-suspension-answer.test.ts b/packages/workflow/tests/workflow-suspension-answer.test.ts index 7adb4d014..1de23679f 100644 --- a/packages/workflow/tests/workflow-suspension-answer.test.ts +++ b/packages/workflow/tests/workflow-suspension-answer.test.ts @@ -22,6 +22,7 @@ import { expect } from "@executablemd/test-support/expect"; import { call, type Operation, race, scoped } from "effection"; import { DatabaseSync } from "node:sqlite"; import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import type { JsonObject } from "@executablemd/core"; import { collect, inlineSource, registerComponents } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import type { Result } from "effection"; @@ -190,9 +191,14 @@ function waiting(): Operation { return suspendFor({ request: REQUEST, responseSchema: SCHEMA }); } +/** The same, for a suite that needs a wait to retain a different schema. */ +function waitingFor(responseSchema: JsonObject): () => Operation { + return () => suspendFor({ request: REQUEST, responseSchema }); +} + /** One run left suspended at its wait, and the identity of that wait. */ -function* suspendedRun(root: string): Operation { - const attempted = yield* attempt(root, "start", waiting); +function* suspendedRun(root: string, body: () => Operation = waiting): Operation { + const attempted = yield* attempt(root, "start", body); const id = attempted.notice?.suspensionId; if (id === undefined) { throw new Error("the fixture document did not reach a durable wait"); @@ -543,3 +549,112 @@ function attemptEvents(root: string): Operation { } }); } + +/** + * Tier WAD — the schema semantics the local boundary judges by. + * + * The same judgment a run's owner makes, reached the way a person reaches it: + * a real run in a real file, and the production delivery provider. What is + * being proved is not the validator — `packages/core` owns that — but that this + * boundary is the one that uses it, on the cases where a careless adapter would + * differ. + */ +describe("what the local delivery boundary admits", () => { + it("keeps a literal that carries `format`, and refuses an altered one", function* () { + const root = yield* useStorageRoot(); + const id = yield* suspendedRun(root, waitingFor({ const: { format: "email", x: 1 } })); + + const exact = yield* deliver(root, { + suspensionId: id, + value: { format: "email", x: 1 }, + secretDetection: false, + }); + expect([exact.ok, exact.ok === false && String(exact.error)]).toEqual([true, false]); + expect(answerRows(root)[0]?.["answer"]).toBe(JSON.stringify({ format: "email", x: 1 })); + + // A second run, because the first now holds an answer. + const other = yield* useStorageRoot(); + const second = yield* suspendedRun(other, waitingFor({ const: { format: "email", x: 1 } })); + const altered = yield* deliver(other, { + suspensionId: second, + value: { x: 1 }, + secretDetection: false, + }); + expect(altered.ok).toBe(false); + expect(answerRows(other)).toEqual([]); + }); + + it("keeps a declared property named `format`", function* () { + const root = yield* useStorageRoot(); + const id = yield* suspendedRun( + root, + waitingFor({ + type: "object", + properties: { format: { type: "string", format: "email" } }, + required: ["format"], + additionalProperties: false, + }), + ); + + // Declared, so not an additional property; annotated, so not constrained. + const outcome = yield* deliver(root, { + suspensionId: id, + value: { format: "not-email" }, + secretDetection: false, + }); + + expect([outcome.ok, outcome.ok === false && String(outcome.error)]).toEqual([true, false]); + }); + + it("treats an inherited name as a member the value does not hold", function* () { + const root = yield* useStorageRoot(); + const id = yield* suspendedRun( + root, + waitingFor({ + type: "object", + properties: { toString: { type: "string" } }, + required: ["toString"], + additionalProperties: false, + }), + ); + + const missing = yield* deliver(root, { suspensionId: id, value: {}, secretDetection: false }); + expect(missing.ok).toBe(false); + expect(missing.ok === false && missing.error.message).toContain("toString"); + expect(answerRows(root)).toEqual([]); + + const held = yield* deliver(root, { + suspensionId: id, + value: JSON.parse('{"toString":"held"}'), + secretDetection: false, + }); + expect([held.ok, held.ok === false && String(held.error)]).toEqual([true, false]); + }); + + it("refuses a wait whose schema references what it does not define", function* () { + const root = yield* useStorageRoot(); + const id = yield* suspendedRun( + root, + waitingFor({ type: "object", properties: { a: { $ref: "#/definitions/missing" } } }), + ); + + const outcome = yield* deliver(root, { + suspensionId: id, + value: { a: 1 }, + secretDetection: false, + }); + + expect(outcome.ok).toBe(false); + expect(answerRows(root)).toEqual([]); + }); + + it("admits the value draft-07 admits, non-representable steps included", function* () { + const root = yield* useStorageRoot(); + const id = yield* suspendedRun(root, waitingFor({ type: "number", multipleOf: 0.1 })); + + const outcome = yield* deliver(root, { suspensionId: id, value: 0.3, secretDetection: false }); + + expect([outcome.ok, outcome.ok === false && String(outcome.error)]).toEqual([true, false]); + expect(answerRows(root)[0]?.["answer"]).toBe("0.3"); + }); +}); diff --git a/packages/workflow/tests/workspace-effect.test.ts b/packages/workflow/tests/workspace-effect.test.ts index 25a352056..0faeb90cc 100644 --- a/packages/workflow/tests/workspace-effect.test.ts +++ b/packages/workflow/tests/workspace-effect.test.ts @@ -686,10 +686,39 @@ describe("Tier DLC — Workspace coordination selection", () => { "packages/durable-streams/*.ts", ], // Whole packages rather than named modules, so a coordination module - // added later is covered without this list being remembered. The single - // exception carries its reason: the HTTP stream is a client for a remote - // durable stream and reaches the platform's own `fetch`. - exclude: ["packages/workflow/src/deno/**", "packages/durable-streams/http-stream.ts"], + // added later is covered without this list being remembered. Each + // exception carries its reason. + // + // The two implementation subtrees are runtime-owned: scanning an adapter + // for the vocabulary of the runtime it adapts is a category error, and + // Code Rule 12 puts host behavior behind exactly these names. The package + // root and every shared module stay covered, so a host name reaching the + // neutral surface is still a failure. + // + // `src/sqlite` is a private physical SQLite backend the two runtime + // adapters share so version 1 is declared once rather than twice. It + // names a database engine because that is its subject, and it is not the + // provider-neutral coordination or external-effect surface — it owns no + // connection, path, transaction or lifecycle authority and is published + // from no entrypoint. + // + // The software factory is the other kind of exception. It is not a + // runtime adapter and is still held to the host-import and + // runtime-detection rules by `host-neutrality.test.ts`; what it is + // allowed is the product vocabulary, because + // `specs/github-actions-software-factory-spec.md` §1.1 makes GitHub the + // subject matter of that contract rather than one provider capturing a + // neutral boundary. + // + // The HTTP stream is a client for a remote durable stream and reaches the + // platform's own `fetch`. + exclude: [ + "packages/workflow/src/deno/**", + "packages/workflow/src/cloudflare/**", + "packages/workflow/src/software-factory/**", + "packages/workflow/src/sqlite/**", + "packages/durable-streams/http-stream.ts", + ], })) .map((entry) => entry.path) .sort(); @@ -729,7 +758,13 @@ describe("Tier DLC — Workspace coordination selection", () => { "packages/workflow/src/workspace/effect.ts", ]), ); + // An exclusion that matched nothing would scan the adapter and fail on its + // own vocabulary; one that matched too little would scan part of it. Both + // subtrees are checked, so a malformed pattern cannot pass quietly. expect(found.some((path) => path.includes("/src/deno/"))).toBe(false); + expect(found.some((path) => path.includes("/src/cloudflare/"))).toBe(false); + expect(found.some((path) => path.includes("/src/software-factory/"))).toBe(false); + expect(found.some((path) => path.includes("/src/sqlite/"))).toBe(false); const crossings: Record = {}; const unread: string[] = []; diff --git a/packages/workflow/tsconfig.cloudflare.json b/packages/workflow/tsconfig.cloudflare.json new file mode 100644 index 000000000..10bc4647a --- /dev/null +++ b/packages/workflow/tsconfig.cloudflare.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023"], + "types": ["@cloudflare/workers-types", "@cloudflare/vitest-plugin/types", "node"], + "strict": true, + "allowImportingTsExtensions": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["cloudflare.ts", "src/cloudflare/**/*.ts", "tests/cloudflare/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 140cced90..b9909494c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + tsx: 4.23.1 + '@cloudflare/workers-types': 5.20260831.1 + importers: .: @@ -81,6 +85,12 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@cloudflare/vitest-plugin': + specifier: 1.1.3 + version: 1.1.3(@cloudflare/workers-types@5.20260831.1)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@22.19.15)(vite@8.2.2(@types/node@22.19.15)(esbuild@0.28.1)(tsx@4.23.1))) + '@cloudflare/workers-types': + specifier: 5.20260831.1 + version: 5.20260831.1 '@durable-streams/server': specifier: ^0.3.8 version: 0.3.8 @@ -117,6 +127,12 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.15 + '@vitest/runner': + specifier: 4.1.11 + version: 4.1.11 + '@vitest/snapshot': + specifier: 4.1.11 + version: 4.1.11 expect: specifier: ^30.0.0 version: 30.3.0 @@ -127,11 +143,14 @@ importers: specifier: 1.74.0 version: 1.74.0 tsx: - specifier: ^4.19.0 - version: 4.21.0 + specifier: 4.23.1 + version: 4.23.1 typescript: specifier: ^5.0.0 version: 5.9.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@22.19.15)(vite@8.2.2(@types/node@22.19.15)(esbuild@0.28.1)(tsx@4.23.1)) packages/acp: dependencies: @@ -197,6 +216,9 @@ importers: packages/core: dependencies: + '@cfworker/json-schema': + specifier: ^4.1.1 + version: 4.1.1 '@effectionx/context-api': specifier: 0.6.0 version: 0.6.0(effection@4.1.0) @@ -490,6 +512,9 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@clack/core@1.4.3': resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} engines: {node: '>= 20.12.0'} @@ -498,10 +523,67 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/vitest-plugin@1.1.3': + resolution: {integrity: sha512-ED1Rkaq5Wr5rCeHXpLoDyV4WGJzD0Ju0clM8jS7Hj+wjj/CwaMHeb8DXzUfUQPiHW9rTgwcttPPQnzau2kp6Jg==} + peerDependencies: + '@vitest/runner': ^4.1.0 + '@vitest/snapshot': ^4.1.0 + vitest: ^4.1.0 + + '@cloudflare/workerd-darwin-64@1.20260831.1': + resolution: {integrity: sha512-oyZ8xhu+gYTvoxV/sn6NRmTHK95RhEO1Dk54/6oPb0Uu70w7ZeRoCjkJ5aNmfS8Vrkdu6+oL0HNg6EcC61uQ2Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260831.1': + resolution: {integrity: sha512-s6Go53KPnoXZ1sTGBZ3en3otfHDuMPJhiwXMYWU21JkJQkpoeRt6HFUwM0GPhK3YhXWm+8baGMvCGZYS/KA9eA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260831.1': + resolution: {integrity: sha512-WxNKBgjKgeYTolW3yl1Lt3Lu67UlxdeyzWYi9MIqrKBdyQcz+UNG36RevSBf8rv1sTWapRW234VX2keZ+wXapA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260831.1': + resolution: {integrity: sha512-JTF9+9clUT3gaCq7Xnmd+Q/wEMaitpngSTOec/Ffb/r3xexA9XwNJVFSOKfk6q61flHGjAYJ4H9B7Mu5Qur49w==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260831.1': + resolution: {integrity: sha512-do+KDYw0PABwsrKUQIccWBZB70kqKcADoSnvzJ8pvMaWUVB4qaCspEZYfm97WNdtY1wt8mlKYqIJyYUNOkTvQg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@5.20260831.1': + resolution: {integrity: sha512-yXg4pwfYjhsDH9rYc3qZ3K+z62DCSvO/aj7GiZo6AyDeWGZpyFRpPMYcQ6LF/zfaf1x0Ngw2gSqL8JjuUtMGlA==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@durable-streams/client@0.2.2': resolution: {integrity: sha512-zmr9ErxJP1ORljnog4kclWmEJGoTpGN+Mu8FJLVEgcaR9PqTeyKtadq1l1H+DhrPsfAPeG6BF/mEJs4HyI+Eig==} engines: {node: '>=18.0.0'} @@ -584,11 +666,8 @@ packages: peerDependencies: effection: ^3 || ^4 - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} @@ -596,300 +675,150 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.4': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -920,6 +849,152 @@ packages: '@harperfast/extended-iterable@1.0.3': resolution: {integrity: sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jest/diff-sequences@30.3.0': resolution: {integrity: sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -944,9 +1019,16 @@ packages: resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lmdb/lmdb-darwin-arm64@3.5.6': resolution: {integrity: sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==} cpu: [arm64] @@ -1019,6 +1101,9 @@ packages: resolution: {integrity: sha512-9T3nD5q51X1d4QYW6vouKW9hBSb2Tb/wB/2XoTr4oP5SCGtp3a7aTHHewQFylred1B21/Bhev6gy4x01FPBcbQ==} engines: {node: '>=18'} + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + '@oxfmt/binding-android-arm-eabi@0.41.0': resolution: {integrity: sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1247,6 +1332,15 @@ packages: cpu: [x64] os: [win32] + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@radix-ui/number@1.1.3': resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} @@ -1653,6 +1747,99 @@ packages: peerDependencies: '@rjsf/utils': ^6.7.1 + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@secretlint/core@13.0.4': resolution: {integrity: sha512-Wv49KcI5XX6xjLR1wxyjORA15PtMb5ar/M27ShimVudaSi6iAM04QCA5Ozx+uEahfHNefUUKbjKGpy/9pxuW7g==} engines: {node: '>=22.0.0'} @@ -1675,12 +1862,28 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} @@ -1720,6 +1923,35 @@ packages: '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@x0k/json-schema-merge@1.0.4': resolution: {integrity: sha512-KvmMgAftbVzATq4IRnkno/SKSu+gjaR2ZUPJG5JUlY4W3twRJo03sk2914u8scmosibBZ0m7s6euZlJuqpv8Ww==} @@ -1774,6 +2006,10 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -1822,6 +2058,9 @@ packages: bare-url@2.4.6: resolution: {integrity: sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -1831,6 +2070,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1856,6 +2099,9 @@ packages: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} + cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1899,6 +2145,13 @@ packages: resolution: {integrity: sha512-4bxK3+L+FHr9Xm/d69Syvvpvkj7lj7a4zz3B+tchuohg5WKeudyBS+4Oob5Zdgoh8I7+n2lj0lfa8I6cUXfcEg==} engines: {node: '>= 16'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1949,10 +2202,11 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} - engines: {node: '>=18'} - hasBin: true + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} @@ -1972,9 +2226,16 @@ packages: engines: {node: '>=4'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + expect@30.3.0: resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2014,6 +2275,15 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2027,9 +2297,6 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2116,6 +2383,80 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lmdb@3.5.6: resolution: {integrity: sha512-j3uE8ReKNyUWDjhfEFSJqE/1DLtfTR5Z8yFzVHvBjAk37wNg7HdScjcv8ttPHRvrdgPQMPWxFFI0SsdBzI5lBw==} hasBin: true @@ -2248,6 +2589,10 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + miniflare@5.20260831.0-alpha: + resolution: {integrity: sha512-Hwgh1VDUiPCPGQKODQfUmy7hRAje1D55icB+9png3ueiM64rlSM87nSrtqpxAD+DlLWI4ehnYBuECaXV43zGmQ==} + engines: {node: '>=22.0.0'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2261,6 +2606,11 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + node-addon-api@6.1.0: resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} @@ -2279,6 +2629,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + ordered-binary@1.6.1: resolution: {integrity: sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==} @@ -2313,6 +2667,12 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2320,6 +2680,14 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + pretty-format@30.3.0: resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2407,13 +2775,15 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2421,6 +2791,15 @@ packages: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2432,6 +2811,9 @@ packages: shellwords-ts@3.0.1: resolution: {integrity: sha512-GabK4ApLMqHFRGlpgNqg8dmtHTnYHt0WUUJkIeMd3QaDrUUBEDXHSSNi3I0PzMimg8W+I0EN4TshQxsnHv1cwg==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -2448,6 +2830,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -2458,6 +2844,12 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + streamx@2.28.0: resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} @@ -2479,6 +2871,10 @@ packages: structured-source@4.0.0: resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2511,10 +2907,25 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@2.1.0: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -2524,11 +2935,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} - hasBin: true - tsx@4.23.1: resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} engines: {node: '>=18.0.0'} @@ -2542,6 +2948,13 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} @@ -2597,6 +3010,90 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: 4.23.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + weak-lru-cache@1.2.2: resolution: {integrity: sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==} @@ -2605,10 +3102,42 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + workerd@1.20260831.1: + resolution: {integrity: sha512-A2LwrkBel/FnKABPfeBAMiL6v70+rugnunqQRfWsWZjlhsTZoBScWUVunMy/xLCGLjWCQL2zp39AVR6aO0jurQ==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.128.0: + resolution: {integrity: sha512-jNXy9e8/pbx8iqTzXPiuflnitKJZoAfEUSUUDLW87bwyeMvJ7kb3yQMSbxEcfNdfHqJW38KRcKaLljOYV4N/4w==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': 5.20260831.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2621,6 +3150,12 @@ packages: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2648,6 +3183,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@cfworker/json-schema@4.1.1': {} + '@clack/core@1.4.3': dependencies: fast-wrap-ansi: 0.2.2 @@ -2660,9 +3197,53 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260831.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260831.1 + + '@cloudflare/vitest-plugin@1.1.3(@cloudflare/workers-types@5.20260831.1)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@22.19.15)(vite@8.2.2(@types/node@22.19.15)(esbuild@0.28.1)(tsx@4.23.1)))': + dependencies: + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 5.20260831.0-alpha + vitest: 4.1.11(@types/node@22.19.15)(vite@8.2.2(@types/node@22.19.15)(esbuild@0.28.1)(tsx@4.23.1)) + wrangler: 4.128.0(@cloudflare/workers-types@5.20260831.1) + zod: 4.4.3 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - utf-8-validate + + '@cloudflare/workerd-darwin-64@1.20260831.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260831.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260831.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260831.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260831.1': + optional: true + + '@cloudflare/workers-types@5.20260831.1': {} + '@colors/colors@1.5.0': optional: true + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@durable-streams/client@0.2.2': dependencies: '@microsoft/fetch-event-source': 2.0.1 @@ -2748,184 +3329,217 @@ snapshots: dependencies: effection: 4.1.0 - '@esbuild/aix-ppc64@0.27.4': + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 optional: true '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.27.4': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-arm@0.27.4': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/android-x64@0.27.4': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.27.4': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.27.4': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.27.4': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.27.4': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.27.4': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/linux-arm@0.27.4': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/linux-ia32@0.27.4': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/linux-loong64@0.27.4': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/linux-mips64el@0.27.4': + '@esbuild/win32-x64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.28.1': - optional: true + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 - '@esbuild/linux-ppc64@0.27.4': - optional: true + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 - '@esbuild/linux-ppc64@0.28.1': - optional: true + '@floating-ui/react-dom@2.1.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) - '@esbuild/linux-riscv64@0.27.4': - optional: true + '@floating-ui/utils@0.2.12': {} - '@esbuild/linux-riscv64@0.28.1': - optional: true + '@fontsource/montserrat@5.3.0': {} - '@esbuild/linux-s390x@0.27.4': - optional: true + '@fontsource/space-mono@5.3.0': {} - '@esbuild/linux-s390x@0.28.1': - optional: true + '@harperfast/extended-iterable@1.0.3': {} - '@esbuild/linux-x64@0.27.4': + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 optional: true - '@esbuild/linux-x64@0.28.1': + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 optional: true - '@esbuild/netbsd-arm64@0.27.4': + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@img/sharp-libvips-darwin-arm64@1.3.1': optional: true - '@esbuild/netbsd-x64@0.27.4': + '@img/sharp-libvips-darwin-x64@1.3.1': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@img/sharp-libvips-linux-arm64@1.3.1': optional: true - '@esbuild/openbsd-arm64@0.27.4': + '@img/sharp-libvips-linux-arm@1.3.1': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@img/sharp-libvips-linux-ppc64@1.3.1': optional: true - '@esbuild/openbsd-x64@0.27.4': + '@img/sharp-libvips-linux-riscv64@1.3.1': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@img/sharp-libvips-linux-s390x@1.3.1': optional: true - '@esbuild/openharmony-arm64@0.27.4': + '@img/sharp-libvips-linux-x64@1.3.1': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': optional: true - '@esbuild/sunos-x64@0.27.4': + '@img/sharp-libvips-linuxmusl-x64@1.3.1': optional: true - '@esbuild/sunos-x64@0.28.1': + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 optional: true - '@esbuild/win32-arm64@0.27.4': + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 optional: true - '@esbuild/win32-arm64@0.28.1': + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 optional: true - '@esbuild/win32-ia32@0.27.4': + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 optional: true - '@esbuild/win32-ia32@0.28.1': + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 optional: true - '@esbuild/win32-x64@0.27.4': + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 optional: true - '@esbuild/win32-x64@0.28.1': + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 optional: true - '@floating-ui/core@1.8.0': - dependencies: - '@floating-ui/utils': 0.2.12 + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true - '@floating-ui/dom@1.8.0': + '@img/sharp-wasm32@0.35.2': dependencies: - '@floating-ui/core': 1.8.0 - '@floating-ui/utils': 0.2.12 + '@emnapi/runtime': 1.11.3 + optional: true - '@floating-ui/react-dom@2.1.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@img/sharp-webcontainers-wasm32@0.35.2': dependencies: - '@floating-ui/dom': 1.8.0 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - - '@floating-ui/utils@0.2.12': {} + '@img/sharp-wasm32': 0.35.2 + optional: true - '@fontsource/montserrat@5.3.0': {} + '@img/sharp-win32-arm64@0.35.2': + optional: true - '@fontsource/space-mono@5.3.0': {} + '@img/sharp-win32-ia32@0.35.2': + optional: true - '@harperfast/extended-iterable@1.0.3': {} + '@img/sharp-win32-x64@0.35.2': + optional: true '@jest/diff-sequences@30.3.0': {} @@ -2954,8 +3568,15 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@lmdb/lmdb-darwin-arm64@3.5.6': optional: true @@ -2999,6 +3620,8 @@ snapshots: '@neophi/sieve-cache@1.5.0': {} + '@oxc-project/types@0.148.0': {} + '@oxfmt/binding-android-arm-eabi@0.41.0': optional: true @@ -3113,6 +3736,18 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.74.0': optional: true + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@radix-ui/number@1.1.3': {} '@radix-ui/primitive@1.1.7': {} @@ -3464,6 +4099,53 @@ snapshots: lodash: 4.18.1 lodash-es: 4.18.1 + '@rolldown/binding-android-arm-eabi@1.2.7': + optional: true + + '@rolldown/binding-android-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-x64@1.2.7': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.7': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.7': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.7': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.7': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.7': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@secretlint/core@13.0.4': dependencies: '@secretlint/profiler': 13.0.4 @@ -3483,12 +4165,25 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + '@standard-schema/spec@1.1.0': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -3527,6 +4222,47 @@ snapshots: '@ungap/structured-clone@1.3.3': {} + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@22.19.15)(esbuild@0.28.1)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(@types/node@22.19.15)(esbuild@0.28.1)(tsx@4.23.1) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@x0k/json-schema-merge@1.0.4': dependencies: '@types/json-schema': 7.0.15 @@ -3580,6 +4316,8 @@ snapshots: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + b4a@1.8.1: {} bail@2.0.2: {} @@ -3613,12 +4351,16 @@ snapshots: dependencies: bare-path: 3.1.1 + blake3-wasm@2.1.5: {} + boolbase@1.0.0: {} boundary@2.0.0: {} ccount@2.0.1: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3636,6 +4378,8 @@ snapshots: ci-info@4.4.0: {} + cjs-module-lexer@1.2.3: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -3689,6 +4433,10 @@ snapshots: dependencies: '@standard-schema/spec': 1.1.0 + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3725,34 +4473,9 @@ snapshots: environment@1.1.0: {} - esbuild@0.27.4: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 + error-stack-parser-es@1.0.5: {} + + es-module-lexer@2.3.2: {} esbuild@0.28.1: optionalDependencies: @@ -3789,12 +4512,18 @@ snapshots: esprima@4.0.1: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + events-universal@1.0.1: dependencies: bare-events: 2.9.1 transitivePeerDependencies: - bare-abort-controller + expect-type@1.4.0: {} + expect@30.3.0: dependencies: '@jest/expect-utils': 30.3.0 @@ -3834,6 +4563,10 @@ snapshots: dependencies: reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fsevents@2.3.3: optional: true @@ -3841,10 +4574,6 @@ snapshots: get-nonce@1.0.1: {} - get-tsconfig@4.13.6: - dependencies: - resolve-pkg-maps: 1.0.0 - graceful-fs@4.2.11: {} gray-matter@4.0.3: @@ -3950,6 +4679,57 @@ snapshots: kind-of@6.0.3: {} + kleur@4.1.5: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lmdb@3.5.6: dependencies: '@harperfast/extended-iterable': 1.0.3 @@ -4185,6 +4965,18 @@ snapshots: transitivePeerDependencies: - supports-color + miniflare@5.20260831.0-alpha: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260831.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + ms@2.1.3: {} msgpackr-extract@3.0.4: @@ -4209,6 +5001,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.18: {} + node-addon-api@6.1.0: {} node-emoji@2.2.0: @@ -4228,6 +5022,8 @@ snapshots: object-assign@4.1.1: {} + obug@2.1.4: {} + ordered-binary@1.6.1: {} oxfmt@0.41.0: @@ -4286,10 +5082,22 @@ snapshots: path-key@3.1.1: {} + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} + picomatch@4.0.7: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + pretty-format@30.3.0: dependencies: '@jest/schemas': 30.0.5 @@ -4387,10 +5195,29 @@ snapshots: require-from-string@2.0.2: {} - resolve-pkg-maps@1.0.0: {} - reusify@1.1.0: {} + rolldown@1.2.7: + dependencies: + '@oxc-project/types': 0.148.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 + scheduler@0.27.0: {} section-matter@1.0.0: @@ -4398,6 +5225,40 @@ snapshots: extend-shallow: 2.0.1 kind-of: 6.0.3 + semver@7.8.5: {} + + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4406,6 +5267,8 @@ snapshots: shellwords-ts@3.0.1: {} + siginfo@2.0.0: {} + sisteransi@1.0.5: {} skillflag@0.2.1: @@ -4423,6 +5286,8 @@ snapshots: slash@3.0.0: {} + source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} sprintf-js@1.0.3: {} @@ -4431,6 +5296,10 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + + std-env@4.2.0: {} + streamx@2.28.0: dependencies: events-universal: 1.0.1 @@ -4461,6 +5330,8 @@ snapshots: dependencies: boundary: 2.0.0 + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -4506,21 +5377,25 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + tinypool@2.1.0: {} + tinyrainbow@3.1.1: {} + trim-lines@3.0.1: {} trough@2.2.0: {} tslib@2.8.1: {} - tsx@4.21.0: - dependencies: - esbuild: 0.27.4 - get-tsconfig: 4.13.6 - optionalDependencies: - fsevents: 2.3.3 - tsx@4.23.1: dependencies: esbuild: 0.28.1 @@ -4531,6 +5406,12 @@ snapshots: undici-types@6.21.0: {} + undici@7.29.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + unicode-emoji-modifier-base@1.0.0: {} unified@11.0.5: @@ -4597,18 +5478,90 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@8.2.2(@types/node@22.19.15)(esbuild@0.28.1)(tsx@4.23.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.26 + rolldown: 1.2.7 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.19.15 + esbuild: 0.28.1 + fsevents: 2.3.3 + tsx: 4.23.1 + + vitest@4.1.11(@types/node@22.19.15)(vite@8.2.2(@types/node@22.19.15)(esbuild@0.28.1)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.19.15)(esbuild@0.28.1)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2(@types/node@22.19.15)(esbuild@0.28.1)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - msw + weak-lru-cache@1.2.2: {} which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + workerd@1.20260831.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260831.1 + '@cloudflare/workerd-darwin-arm64': 1.20260831.1 + '@cloudflare/workerd-linux-64': 1.20260831.1 + '@cloudflare/workerd-linux-arm64': 1.20260831.1 + '@cloudflare/workerd-windows-64': 1.20260831.1 + + wrangler@4.128.0(@cloudflare/workers-types@5.20260831.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260831.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260831.0-alpha + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260831.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20260831.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + ws@8.21.0: {} + y18n@5.0.8: {} yargs-parser@20.2.9: {} @@ -4623,6 +5576,19 @@ snapshots: y18n: 5.0.8 yargs-parser: 20.2.9 + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + zod@4.3.6: {} zod@4.4.3: {} diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 533a5fed5..86c52709f 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -485,6 +485,24 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ "opens a real node:sqlite run store through @executablemd/workflow/deno to drive runWorkflow() directly; Bun has no node:sqlite at all and Node 22 keeps it behind --experimental-sqlite", issue: "https://github.com/taras/executable.md/issues/366", }, + { + path: "packages/cli/tests/workflow-replay.test.ts", + reason: + "replays completed runs out of a real node:sqlite run store opened through @executablemd/workflow/deno, and reads the answers table it settles; Bun has no node:sqlite at all and Node 22 keeps it behind --experimental-sqlite. The host-neutral half — what a completed run replays on, and what its retained history is held to — runs on every runtime as packages/workflow/tests/replay-inputs.test.ts", + issue: "https://github.com/taras/executable.md/issues/366", + }, + { + path: "packages/workflow/tests/remote-interoperability.test.ts", + reason: + "the subject is that the two capture implementations agree, so the fixture has to be produced by the local one: it opens a real node:sqlite store, creates the run through the Deno provider — which takes that run's advisory lock through Deno.FsFile.tryLock — and captures it with the Deno DOFS provider before the runner materializes and recaptures it. Neither half is portable, and there is nothing left of the claim without both", + issue: "https://github.com/taras/executable.md/issues/698", + }, + { + path: "packages/workflow/tests/remote-staged-fork.test.ts", + reason: + "assembles the fork candidate through the Deno adapter itself — its connections, its staging directory and its remote lifecycle installation — so creating the run takes an advisory lock through Deno.FsFile.tryLock. What a staged candidate must be, independent of the adapter that builds one, is proved over the no-acquisition plane on every runtime as packages/workflow/tests/remote-fork.test.ts", + issue: "https://github.com/taras/executable.md/issues/698", + }, ]; /** diff --git a/scripts/tests/ci-workflow.test.ts b/scripts/tests/ci-workflow.test.ts index 922d5b164..0f04745d4 100644 --- a/scripts/tests/ci-workflow.test.ts +++ b/scripts/tests/ci-workflow.test.ts @@ -472,6 +472,26 @@ describe("the conditional CI jobs", () => { return found.if; } + /** + * The workerd suite runs nowhere else. A Durable Object's acquisition + * lifetime, its eviction and its transaction atomicity are properties of that + * runtime, and the `.vitest.ts` files that prove them are invisible to the + * Deno, Node and Bun corpora by design — so if this job stopped running the + * evidence would go with it and every other job would still be green. + */ + it("owns the Cloudflare typecheck and the workerd suite", function* () { + const jobs = yield* workflow(); + const job = jobs["test-cloudflare"]; + expect(job).toBeDefined(); + const commands = (job?.steps ?? []).flatMap((step) => + step.run === undefined ? [] : [step.run], + ); + expect(commands).toContain("pnpm check:cloudflare"); + expect(commands).toContain("pnpm test:cloudflare"); + // It runs on every event, so `green` requires success from it unconditionally. + expect(job?.if).toBeUndefined(); + }); + it("runs main-green on a pull request and on nothing else", function* () { expect(conditional(yield* workflow(), "main-green")).toEqual( "github.event_name == 'pull_request'", diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index bfdd3b769..6d2dbdd31 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -7497,6 +7497,46 @@ component grants that admission, and a repository component that takes the name `Fetch` is not the pinned identity. +### 6.19 Software-factory constructs + +An issue-driven software factory adds ten authored constructs. They belong to +the workflow host rather than to core: nothing registers them under `xmd run`, +and a document executed without that host has none of them. Their exact forms +are listed here so the public surface is readable in one place; the contract +behind each form — closed props, request, natural key, compatible pre-state, +normalized result, refusal and unavailability behavior, cancellation, replay, +provider ownership and credential boundary — belongs to the section named +beside it. + +`as` is required on every one of them, because every one binds a result. The +form is validated before any context, provider, ceiling or credential is +reached, so a missing prop, an unknown prop, a value outside a closed enum and a +missing `as` each fail before the effect exists. Durable effect identity is +engine-derived from the run and the expansion and is never a prop. + +| Construct | Exact authored form | Contract | +| --- | --- | --- | +| `Issue.Comment` | `` — paired; the content is the body; binds `{ url }` | [Workflow workspaces](./workflow-workspace-spec.md) §10.3 | +| `PullRequest.Comment` | `` — paired; the content is the body; binds `{ url }` | [Workflow workspaces](./workflow-workspace-spec.md) §7.10 | +| `PullRequest.Ready` | `` — binds `{ url, state: "open", draft: false }` | [Workflow workspaces](./workflow-workspace-spec.md) §7.10 | +| `PullRequest.Close` | `` — binds `{ url, state: "closed", merged: false }` | [Workflow workspaces](./workflow-workspace-spec.md) §7.10 | +| `PullRequest.Merged` | `` — binds `{ subject, state: "closed", merged: true, mergeCommit, decision: "adopted" }` | [Workflow workspaces](./workflow-workspace-spec.md) §7.11 | +| `Issue.Close` | ``, or the same form with `reason="not_planned"` — binds `{ url, state: "closed", reason }` | [Workflow workspaces](./workflow-workspace-spec.md) §10.3 | +| `Project.Status` | `` — binds the normalized `{ item, field, option }` | [Workflow workspaces](./workflow-workspace-spec.md) §10.6 | +| `Git.Merge` | ``, or the same form with `purpose="publish"` — binds `{ outcome: "clean", purpose, firstParent, secondParent, mergeBase, commit, workspaceRoot }` or `{ outcome: "conflicted", purpose, firstParent, secondParent, mergeBase, workspaceRoot, conflicts }` | [Workflow workspaces](./workflow-workspace-spec.md) §7.8 | +| `Git.PublishTarget` | `` — binds `{ target, expectedRemoteCommit, reviewedHead, sourceCommit, observedCommit, decision }` | [Workflow workspaces](./workflow-workspace-spec.md) §7.9 | +| `Evidence.Run` | ``, where `commands` is an ordered non-empty list of non-empty argv vectors — binds `{ completion, authoredCommands, executed, runTimeout? }`, each executed row `{ argv, outcome, status?, signal?, limit?, stdout, stderr }` and each channel `{ text, retainedBytes, producedBytes, truncated }` | [Workflow workspaces](./workflow-workspace-spec.md) §10.5 | + +Every binding above is the exact closed record its contract section defines; a member outside those shapes, an unknown member and a value outside a closed enum each refuse rather than being carried. + +Two things a reader looking for a component will not find here. The remote `WorkflowHost` is a host assembly contract — the existing `useRunHost()`, `useLifecycle()`, `useDelivery()` and `attach()`, with a Cloudflare implementation beside the Deno one and no transitions type of its own — and not an element a document writes; its runner-to-owner messages are private to one release rather than a public wire contract ([Workflow workspaces](./workflow-workspace-spec.md) §13.2). The factory's protocol records — subject, stage, implementation revision, handoff, role outcome, invalidation, verdict, conflict suspension, Stage 7 decision, merged-observation wait, stage-to-option table, active frontier and terminal settlement — are the closed versioned schemas of [the software factory](./github-actions-software-factory-spec.md) §11.2, which owns them; nothing here or in the Workspace specification duplicates them, and they are neither components nor a lifecycle controller beside the journal. + +None of these constructs is available to a workflow Agent, and none appears in +any generated-XMD read or write table +([Workflow workspaces](./workflow-workspace-spec.md) §§8.3-8.4). The standard +write table remains exactly core's paired `File:write`, the composition +package's lexical `Dir` and core's self-closing `File.Delete`. + ## 7. Entry point ### 8.1 `execute` @@ -9998,6 +10038,218 @@ Defined in [Workflow workspaces](./workflow-workspace-spec.md) §8. |---|------|--------| | WFX1 | SIGKILL and resume | A real `SIGKILL` part-way through leaves the run `running` with the effects that committed; the resume replays those exact events by id, performs the rest once each with no duplicate and no gap, advances the current root, and completes | +### The software-factory tiers + +The seven tiers below are the frozen evidence names for the software factory +specified by +[the software factory](./github-actions-software-factory-spec.md) and by +[Workflow workspaces](./workflow-workspace-spec.md) §§3.8, 7.8-7.11, 10.3, 10.5-10.7 +and 13.2, and by [the software factory](./github-actions-software-factory-spec.md) §11.2. Each lists the finite structural +scenarios — success, refusal, stale authority, interruption and cancellation, +teardown, replay, and denied Agent or generated-XMD authority — and no +malformed-input permutation without a distinct structural consequence. + +Most of what they name is **specified; implementation unbuilt**, so those tiers name the scenarios an implementation is accepted against rather than tests that exist. Tier WRH is the exception: rows WRH1-WRH16 are implemented and have committed evidence, listed with the tier, while WRH17-WRH22 remain unbuilt with the machine-wait work they belong to. Nothing in the other six tiers is built. + +WRH14 covers the configured host as well as the boundary it satisfies, including what its fourth method installs: `packages/workflow/tests/remote-runner.test.ts` runs an authored `` — and an authored `` inside a `` — through the public attachment with the ambient host filesystem installed outside it, and proves the host provider is never asked, the exact retained root is materialized, one commit carries the new root and the effect's own journal row together, and the owner's frontier moves to what it published; the same file covers a refused commit, a lost answer and a document that fails afterwards, each leaving one attempt and the prior or new complete transaction as the only visible outcome. `packages/cli/tests/remote-workflow-host.test.ts` proves that the explicit installer has exactly the four methods, that it is bound to one run and refuses another before a token is minted, that reads and deliveries take no acquisition while execution takes one socket, and that a storage handle it did not open cannot be attached; `packages/workflow/tests/remote-runner.test.ts` proves the lifecycle-to-attachment handoff, including that two clients holding handles which agree about run id, root and anchor still cannot attach each other's; and `packages/workflow/tests/cloudflare/remote-owner-routes.vitest.ts` proves the request boundary against a real Durable Object — namespace routing, an actual upgrade whose subprotocol is selected, the admission order as a status, and a read and a delivery answered while an executor is live. No selector chooses that host: the shipped Deno and compiled entrypoints install the local one and Node and Bun remain unsupported. + +### Tier WRH — Remote host, executor and delivery separation + +Defined in [Workflow workspaces](./workflow-workspace-spec.md) §3.8 and §13.2 +and [Workflow runs](./workflow-spec.md) §9.8. + +| # | Test | Verify | +|---|------|--------| +| WRH1 | One owner | A run ID selects one durable owner arithmetically; two admissions of one ID reach that owner and no second registry answers | +| WRH2 | Acquisition is a connection | Start, resume, stale recovery, document execution, Workspace mutation, provider attachment, lifecycle transition, accepted-outcome publication and terminal settlement each validate the exact live acquisition and the expected Workspace root inside their own mutating transaction | +| WRH3 | A second executor | A second connection for a live run follows or is refused, and advances nothing either way | +| WRH4 | Stale authority | A closed, foreign or superseded acquisition reaches no mutation; a run left `running` by a closed connection is recovered by the next acquisition from the exact committed frontier | +| WRH5 | Runner crash | A runner killed between materializing a root and submitting changes leaves a prior or a new complete transaction and never a partial one | +| WRH6 | Content-addressed transfer | The owner refuses a submission whose acquisition, expected root or content does not validate, and publishes the new root and the filtered journal result atomically when it does | +| WRH7 | Delivery is not execution | An answer and a terminal decision each retain against their exact subject while taking no acquisition, beginning no execution, attaching no provider, appending no journal event and changing no run status | +| WRH8 | Delivery correlation | A value for a subject the run is not holding, a duplicate delivery and a spent delivery are each refused with nothing written | +| WRH9 | Consumption | A later executor consumes the retained value inside the run's transaction and appends the accepted event exactly once | +| WRH10 | Inspection | Status and history read immutable snapshots, take no acquisition, and authorize no transition | +| WRH11 | Teardown | Closing the connection releases executor ownership and rolls back nothing already committed | +| WRH12 | Completed replay | A completed run replays by reading its durable owner — lifecycle storage access, not external-effect replay — 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 | +| WRH13 | Host neutrality | Shared WorkflowRun modules import nothing Cloudflare-specific and detect no runtime; the runtime-named entrypoint is the only place the topology appears | +| WRH14 | The host boundary is unchanged | The Cloudflare adapter satisfies the existing `useRunHost()`, `useLifecycle()`, `useDelivery()` and `attach()` with the same provider-neutral transition and request types; no fifth method and no adapter-specific transitions type appears, and the shared CLI asks the same four questions it asks the Deno host | +| WRH17 | A machine wait is not a typed answer | The wait publishes a `machine_wait` event identified by a `waitId`, never a `suspension_request` or a suspension id; it exposes no response schema, no `xmd workflow answer` route, no form and no bound value, and inspection reports a run waiting on provider state | +| WRH18 | Atomic wait settlement | The `machine_wait` event and the `suspended` status commit together, the executor acquisition is released only after that commit, and a refused settlement publishes neither | +| WRH19 | Wake delivery | An authenticated intake correlated to the exact wait subject retains one bounded wake notification with no executor acquisition, no lifecycle outcome, no run-status change and no answer, verdict, stage, transition or observation result | +| WRH20 | Wake refusals | A duplicate notification changes nothing; one naming another wait, a spent wait, an invalidated wait or a terminal run refuses and leaves the active wait unchanged | +| WRH21 | Wake consumption is one transaction | A later executor consumes one notification and appends one `machine_wake` for that exact `waitId` in a single transaction — `intakeId` present exactly for `provider-intake` and absent exactly for `operator-resume`; a crash before commit leaves wait and notification pending, and replay after commit restores the event without consuming or appending again | +| WRH22 | Resume without authority | An explicit resume with neither a pending wake nor operator-resume authority ends nothing: it reports the same machine wait and settles `suspended` again | +| WRH15 | Release identity | Connection admission validates an exact immutable runner and owner build or protocol fingerprint from trusted deployment configuration and refuses a mismatch closed, before request parsing, acquisition or state access; no message shape is adapted, downgraded or negotiated, and no transport record is journaled, exported or authored | +| WRH16 | Ownership split | Connection admission, request parsing, transaction lifetime and stale recovery belong to the owner; provider attachment and cancellation of its own execution belong to the runner; content is produced by the runner and validated by the owner | + +**Where WRH1-WRH16 are proved.** Every row below has committed evidence at two levels: the owner's own behavior against a real Durable Object under `workerd`, and the provider-neutral decisions against a scripted owner. Paths are relative to `packages/`. + +| # | Owner evidence (real Durable Object) | Provider-neutral and host evidence | +|---|---|---| +| WRH1 | `workflow/tests/cloudflare/remote-storage.vitest.ts`, `workflow/tests/cloudflare/remote-owner.vitest.ts` | `workflow/tests/remote-storage.test.ts` | +| WRH2 | `workflow/tests/cloudflare/executor-acquisition.vitest.ts`, `workflow/tests/cloudflare/remote-lifecycle.vitest.ts`, `workflow/tests/cloudflare/remote-publish.vitest.ts` | `workflow/tests/remote-lifecycle.test.ts` | +| WRH3 | `workflow/tests/cloudflare/executor-acquisition.vitest.ts` | `workflow/tests/remote-lifecycle.test.ts` | +| WRH4 | `workflow/tests/cloudflare/remote-lifecycle.vitest.ts` | `workflow/tests/remote-recovery.test.ts` | +| WRH5 | `workflow/tests/cloudflare/remote-publish.vitest.ts`, `workflow/tests/cloudflare/remote-workspace.vitest.ts` | `workflow/tests/remote-publication.test.ts` | +| WRH6 | `workflow/tests/cloudflare/remote-publish.vitest.ts`, `workflow/tests/cloudflare/remote-storage.vitest.ts` | `workflow/tests/remote-publication.test.ts`, `workflow/tests/remote-storage.test.ts` | +| WRH7 | `workflow/tests/cloudflare/remote-delivery.vitest.ts` | `workflow/tests/remote-delivery.test.ts`, `workflow/tests/delivery-gate.test.ts` | +| WRH8 | `workflow/tests/cloudflare/remote-delivery.vitest.ts` | `workflow/tests/remote-delivery.test.ts` | +| WRH9 | `workflow/tests/cloudflare/remote-delivery.vitest.ts` | `workflow/tests/remote-delivery.test.ts`, `workflow/tests/workflow-suspension-answer.test.ts` | +| WRH10 | `workflow/tests/cloudflare/remote-read-plane.vitest.ts` | `workflow/tests/remote-inspection.test.ts` | +| WRH11 | `workflow/tests/cloudflare/executor-acquisition.vitest.ts`, `workflow/tests/cloudflare/remote-lifecycle.vitest.ts` | `workflow/tests/remote-lifecycle.test.ts` | +| WRH12 | `workflow/tests/cloudflare/remote-replay.vitest.ts` | `workflow/tests/replay-inputs.test.ts`, `workflow/tests/git-blob.test.ts`, `cli/tests/workflow-replay.test.ts` | +| WRH13 | — the suites import the runtime-named entrypoint and nothing else does | `workflow/tests/host-neutrality.test.ts`, `workflow/tests/public-entrypoint.test.ts`, `cli/tests/workflow-host-boundary.test.ts` | +| WRH14 | `workflow/tests/cloudflare/remote-lifecycle.vitest.ts` | `cli/tests/workflow-host-boundary.test.ts`, `cli/tests/workflow-cli.test.ts` | +| WRH15 | `workflow/tests/cloudflare/executor-acquisition.vitest.ts`, `workflow/tests/cloudflare/remote-delivery.vitest.ts` | — admission is the owner's, and only the owner can refuse before parsing | +| WRH16 | `workflow/tests/cloudflare/remote-owner.vitest.ts`, `workflow/tests/cloudflare/settle-parser.vitest.ts`, `workflow/tests/cloudflare/owner-storage.vitest.ts` | `workflow/tests/remote-client.test.ts`, `workflow/tests/remote-publication.test.ts` | + +Two rows are narrower than their sentence reads, and the narrowing is not incidental. WRH7's terminal-decision subject is a factory record that does not exist yet, so what is built and proved is answer delivery and its consumption; the plane it arrives on takes no acquisition either way. WRH12 is proved against the whole terminal-history contract of [Workflow runs](./workflow-spec.md) §9.9 rather than against a readable-selection parser alone: `cli/tests/workflow-replay.test.ts` carries the eighteen host-level cases — coherent completed and failed replay, compatible `start --id` and completed `resume`, exact failure recovery, refusal of an unreadable terminal, a duplicated or disowned root import and an unverifiable selection, envelope-only settlement with the terminal run row preserved, and no Git, provider or attachment reached on any of them — and `workflow/tests/cloudflare/remote-replay.vitest.ts` proves the same conclusions where the retained state is a real Durable Object's. + +Fork has no row of its own in this tier because it is not remote-specific: §11 owns it, and the remote implementation is evidenced by `workflow/tests/remote-fork.test.ts`, `workflow/tests/remote-staged-fork.test.ts` and `workflow/tests/cloudflare/remote-fork.vitest.ts`. + +### Tier WGI — Authenticated GitHub ingress + +Defined in [the software factory](./github-actions-software-factory-spec.md) §5. + +| # | Test | Verify | +|---|------|--------| +| WGI1 | Order | The webhook signature is verified before the payload is parsed as anything but bytes, and the complete objects are reread through the API before authorization is decided | +| WGI2 | Bad signature | An unsigned or wrongly signed delivery is refused before parsing and retains no intake | +| WGI3 | Bounded intake | One intake is retained per delivery or submission identity, holding only typed bounded fields | +| WGI4 | Duplicates | A repeated delivery of one identity finds the retained intake and writes nothing | +| WGI5 | Unavailable is not absent | A missing result page, an unavailable field, an ambiguous object and a partial permission read are each unavailable, and none of them admits an item | +| WGI6 | Admission ceiling | Admission requires the configured organization-owned Project and the exact repository, Project, item, status field and allowed option IDs | +| WGI6b | Stage mapping | Admission maps a completely reread option ID to a stage through the configured bijection and projection maps a stage back through its inverse; neither parses a display string, an invalid table refuses before an intake is retained, a token is minted, a run starts or anything is projected, and only the configured `Backlog`-to-`User` movement admits a new item | +| WGI7 | Dispatch carries nothing | `repository_dispatch` carries only the retained intake identity; a payload naming a stage, outcome, answer, decision, transition, credential or definition is refused | +| WGI8 | OIDC claims | Admission validates issuer, audience, repository ID, repository-owner ID, event name, workflow ref and SHA, and the configured workflow identity; a valid token for another repository or workflow admits no session | +| WGI9 | Human floor | An actor without Project write and repository write-or-higher authorizes no admission, answer, change, merge or abandonment | +| WGI10 | Comments are not authority | A comment naming an answer, a merge, an abandonment or a resume changes nothing | +| WGI11 | Secrets | No private key, webhook secret, OIDC configuration, installation token, endpoint, raw payload, cursor or host path appears in props, context data, a durable record, a comment, output or a diagnostic | +| WGI12 | Run identity | One issue admitted twice derives one run ID; a reread returning a different node ID for the same subject refuses as drift and creates no second run | + +### Tier WGE — Reconciled GitHub and Project projections + +Defined in [Workflow workspaces](./workflow-workspace-spec.md) §7.10, §10.3 and +§10.6. + +| # | Test | Verify | +|---|------|--------| +| WGE1 | Comment identity | A comment's natural key is its subject plus the engine-derived effect identity; a re-rendered or edited body is the same comment and produces no second one | +| WGE2 | Ready and close | `PullRequest.Ready` and `PullRequest.Close` are keyed by their exact subject; an already-ready pull request is adopted, a merged one conflicts for both, and each binds its literal closed record rather than an observed one | +| WGE2a2 | Create effects differ | An effect with a provider-native client idempotency or correlation key — an Issue upsert, a pull-request upsert — reconciles on that key under its existing complete-observation contract and carries no attempt state; only an effect with neither a native key nor a pre-existing subject to read requires the marker and the unattempted/attempted distinction, and one with neither mechanism refuses before its first mutation | +| WGE2b | Comment correlation | A provider that cannot write, preserve and completely query a stable opaque marker refuses before its first mutation; the authored logical body is preserved byte for byte as the authored portion of the projection while the correlation representation lives outside it, so the provider payload is not the authored bytes; the binding and every replay expose the authored body and the provider comment identity, never the transport encoding | +| WGE2c | Attempt state decides absence | Unattempted with no marker is proven absence and creates once; exactly one marker adopts; more than one is ambiguity; **attempted with no committed completion and no marker is permanent ambiguity, not absence**, so a removed marker inside the interrupted window stalls rather than duplicating; a marker removed after a committed completion changes nothing, because replay contacts no provider | +| WGE2d | Merged observation | `PullRequest.Merged` mutates nothing and only adopts: merged at the exact published commit is the completion, merged at another commit conflicts, still open is temporary unavailability, and closed unmerged conflicts rather than being waited out | +| WGE3 | Issue closure | `Issue.Close` adopts an issue already closed with the same reason and conflicts with one closed under the other reason | +| WGE4 | Project pre-state | `Project.Status` is keyed by exact item plus field, adopts an item already at the requested option, and performs once from another allowed option | +| WGE5 | Project unavailability | An unreadable board, unavailable field, ambiguous item and partial permission read are unavailable rather than absent, and none of them mutates | +| WGE6 | Ceilings | A project, item, field or option outside the host ceiling is refused; no authored prop widens it | +| WGE7 | Boundaries stay separate | An Issue-provider effect, a Git-host effect and a Project effect journal their own types and no adapter answers another's request | +| WGE8 | Form before provider | A missing prop, an unknown prop, a value outside a closed enum and a missing `as` each fail before a provider, ceiling or credential is reached | +| WGE9 | Cancellation | A cancelled effect tears the provider call down and publishes no completion | +| WGE10 | Interruption | An interrupted remote completion is reobserved and adopted only when it matches the retained intent | +| WGE11 | Replay | A completed record replays without contacting a provider | +| WGE12 | Projection is not authority | A board, a comment or a draft state ahead of the journal is reconciled as drift and never accepted as proof that a stage passed | +| WGE13 | Denied to generated XMD | A fragment naming any of these constructs is refused in the preflight, before any generated effect, and the standard write table still holds exactly `File:write`, `Dir` and `File.Delete` | + +### Tier WGM — Ordered merge and target publication + +Defined in [Workflow workspaces](./workflow-workspace-spec.md) §7.8 and §7.9. + +| # | Test | Verify | +|---|------|--------| +| WGM1 | Clean merge | A clean `Git.Merge` publishes the commit, the new Workspace root and the filtered result in one transaction | +| WGM2 | Conflict | A conflicted merge restores the pre-merge root, publishes normalized conflict evidence against it, and offers no mutation under that evidence | +| WGM3 | Parent order | `purpose="synchronize"` carries `[implementationHead, targetBase]` and `purpose="publish"` carries `[reviewedBase, reviewedHead]`; neither purpose reorders what it was given | +| WGM3b | `purpose` authorizes | Each purpose's authored parents and merge base are validated against the provider-authenticated merge ceiling — the current implementation head and observed target base for a synchronization, the Stage 7 decision's reviewed `{ headSha, baseSha }` for a publication — and both correct orders pass | +| WGM3c | What refuses before mutation | A missing ceiling, a purpose the ceiling does not authorize, a swapped parent, a stale parent, a stale merge base, another revision, and a ceiling for another Repository or checkout each refuse before any Git mutation | +| WGM4 | Stale identities | A changed head, base, merge base, Repository identity, conflict set or Workspace root makes a retained conflict admission stale, and nothing mutates under it | +| WGM5 | Interruption | A host killed between the merge and the commit leaves the checkout, the current root and the effect history unchanged | +| WGM6 | Compare-and-swap | `Git.PublishTarget` updates the ref only after observing the target equal to the expected commit | +| WGM7 | Adoption | A target already equal to the exact source commit is adopted with nothing performed | +| WGM8 | Race | A target moved to a third commit refuses without mutating, and the exact-revision reviews invalidate rather than the publication proceeding | +| WGM9 | Ceilings | Remote, ref, credential and non-force policy are host-owned; no authored prop sets or widens them | +| WGM10 | Distinct operations | `Git.Push`, `Git.PublishTarget`, `Git.Merge`, pull-request upsert, ready, close and merged observation are seven operations with distinct subjects and records; no force, force-with-lease, rebase, reset or host squash appears in any command trace or retained configuration | +| WGM11 | Replay | Completed merge and publication records replay without running Git and without contacting a Git host | +| WGM13 | Exact merge records | A clean result names the merge commit and published root; a conflicted one names the restored pre-merge root and the complete conflict set sorted by path in UTF-8 byte order, with stage numbers and side presence agreeing exactly, duplicate paths refused, and an absent side absent rather than null | +| WGM14 | Restoration failure | A pre-merge root that cannot be restored publishes no conflicted result and no new root, and activates the durable fail-stop fence | +| WGM15 | Exhaustive pre-states | Expected base performs once, the exact source commit adopts, a third commit conflicts, and incomplete observation, ambiguity and temporary unavailability each refuse as themselves | +| WGM12 | Denied to the Agent | Neither construct is reachable by a workflow Agent or by an admitted generated fragment | + +### Tier WER — Trusted evidence execution + +Defined in [Workflow workspaces](./workflow-workspace-spec.md) §10.5. + +| # | Test | Verify | +|---|------|--------| +| WER1 | Structured argv | `commands` is an ordered list of non-empty argument vectors; no shell string, interpreter or quoting layer takes part, and the retained argv is what ran | +| WER2 | Root | The commands run against the exact retained Workspace root the host materialized | +| WER3 | Ceilings | Executable, environment, time and output ceilings are host-owned and refuse rather than truncating silently what they were not given | +| WER4 | Results | The binding is one ordered bounded result per command, carrying its argv, exit status and bounded output | +| WER5 | Location | The execution happens on the trusted runner; the durable owner runs no native process | +| WER6 | Cancellation and teardown | A cancelled run terminates its child before publishing, and no child outlives the effect | +| WER7 | Replay | A completed record replays running nothing | +| WER4b | Fail-fast prefix | A command exiting `0` starts its successor; a non-zero exit, a signal and a timeout each become the last row and start none. `completion` is `"passed"` only when `executed` holds `authoredCommands` rows that all exited `0`, and `"failed"` otherwise, so a complete pass and a stopped prefix are distinguishable without the authored list | +| WER4c | Channels and truncation | stdout and stderr are retained separately, each stating its retained bytes, the bytes the child produced, and whether it was truncated; truncation is stated rather than inferred | +| WER4d | Outcome discriminants | `status` is present exactly for `exited`, `signal` exactly for `signalled`, and `limit` exactly for `timeout`; any of the three beside the wrong outcome, an unknown outcome and an unknown `limit` each refuse the record | +| WER4e | Two ceilings | A per-command ceiling and a whole-run ceiling are both host-owned and neither is a prop; a running command's effective deadline is the earlier of the two, and the timeout row's `limit` names which fired | +| WER4f | Whole-run expiry between commands | The result ends `completion: "failed"` with a `runTimeout` record naming the index that did not start, no next command runs, and no fabricated argv row appears | +| WER6b | What binds and what fails | A zero exit, a non-zero exit, a signal, a per-command timeout and a whole-run timeout each belong to an ordinary bound result; a launch failure, an output-pump failure and a teardown failure each fail the effect and bind no `EvidenceRunResult`; cancellation terminates the complete process tree and commits neither a completion nor a failure | +| WER6c | Precedence | Cancellation outranks everything; otherwise the first infrastructure failure is authoritative and a teardown failure after it is retained as secondary evidence rather than replacing it; a teardown failure with nothing before it is authoritative even when every command produced an observed exit | +| WER6d | Failure evidence is retained | A failed effect's error carries the safely collected executed prefix, the separately bounded channels, the primary infrastructure category and any secondary teardown category; nothing binds it, it is not an `EvidenceRunResult`, replaying the failed effect starts no process, and cancellation retains neither | +| WER6e | Enforcement that fails | A termination, drain or reap that fails while the host is enforcing a ceiling is an infrastructure failure rather than a timeout row | +| WER8 | Denied authority | `Evidence.Run` appears in no Agent capability and in no generated-XMD read or write table; a fragment naming it is refused before any generated effect | + +### Tier WFP — Factory protocol and frontier + +Defined in [the software factory](./github-actions-software-factory-spec.md) +§§1-2 and §12. + +| # | Test | Verify | +|---|------|--------| +| WFP1 | Run identity | The run ID equals the §1.1 derivation for its issue: the same canonical GitHub authority and the same issue node ID produce the same 52-character id in two independent implementations, and a changed repository name, issue number, Project identity, comment, branch, revision, definition SHA, delivery ID or actor changes it not at all | +| WFP1b | Identity drift | A reread returning a different canonical GitHub authority or node ID for a subject the host already retains refuses as unsupported provider-identity drift and derives no second run | +| WFP2 | Adjacency | An outcome advancing more than one stage is rejected, and the current stage's handoff commits before the next role is invoked | +| WFP3 | Same-stage amendment | A later accepted same-stage output replaces the frontier while the superseded output stays readable in the journal | +| WFP4 | Backward destinations | Each of the six destinations in §2.2 deactivates exactly the downstream handoffs its row names and requires every later stage again | +| WFP5 | Head invalidation | A changed `headSha` places the frontier at Stage 4 and invalidates Stages 5-7 | +| WFP6 | Base invalidation | A base-only move places the frontier at Stage 5, and a base move requiring synchronization or implementation work places it at Stage 4 | +| WFP7 | Exact subjects | Stage 6 accepts only a Planner verdict naming the same revision, and Stage 7 only a review chain naming the current one | +| WFP8 | Ready authority | Only an accepted Stage 6 verdict for the current revision takes the pull request out of draft; observed ready state manufactures no verdict | +| WFP9 | Closed parsers | Every §11.2 record carries its schema discriminant and version; an unknown schema, an unknown version, an unknown member, a missing required member and a value outside a closed enum each refuse, and a refusal names the member path and never the value | +| WFP9b | Frontier reduction | Each reduction input of §11.2 — advance, amend, invalidate, head change, base-only change, base-needs-work, and a terminal decision on an already-terminal run — produces exactly the stated frontier, and a reduction leaving a stage out of range, two entries for one stage, or an advance past an unaccepted stage refuses | +| WFP9c | Decision shapes | `merge` carries revision, actor and delivery identity; `abandon` adds a required reason; `change` adds a reason and the earliest invalidated stage; a decision naming a revision that is not the current frontier revision refuses | +| WFP9d | Stage-to-option table | The retained table holds exactly nine entries ordered by stage, one option ID per stage `0`-`8`, every option ID distinct, and every display name equal to the settled status string for its stage; missing, partial, duplicated, cross-field, unavailable and renamed cases each refuse | +| WFP9e | Merged-observation wait and wake | The wait record names a `waitId`, the canonical pull-request URL, the expected merge commit, the terminal decision and the revision, carries `retriesExhausted: true`, and holds no stage, outcome, verdict or response schema; the wake record names the same `waitId` and a closed `source`, with `intakeId` required exactly for `provider-intake` and absent exactly for `operator-resume`, and carries no observation result | +| WFP9f | Terminal references | Every event a terminal names must belong to this run and intent, parse under its exact effect kind, be complete, and agree on revision, actor and decision; missing, foreign, wrong-kind, incomplete, invalidated, duplicated and cross-path events each refuse settlement, and provider identities stay in the referenced results rather than being copied into the terminal | +| WFP10 | Definition identity | Definition incompatibility is a workflow lifecycle refusal and never an implementation correction | +| WFP11 | Journal is authority | A Project status, comment or pull-request state ahead of the journal is drift and authorizes no stage | + +### Tier WFL — Authored factory lifecycle and terminal settlement + +Defined in [the software factory](./github-actions-software-factory-spec.md) +§§8-10. + +| # | Test | Verify | +|---|------|--------| +| WFL1 | Clean synchronization | Observe, merge, record the revision, remain at Stage 4, run evidence, push the descendant, offer the new pair to Stage 5 — as separate durable effects, resuming from the first uncommitted one | +| WFL2 | Conflict suspension | Every conflict returns the conflicted shape of the `GitMergeResult` union, restores the pre-merge root, retains the complete conflict set that shape defines, publishes a handoff and suspends | +| WFL3 | No automatic resolution | No generated fragment, structured text-conflict capability, rebase, force, force-with-lease or reset resolves a conflict | +| WFL4 | Manual resolution | A pushed resolution is observed as a new Stage 4 revision, receives new evidence, and inherits no Stage 5-7 conclusion | +| WFL5 | Merge decision first | A merged decision is retained before any effect is attempted | +| WFL6 | Merge ordering | Merge construction, target publication, `PullRequest.Merged`, issue completion as `completed`, the Project move and terminal settlement are separate reconciled steps in that order | +| WFL6b | Paths do not borrow steps | The merged path closes no pull request and the abandoned path constructs no merge, publishes no target and observes no merged state; a `FactoryTerminal` naming a step from the other path refuses | +| WFL6c | Waiting for the host | A pull request still open runs the bounded host-configured retry and then enters the merged-observation machine wait; the wait appends no lifecycle outcome and moves no stage, a wake only permits one further observation, and terminal settlement stays absent until the adoption succeeds | +| WFL6d | Reobservation after a wake | Authored control flow invokes `PullRequest.Merged` again after the wake event; an adoption advances the terminal sequence, a still-open observation may retry and wait again at a new durable position, and merged-at-another-commit or closed-unmerged ends the wait as a conflict | +| WFL6e | Cancellation during a wait | Cancelling a waiting run follows ordinary run cancellation and invents neither a wake nor a merged observation | +| WFL7 | Terminal is last | The run becomes terminal `merged` only after every required projection completes | +| WFL8 | Abandonment | An exact-revision authenticated abandonment carrying a reason is retained first, then pull-request close unmerged, issue close as `not_planned`, the Project move and settlement; terminal `abandoned` follows all of them | +| WFL9 | Retention | Both terminal kinds retain the actor, exact revision, resulting provider identities and required reason, and retain the Project item, branch, comments, journal, Workspace roots and Agent evidence | +| WFL10 | Interrupted terminal | An interruption between two projections resumes at the first uncommitted one and publishes no terminal status until the rest complete | +| WFL11 | Provider-free replay | A terminal run replays attaching no provider | +| WFL12 | Reopening | Reopening the issue does not reopen the completed run; continuing requires a new linked issue and therefore a new run | + ### Tier SL — Own-scope context updates | # | Test | Verify | diff --git a/specs/github-actions-software-factory-spec.md b/specs/github-actions-software-factory-spec.md index ff1a0ef41..539053123 100644 --- a/specs/github-actions-software-factory-spec.md +++ b/specs/github-actions-software-factory-spec.md @@ -1,47 +1,103 @@ # GitHub Actions-hosted AI Software Factory -This specification defines an issue-driven software factory whose durable -procedure is an XMD workflow and whose invocation host is GitHub Actions. One +You open one GitHub issue and the factory carries it to a merged pull request or +an explicit abandonment, without anybody having to remember where it got to. One GitHub Project item shows who owns the work now; the retained XMD run proves how -the item reached that owner. - -The factory has no independent controller. GitHub Actions starts or resumes the -XMD workflow and supplies an authorized GitHub environment. The XMD workflow -invokes roles, validates their structured outcomes, records handoffs, performs -authorized GitHub and Workspace effects, updates the Project status, and -journals those effects. - -## 1. One item, one durable run +the item reached that owner. Every stage the item passed through, every revision +that was reviewed, and every effect that reached GitHub are in one durable +journal addressed by the issue itself. + +The factory has no independent controller. A durable XMD workflow run is the +whole procedure. GitHub Actions supplies an ephemeral trusted runner; a +Cloudflare Durable Object supplies the run's durable state; a dedicated GitHub +App supplies authenticated ingress and the credential every GitHub effect is +performed with. The XMD workflow invokes roles, validates their structured +outcomes, records handoffs, performs authorized GitHub and Workspace effects, +updates the Project status, and journals those effects. + +Three planes are separate throughout, and keeping them separate is what the rest +of this specification spends its length on: + +- the **executor plane**, one authenticated WebSocket connection that advances + the run; +- the **delivery plane**, authenticated transactions that retain an intake, an + answer or a decision without executing anything; and +- the **inspection plane**, read-only reads that can never become transition + authority. + +## 1. One issue, one durable run One issue corresponds to one durable XMD factory run. The run may produce and review many implementation revisions before it closes; a new implementation commit never creates a new factory run. -Two SHA identities remain separate throughout that run: +### 1.1 The run ID is derived from the issue + +Admission rereads the GitHub issue from the API before it derives anything. The +**factory run ID** is then the lowercase unpadded RFC 4648 Base32 encoding of +the full SHA-256 digest of these UTF-8 bytes, concatenated in this order: + +```text +"github-issue-v1" || 0x00 || canonical GitHub authority || 0x00 || issue node ID +``` + +The digest is all 32 bytes, so the run ID is 52 Base32 characters with no +padding and no separators. It is a public run ID in the sense §9 of the workflow +specification already defines: non-empty, containing no NUL, opaque to +everything but equality and lifecycle addressing. + +The **canonical GitHub authority** is the lowercase DNS hostname of the GitHub +deployment, plus `:` and the port when the port is not the scheme's default. It +carries no scheme, path, query, fragment, user information or trailing +separator, so one deployment has exactly one spelling. The **issue node ID** is +the exact string GitHub's GraphQL API returns for that issue, compared byte for +byte with no case folding and no Unicode normalization: it is an opaque provider +identity, and normalizing it would be inventing a second one. + +Nothing mutable participates. Repository names, issue numbers, Project, Project +item and status identities, comments, branch names, implementation revisions, +the workflow definition SHA, webhook delivery IDs and actor identities all +change while the run stays the run it was, so none of them is an input to the +derivation. + +Two consequences follow directly. Duplicate admission for the same authenticated +subject derives the same run ID and therefore routes to the same run rather than +creating a second one — the compatible-reuse rule of the workflow contract does +the rest. And a reread that returns a different retained provider identity for +the same subject, including an issue transfer that changes the node ID, is +unsupported drift: the host refuses it, names it as drift, and creates no second +run. Silently starting another run would leave two frontiers claiming one piece +of work. + +### 1.2 Two SHA identities, and neither is the run + +Two SHA identities remain separate throughout the run, and neither is the run +ID: - The **workflow definition SHA** is the immutable Git commit in the XMD workflow definition. It fixes the procedure and its component bundle for the - lifetime of the run. A different definition is not a revision of the same - run; it requires a new run or an eligible history fork under the workflow - lifecycle contract. -- The **implementation revision** is the evolving pair - `{ headSha, baseSha }`. `headSha` is the exact commit at the draft pull - request's head, and `baseSha` is the exact target-branch commit against which - that head is evaluated. + lifetime of the run. A different definition is not a revision of the same run; + it requires a new run or an eligible history fork under the workflow lifecycle + contract. +- The **implementation revision** is the evolving pair `{ headSha, baseSha }`. + `headSha` is the exact commit at the draft pull request's head, and `baseSha` + is the exact target-branch commit against which that head is evaluated. The definition SHA authorizes which procedure executes. The implementation revision identifies what the procedure is currently producing or reviewing. -Neither substitutes for the other, and neither a Project field nor a comment -may rewrite either identity. +Neither substitutes for the other, and neither a Project field nor a comment may +rewrite either identity. ## 2. Lifecycle and validation frontier +![Ownership bands across the eight factory stages](./assets/github-actions-software-factory-ownership-bands.svg) + The Project status has these values: | Stage | Status | Owner | Question answered | | ---: | --- | --- | --- | | 0 | Backlog | User | Is this item admitted to the factory? | -| 1 | Product Owner | User | What product outcome and acceptance boundary are intended? | +| 1 | User | User | What product outcome and acceptance boundary are intended? | | 2 | Architect | Architect | Is the structural contract ready? | | 3 | Planner | Planner | Is there an implementation-ready plan and evidence matrix? | | 4 | Implementor | Implementor | Does an implementation revision satisfy the accepted plan? | @@ -50,14 +106,14 @@ The Project status has these values: | 7 | User Review | User | Is this exact validated result accepted? | | 8 | Closed | None | Was the item merged or abandoned? | -The left side progressively removes uncertainty: product intent, structural -contract, implementation plan, then code. The right side validates the result: -implementation evidence, structural correctness, user acceptance, then -completion. +Those nine strings are the exact status vocabulary. The diagram above shows the +same eight stages as two ownership bands: the left side progressively removes +uncertainty — product intent, structural contract, implementation plan, then +code — and the right side validates the result — implementation evidence, +structural correctness, user acceptance, then completion. -Moving an authorized Backlog item to Product Owner admits it and starts its -factory run. Once admitted, an ordinary successful outcome advances exactly one -stage: +Moving an authorized Backlog item to User admits it and starts its factory run. +Once admitted, an ordinary successful outcome advances exactly one stage: ```text 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 @@ -83,11 +139,13 @@ Self-correction is iteration, not progress: passes. - An Implementor producing, synchronizing, or correcting an implementation remains at Stage 4 until the latest implementation revision passes. -- A reviewer replacing a malformed or incomplete verdict remains at that - review stage until a valid verdict identifies the exact revision reviewed. +- A reviewer replacing a malformed or incomplete verdict remains at that review + stage until a valid verdict identifies the exact revision reviewed. The role's latest accepted same-stage output supersedes its earlier output at -the active frontier without erasing history. +the active frontier without erasing history. The superseded output stays in the +journal and stays readable; what it loses is the authority to carry the item +forward. ### 2.2 Backward invalidation @@ -96,7 +154,7 @@ invalidates. Forward progress then traverses every later stage again: | Returned to | Still valid | Must run again | | --- | --- | --- | -| Product Owner (1) | Nothing downstream | 2-7 | +| User (1) | Nothing downstream | 2-7 | | Architect (2) | Product decision | 2-7 | | Planner (3) | Product decision and architecture | 3-7 | | Implementor (4) | Product decision, architecture, and plan | 4-7 | @@ -105,17 +163,16 @@ invalidates. Forward progress then traverses every later stage again: The workflow validates a backward outcome against this frontier. The role supplies the reason and earliest invalidated stage; the XMD procedure decides -which accepted downstream handoffs become inactive and records that decision. -A Project edit alone never proves invalidation or approval. +which accepted downstream handoffs become inactive and records that decision. A +Project edit alone never proves invalidation or approval. ### 2.3 Revision invalidation Every Stage 5, Stage 6, and Stage 7 conclusion identifies the exact `{ headSha, baseSha }` it evaluated. -- Any merge, rebase, manual edit, generated mutation, conflict resolution, or - other change to `headSha` remains at or returns to Stage 4 and invalidates - Stages 5-7. +- Any merge, manual edit, conflict resolution, or other change to `headSha` + remains at or returns to Stage 4 and invalidates Stages 5-7. - Movement of `baseSha` without a head change invalidates the review context at Stage 5. The item repeats Stages 5-7 against the new pair when no synchronization or implementation correction is required. @@ -124,61 +181,273 @@ Every Stage 5, Stage 6, and Stage 7 conclusion identifies the exact - An invalidated Planner verdict returns to Stage 5. An invalidated Architect verdict returns to Stage 6. -Stage 6 may accept a Planner verdict only when that verdict names the same -implementation revision. Stage 7 may accept the review chain only when both -review verdicts name the current revision. A later SHA never inherits a verdict -for an earlier pair. +Stage 6 accepts a Planner verdict only when that verdict names the same +implementation revision. Stage 7 accepts the review chain only when both review +verdicts name the current revision. A later SHA never inherits a verdict for an +earlier pair. + +Only an accepted Stage 6 verdict for the current revision is **ready +authority**: it is what authorizes XMD to take the pull request out of draft and +move the item to Stage 7. Ready state observed on GitHub without that accepted +verdict is drift to reconcile, never a substitute for it. The workflow definition SHA does not participate in this invalidation table. Definition incompatibility is a workflow lifecycle refusal, not an implementation correction. -## 3. Durable procedure and GitHub projection +### 2.4 Terminal kinds + +Stage 7 -> 8 is the adjacent terminal transition, and it has exactly two kinds. +A **merged** terminal records that the reviewed revision reached the target +branch. An **abandoned** terminal records that an authorized human ended the run +without publishing it. Both are described in §10, which owns their effect +ordering; what matters here is that they are the only two ways an admitted run +becomes terminal, and that each names the exact reviewed revision it settled on. + +## 3. Deployment topology + +Three hosts carry one run, and each owns something the others cannot reach. + +```text +GitHub App (ingress, credentials) + │ webhook / form / repository_dispatch + ▼ +Cloudflare Durable Object ── SQLite ── WorkflowRun, Workspace roots, journal + ▲ + │ authenticated WebSocket (executor acquisition) + ▼ +GitHub Actions ephemeral runner ── native Git, evidence processes, Agent clients +``` + +**One SQLite-backed Cloudflare Durable Object, selected from the run ID, owns +the run.** It holds the WorkflowRun record and its filtered journal, the +immutable Workspace roots and their content-addressed bytes, the Agent-session +mappings and checkpoints, pending answers and Stage 7 decisions, the retained +intake records, and executor ownership. There is one durable owner per run, +selected arithmetically from the run ID exactly as local discovery is, so no +second registry can disagree with it. + +**Exported `.xmd` artifacts are immutable evidence only.** They are never live +state, discovery, continuation, answer delivery, or lock authority, and no +Actions artifact is any of those either. An artifact says what a run had +committed at one frontier; it does not say what a run may do next. + +**One authenticated Durable Object WebSocket connection owns the executor +acquisition.** The acquisition is the connection's lifetime and nothing else. It +has no duration, expiry, renewal, heartbeat, PID, liveness poll or application +lease, exactly as the local executor lock has none. Closing the WebSocket +invalidates the acquisition and releases executor ownership; it does not roll +back state that already committed. A second healthy executor follows the active +one or is refused, and cannot advance the run either way. + +**The ephemeral Actions runner executes what cannot run inside a Durable +Object.** Native Git, plan evidence processes and Agent clients run there, +against bounded materialized state, and only there. The runner materializes one +selected retained Workspace root, submits content-addressed changes, and the +Durable Object validates the acquisition, the expected root and the content +before it atomically publishes the new root together with the filtered journal +result. A runner crash therefore exposes only a prior or a new complete +transaction; a later connection performs stale recovery and resumes from the +exact committed WorkflowRun and Workspace frontier. + +**The Cloudflare runtime-named host owns persistence and admission.** Durable +transactions, intake, the authorization gates, token minting and executor +admission are its. Shared production modules stay provider-neutral: they do not +detect Cloudflare, Deno, GitHub Actions or any other runtime, and they reach +every host-specific behavior through the contextual APIs that already exist. + +**A completed replay reads its own history and nothing else.** It may reach and read the run's durable owner, because that owner is where the retained result is and an ephemeral client holds nothing to replay from. It attaches no Workspace, Agent, process, Git, Git-host, Issue, Project, credential or other external-effect provider, performs no effect again and starts no native operation. Reading retained completion from its authoritative owner is lifecycle storage access, not external-effect replay, and it is that second thing §10's terminal ordering exists to keep unnecessary. + +## 4. Durable procedure and GitHub projection The XMD workflow is the only procedure that may change the factory stage. On a -start or resume it: +start or resume the executor: -1. acquires the durable run's executor lock; +1. acquires the run's executor connection; 2. restores the retained workflow definition, Workspace, handoff chain, and incomplete effects; 3. observes the issue, draft pull request, Project item, and exact Git revision identities required by the current stage; 4. reconciles interrupted GitHub effects under their retained identities; -5. renders the active handoff chain and authorized observations to the current +5. consumes any retained answer or Stage 7 decision inside the run's + transaction, appending its accepted durable event exactly once; +6. renders the active handoff chain and authorized observations to the current role; -6. validates the role's structured outcome against the current stage, artifact +7. validates the role's structured outcome against the current stage, artifact identities, and implementation revision; -7. records a same-stage iteration or invalidates the frontier, or performs the +8. records a same-stage iteration or invalidates the frontier, or performs the immediately adjacent successful transition; -8. performs the authorized issue, pull-request, Git, and Project effects; and -9. journals the accepted decision and every effect before yielding the next - durable boundary. +9. performs the authorized issue, pull-request, Git, and Project effects; and +10. journals the accepted decision and every effect before yielding the next + durable boundary. + +Every step above is executor-owned. 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 validate the exact live acquisition and the expected +Workspace root inside every mutation transaction. An acquisition that is closed, +foreign or stale reaches no mutation. + +Two kinds of operation are deliberately outside that list. + +**Delivery is not execution.** Authenticated webhook and form intake, typed +answer delivery, and Stage 7 decision delivery are delivery-plane transactions. +Each takes no executor acquisition, starts no execution, attaches no Workspace, +Agent or process provider, appends no lifecycle outcome and changes no run +status. Each validates its exact delivery identity and its exact pending +suspension or decision subject, and retains only the typed bounded value that +subject describes. This is the answer-delivery contract the workflow +specification already states, applied unchanged to a remote host and extended to +Stage 7 decisions. + +**Inspection is not authority.** Status and history reads are read-only, take no +executor acquisition, and cannot become transition authority. They observe the +same immutable snapshot surface local inspection observes. + +A later executor is what turns a retained answer or decision into progress. It +consumes the retained value inside the run's own transaction, appends the +accepted durable event or outcome once, and only then may authored XMD choose +the next transition. Delivery stores; execution decides. GitHub Actions supplies the trusted executable, the definition reference, the run identity, and credentials or provider configuration within a fixed ceiling. Its YAML does not parse role conclusions, choose stages, construct handoffs, change draft state, merge, comment, or update the Project independently of the -XMD workflow. - -Actions concurrency may reduce duplicate invocations, but it is not the -executor lock and cannot authorize a transition. A second invocation of the -same item follows or is refused by the durable run lifecycle. +XMD workflow. Actions concurrency may reduce duplicate invocations, but it is +not the executor acquisition and cannot authorize a transition. -The Project status is a human-facing projection of the journaled current stage. -Issue and pull-request comments are human-readable transition records. The XMD -journal is the durable execution record of which source records were accepted, -which role conclusion won, and which external effects completed. A Project -status ahead of the journal is drift, not proof that omitted roles passed. +The XMD journal is lifecycle authority. The Project status is a human-facing +projection of the journaled current stage, and issue and pull-request comments +are human-readable transition records. A Project status, comment or pull-request +state ahead of the journal is drift to reconcile, never proof that a skipped +role passed. GitHub offers no transaction spanning a comment, draft state, Project status, -branch update, or merge and the retained XMD store. Each is therefore a durable -external effect with stable identity and reconciliation: observe, adopt an -already-compatible result, perform from proven absence or compatible pre-state, -or refuse conflict and ambiguity. Interruption may leave GitHub ahead of the -local result; resume reconciles the same intended effect rather than repeating -it blindly. +branch update, or merge and the retained XMD store, and none is claimed across +Durable Object state, native Git and processes, GitHub and Project V2. Each +GitHub mutation is therefore one durable external effect with a stable +engine-derived identity and an effect-specific natural key: it observes before +it mutates, adopts only a compatible completion, performs once from proven +absence or an exact compatible pre-state, and refuses conflict, permanent +ambiguity, incomplete observation and temporary unavailability. Cancellation +tears the provider call down and publishes no invented completion; an +interrupted remote completion is reobserved and adopted only when it matches the +retained intent; a completed replay contacts no provider at all. + +The natural keys are exact. A comment uses its subject plus the engine-derived +effect identity, so the body and title are presentation rather than identity. A +Project status uses the exact Project item plus field. Ready, close and the +merged observation use their exact issue or pull-request subject. Target +publication uses the retained repository plus the configured remote and target +ref. Accepted outcomes are +retained before their projections are attempted, and reconciliation completes +each intended projection. + +## 5. Authenticated ingress + +A dedicated GitHub App is the factory's only ingress. It receives Project and +admission webhooks and authenticated human form submissions, and it is the +principal every GitHub effect is performed as. + +### 5.1 Order of operations + +An intake is admitted in this order, and no step may be reordered: + +1. **Verify before parsing.** The webhook signature is verified against the + App's webhook secret before the payload is parsed as anything but bytes. +2. **Reread from the API.** The complete GitHub objects — issue, repository, + Project, Project item, status field — are reread through the API. The + payload's copy of them is a notification, not a source of truth. +3. **Authenticate the installation and the actor.** The installation is resolved + for the exact repository, and the human actor is resolved as an identity + rather than a display name. +4. **Retain one bounded intake.** The intake is keyed by the GitHub delivery + identity for a webhook, or the submission identity for a form, and retains + only bounded typed fields. + +Admission requires a configured organization-owned Project V2 and the exact +repository, Project, Project item, status field and allowed option IDs. A +missing page of results, an unavailable field, an ambiguous object and a partial +permission read are all **unavailable** — they are neither absence nor +authorization. Treating an unreadable Project as an empty one is how an +unauthorized item would be admitted. + +**A configured table maps status options to stages, in both directions.** It is a total bijection between the nine `FactoryStage` values of §11.2 and nine exact Project V2 status option IDs: one option ID for every stage `0` through `8`, and every configured option ID appearing exactly once. It is host configuration and part of the admission and projection ceiling — never an authored prop, and never something a provider payload can supply. + +The table is validated before it is used. Startup and admission refuse — before an intake is retained, a token is minted, a run is started, or anything is projected — when the table is missing, partial, holds a duplicate, names an option the Project does not currently offer, names an option belonging to another field or another Project, or names an option whose display name reread from GitHub is not the settled §2 status string for its stage. + +Admission maps the completely reread option ID to a stage through that table, and projection maps a retained stage back to its option ID through the inverse. Neither direction parses a display string: the strings are what a person reads, the option IDs are what the factory compares, and a status renamed on the board is a configuration refusal rather than a silent remapping. + +Only the configured `Backlog`-to-`User` movement admits a new item. A Project edit at any other point is projection drift or an authenticated intake to reconcile, and is never a role verdict. + +A duplicate delivery of the same identity finds the retained intake and changes +nothing. That is the same compatible-reuse rule run identity uses, applied to +intake. + +### 5.2 Waking Actions carries no decision + +`repository_dispatch` carries only the retained intake identity. The receiving +Actions workflow may be woken by it, but the payload never carries or derives a +stage, a role outcome, answer text, a decision, a transition, a credential, or a +mutable factory definition. Everything the run needs it reads from the Durable +Object after it has authenticated. + +### 5.3 The runner authenticates with OIDC + +The Actions job authenticates to the provider through GitHub OIDC. Before it +admits a session, the provider validates the issuer, the configured audience, +the repository ID, the repository-owner ID, the event name, the workflow ref and +SHA, and the configured immutable workflow identity. Repository *names* are +mutable and are not what is checked; IDs are. + +### 5.4 Human answers and decisions + +Human answers and Stage 7 decisions arrive through a GitHub-App-authenticated +web form bound to the exact retained suspension or decision subject. The form +submission is a delivery-plane transaction under §4. + +**Comment text is never authority.** A comment never answers a question, merges, +authorizes a change, abandons a run or resumes execution. Comments are +transition records a person reads. + +A human may authorize admission, an answer, a Stage 7 change, a merge or an +abandonment only while holding Project write and repository write-or-higher +access, checked at the moment of the submission. The short-lived App +installation token performs the GitHub effects that follow; the journal retains +the human actor separately from the token that acted, so the record says who +decided as well as what was done. + +## 6. Principal, permissions and host ceilings + +The GitHub App has exactly these repository permissions — Metadata read, +Contents write, Issues write, Pull requests write, Checks read, Commit statuses +read — and organization Projects write. It has no Administration, Actions write, +Workflows write, Secrets, Environments, Deployments or Members permission, and +no force-push authority anywhere. + +The App installation is limited to configured repositories. The host narrows +further, per operation, to the exact repository, implementation branch, target +branch, organization Project, status field, option IDs, issue or pull-request +subject, reviewed revision, parent pair, and non-force operation. A ceiling the +installation grants is not a ceiling the host uses. -## 4. Issue and draft pull-request boundary +Three more ceilings are host configuration on the same terms, none of them an authored prop: the stage-to-option bijection of §5.1; the merge authority `Git.Merge` validates its authored parents against, which supplies the current implementation head and observed target base for a synchronization and the reviewed `{ headSha, baseSha }` the Stage 7 decision authorized for a publication; and the bounded retry — count, total duration and backoff — the merged observation of §10.4 runs under. `Evidence.Run`'s executable, environment, working-root, per-command duration, whole-run duration, output and process-tree ceilings are the same kind of configuration, stated in [Workflow workspaces](./workflow-workspace-spec.md) §10.5. + +Mutation of `.github/workflows/**` is refused, even though Contents write could +otherwise reach it. A factory that can rewrite the workflow that runs it is a +factory that can rewrite its own authorization. + +GitHub App private keys, webhook secrets, OIDC verification configuration, +issued installation tokens, provider endpoints, raw payloads, cursors and host +paths are provider secrets and closure state. None of them enters props, context +composition data, durable requests or results, comments, output or diagnostics. + +The configured target ruleset admits only this factory's dedicated App for the +target-branch update of §10, and host validation independently enforces the +target and the reviewed parent pair. Neither substitutes for the other: the +ruleset says who may write, the host says what may be written. + +## 7. Issue and draft pull-request boundary The issue owns product intent, architecture, planning, and the transition into implementation. @@ -191,8 +460,8 @@ implementation. Stage 4 creates or updates a draft pull request before its first handoff to Planner Review. The Stage 4 -> 5 handoff identifies the draft pull request and -its exact `{ headSha, baseSha }`. The draft pull request then owns implementation -iterations and Stage 5-7 review handoffs. +its exact `{ headSha, baseSha }`. The draft pull request then owns +implementation iterations and Stage 5-7 review handoffs. When invalidation crosses from the pull request to Stages 1-3, the XMD workflow writes the full handoff on the issue and a short linking record on the pull @@ -200,62 +469,65 @@ request. When the accepted issue chain crosses back into Stage 4, the draft pull request links the accepted issue handoff before implementation continues. The pull request remains draft throughout Stage 4 corrections and Stage 5 -review. It also remains draft while Stage 6 requests a backward correction. -Only an accepted Stage 6 verdict for the current revision authorizes XMD to make -the pull request ready and move the item to Stage 7. If interruption separates +review. It also remains draft while Stage 6 requests a backward correction. Only +the ready authority of §2.3 takes it out of draft. If interruption separates those GitHub effects, the run remains at its last journaled frontier and resume -reconciles both; ready state alone does not manufacture an Architect verdict. +reconciles both. -Stage 7 -> 8 is the adjacent terminal transition. It records `merged` or -`abandoned`, the reviewed implementation revision, the actor and decision that -authorized closure, and the resulting merge identity when one exists. Closing -the Project item never erases the issue, pull request, handoff history, or XMD -journal. +Closing the Project item never erases the issue, pull request, handoff history +or XMD journal. -## 5. Stage 4 base synchronization +## 8. Stage 4 base synchronization -The initial factory synchronizes a draft implementation branch by merging the -latest observed target base into it. It does not rebase published work. +The factory synchronizes a draft implementation branch by merging the latest +observed target base into it. It does not rebase published work, and it never +force-pushes. -A merge preserves published commit identities, produces a descendant that the -normal non-force Push effect can publish, and allows the final pull request to -use a squash merge when the repository's delivery policy wants a compact target -history. A rebase changes published identities and requires a separately -specified, reconciled force-with-lease effect. No factory role, generated XMD, -or GitHub Actions step has that effect in the initial contract. Force pushes are -refused. +A merge preserves published commit identities and produces a descendant the +ordinary non-force Push effect can publish. Rebase, force push, force-with-lease +and reset-based replacement are absent from this contract; adding any of them +later takes a new external-effect contract with its own reconciliation semantics +and invalidation proof, and cannot be represented as another spelling of Push. A clean base synchronization follows this durable sequence: 1. XMD observes and checkpoints the exact implementation head, target base, and merge base. 2. XMD performs the trusted merge against those identities inside the retained - Workspace. + Workspace, with parents ordered `[implementationHead, targetBase]`. 3. XMD records the resulting merge commit and Workspace root as a new implementation revision. 4. The item remains at Stage 4 because its head changed. -5. XMD runs the implementation evidence selected by the accepted plan. +5. XMD runs the implementation evidence the accepted plan selected. 6. When that evidence passes, XMD publishes the exact descendant through the ordinary non-force Push effect and offers the latest `{ headSha, baseSha }` to Planner Review. The merge, evidence, push, and Stage 4 pass are separate durable effects. No -transaction is claimed across native Git, test processes, GitHub, and the +transaction is claimed across native Git, evidence processes, GitHub and the Project. Resume restores completed effects and continues from the first uncommitted one. A manually changed branch is not adopted as a passed implementation. XMD first -observes its new exact head and base, records a new implementation revision, -and re-enters Stage 4. The Implementor and selected evidence evaluate that +observes its new exact head and base, records a new implementation revision, and +re-enters Stage 4. The Implementor and the selected evidence evaluate that revision before it can return to Stage 5. -## 6. Conflict boundary +## 9. Conflict boundary + +Every Git conflict suspends. There is one profile, and this is it: a conflicted +merge returns a closed conflicted result, restores the pre-merge Workspace root, +retains complete normalized conflict evidence, publishes an actionable handoff, +and suspends for manual resolution. No conflict is resolved automatically, by +generated XMD, or by any structured text-conflict capability. -Tool-less conflict handling grants no tool or checkout to the Agent. The Agent -observes source evidence rendered by XMD and proposes desired file contents; -XMD owns every inspection and mutation. +Tool-less conflict handling grants no tool and no checkout to the Agent. The +Agent observes source evidence rendered by XMD; XMD owns every inspection and +mutation. The workflow Agent still receives no Git or GitHub operation, no +filesystem or shell operation, no Workspace, checkout, Repository or host path, +no native tool, and no MCP server carrying equivalent authority. -### 6.1 Durable conflict identity +### 9.1 Durable conflict identity Before attempting a merge, XMD checkpoints: @@ -271,171 +543,485 @@ conflict set. Every conflict entry identifies: - the exact repository-relative path; - the conflict classification; -- the base, ours, and theirs object identities and modes when Git supplies - them; and +- the base, ours, and theirs object identities and modes when Git supplies them; + and - the corresponding stage numbers for entries retained in the unmerged index. The journal retains structured identity and classification, not only rendered -conflict markers. The conflict profile decides what happens to the native merge -state: - -- The suspend-on-conflict profile rolls the conflicted checkout back, publishes - the structured evidence against the unchanged pre-merge Workspace root, and - enters a durable human wait. It never offers a file mutation under that - evidence. -- A structured-resolution profile retains the conflicted Git state and its - Workspace root with the conflict result, or retains provider-owned state that - reconstructs that exact state and verifies the same conflict identity before - mutation. A later resume cannot combine evidence from one merge attempt with - the index of another. +conflict markers. It retains them so that a later capability could be specified +against real evidence, and so that a stale resolution can be rejected without +inspecting Agent output or trusting current Project state — not because anything +in this contract mutates under them. A changed head, target base, merge base, Repository identity, conflict set, or Workspace root makes the conflict admission stale. XMD discards no remote -history and grants no mutation under that stale identity; it observes the new +history and grants no mutation under a stale identity; it observes the new revision and restarts Stage 4. -### 6.2 Agent observation and proposal +### 9.2 Manual resolution + +Human resolution occurs outside the workflow Agent's authority, by pushing to +the implementation branch. On resume, XMD observes that push as a new Stage 4 +`{ headSha, baseSha }`, records the manual intervention, runs new implementation +evidence against it, and re-enters Stage 4. It inherits no Stage 5-7 conclusion: +no human edit carries the conflicted attempt's review verdicts forward. + +Ordinary text conflicts, binary files, submodules, ambiguous renames, unsafe +symbolic links and unrecognized index forms are classified and retained +identically, because they all suspend. Classification is evidence for the human +reading the handoff, not a branch in the procedure. + +## 10. Stage 7 target publication and terminal settlement + +Stage 7 is where the run leaves the factory, and its ordering is what makes a +completed replay provider-free. + +### 10.1 The merge is constructed, not requested + +Stage 7 merge creates a trusted merge commit whose **first parent is the exact +reviewed `baseSha`** and whose **second parent is the exact reviewed +`headSha`**. That is the opposite order from Stage 4's synchronization merge, +and the difference is deliberate: Stage 4 brings the target into the +implementation, Stage 7 brings the implementation onto the target. + +It does not call an ordinary GitHub squash, rebase or merge endpoint. Final +delivery is this constructed merge commit and nothing else, so the reviewed +parent pair is preserved in the published history rather than replaced by a +commit no reviewer saw. + +### 10.2 Target publication is a compare-and-swap + +`Git.PublishTarget` receives the exact merge commit, the reviewed head, and the +expected remote `baseSha`. The repository, remote, target ref, credential and +non-force policy are host-owned and are not authored props. + +It performs one non-force ref update only after observing the target equal to +`baseSha`. It adopts a target already equal to the exact merge commit, with +nothing performed. Every other observation — a target at another commit, an +incomplete observation, a permanent ambiguity, a temporary unavailability — +refuses or reconciles without mutating anything. + +A race that moves the target before or during publication therefore cannot +publish over it. Because the reviews name exact revisions, that race invalidates +them: the item returns to Stage 5 when only rereview is required, and to Stage 4 +when synchronization or implementation work is required. + +`Git.Push`, `Git.PublishTarget`, `Git.Merge`, pull-request upsert, `PullRequest.Ready`, `PullRequest.Close` and `PullRequest.Merged` are seven distinct operations with distinct subjects, ceilings and reconciliation. None is a spelling of another, and in particular publishing a target and observing that a pull request merged are two facts a Git host can hold separately. + +### 10.3 Terminal ordering + +There are two terminal paths and they share no step list. Each begins by retaining its authenticated exact-revision decision **before** any effect is attempted, and each ends with terminal settlement after every step before it has completed. A `change` decision is on neither path: it is not terminal, and it returns the run to the earliest stage it names. + +**The merged path**, in this order: + +1. Retain the `merge` decision of §11.2, bound to the exact reviewed revision and the authenticated actor. +2. Construct the trusted merge commit — `` with parents `[reviewedBase, reviewedHead]`. +3. Publish the target — `` under §10.2. +4. Observe that the pull request merged — ``, the reconciled Git-host observation of [Workflow workspaces](./workflow-workspace-spec.md) §7.11, against the published merge commit. A Git host records a pull request as merged on its own schedule, so this is its own retained step and not something publication implies. §10.4 says what the run does while it has not caught up. +5. Close the issue — ``. +6. Move the Project item to `Closed` — ``. +7. Publish terminal kind `merged`. + +**The abandoned path**, in this order: + +1. Retain the `abandon` decision of §11.2, bound to the exact reviewed revision, the authenticated actor and its required reason. +2. Close the pull request unmerged — ``. +3. Close the issue — ``. +4. Move the Project item to `Closed` — ``. +5. Publish terminal kind `abandoned`. + +An abandonment constructs no merge, publishes no target and observes no merged state; there is nothing it reviewed that it is publishing. A merge closes no pull request; the Git host closes it when the target moves, which is what step 4 observes rather than performs. + +Every step on either path is a separate reconciled effect or a separate retained transition, and no distributed transaction is claimed across the Durable Object, native Git and processes, GitHub and Project V2. 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. If terminal completion preceded a projection, the replay that was supposed to repair GitHub would be exactly the replay that is forbidden to reach it. -XMD renders the required conflict evidence and only the related source -observations to the Implementor. The workflow Agent still receives: +Both terminal kinds retain the authorizing actor, the exact reviewed revision, the resulting provider identities, and the reason where one is required, in the `FactoryTerminal` record of §11.2 — whose two shapes differ exactly as these two paths do. Both retain the Project item, the implementation branch, the issue and pull-request comments, the journal, the Workspace roots and the Agent evidence. Reopening the issue afterwards does not reopen the completed run; continuing that work requires a new linked issue, and therefore a new run. -- no Git or GitHub operation; -- no filesystem or shell operation; -- no Workspace, checkout, Repository, or host path; -- no native tool; and -- no MCP server carrying equivalent authority. +### 10.4 Waiting for the host to notice -The Implementor returns generated XMD containing proposed file writes and -deletions. That source is untrusted data until the workflow admits it. +A pull request observed still open after a successful target publication is temporary unavailability, not absence and not refusal — the Git host has not yet recognized its own ref moving. The factory waits for it in two stages, and neither is a human decision. -When the structured-resolution profile is installed, conflict resolution adds -a conflict-scoped generated-XMD admission. It is narrower than the workflow -host's ordinary write table: +The authored factory first performs a **bounded retry** around `PullRequest.Merged`. The retry count, total duration and backoff are host ceilings configured for the deployment; they are not props on the component and no document widens them. -- every write or deletion must name an exact path in the authorized conflict - set; -- the recorded Repository, head, base, merge base, conflict identity, and - Workspace root must still match; -- no generated component may stage, commit, merge, push, invoke a process, read - a credential, update GitHub, or mutate a non-conflict path; and -- the admitted source and complete ceiling are retained before the first file - effect. +When that retry is exhausted while the pull request is still open, the run enters a **machine wait** at Stage 7. -The Agent decides desired file contents. XMD applies the admitted file effects, -stages the exact conflict scope, verifies that the index contains no unresolved -entries and no unauthorized mutation, creates the merge commit with the -checkpointed head and base as its parents, runs the selected implementation -evidence, and publishes the resulting descendant through the ordinary -non-force Push effect. +A machine wait is a second kind of durable wait, and it is deliberately not a suspension in the typed-answer sense. A typed suspension publishes a request and a response schema and ends when somebody delivers one value that satisfies it. This wait asks nobody anything: it ends because a later execution looked at a provider again. It therefore has no response schema, no `xmd workflow answer` route, no web form and no bound value, and nothing about the typed-answer protocol — `suspension_request`, `suspension_answer`, a suspension ID — takes part in it. It is a second wait *kind* inside the existing lifecycle, never a second lifecycle controller. -The item remains at Stage 4 throughout. A clean result is a new implementation -revision, not a review pass. +What it does share is the lifecycle boundary. The retained wait event and the `suspended` run status commit together, the executor acquisition is released only after that commit, and a settlement the host refuses publishes neither. Its retained event kind is `machine_wait`, distinct from `suspension_request`, and its stable identity is a `waitId` the trusted execution derives from the run and the authored expansion on the same engine-owned terms every other durable position uses. The run's stop reason references that filtered `machine_wait` event, so inspection reports that the run is waiting on provider state and offers no response schema and no answer command. -### 6.3 Conflict classes +The wait's subject is the canonical pull-request URL, the expected merge commit, the retained merge-decision event identity, the current implementation revision and `retriesExhausted: true`, retained as the `MergedObservationWait` record of §11.2. -The protocol classifies every conflict before asking the Implementor for a -proposal. Ordinary text conflicts may be admitted by a bounded text-conflict -capability. Binary files, submodules, ambiguous renames, unsafe symbolic links, -unrecognized index forms, and every other unsupported class suspend for human -resolution. A mixed set containing one unsupported entry is unsupported as a -whole; no partial Agent mutation is admitted. +**Waking is permission to look again, not an answer.** Two sources may wake it. An authenticated intake for a relevant pull-request state change, correlated to that exact wait subject, is retained as a delivery-plane transaction under §5: it takes no executor acquisition, appends no lifecycle outcome, changes no run status, and supplies no answer, verdict, stage, transition or observation result. It records a bounded wake notification and nothing else. An authorized operator may also resume the run, which is executor-side control rather than a delivery — a resume is not a forged intake. -Human resolution occurs outside the workflow Agent's authority. On resume, XMD -accepts the manually changed branch only by observing its new exact -`{ headSha, baseSha }`, recording the manual intervention and re-entering Stage -4. No human edit inherits the conflicted attempt's review conclusions. +A duplicate wake notification changes nothing. One naming another wait, a spent wait, an invalidated wait or a terminal run refuses and leaves the active wait exactly as it was. -## 7. Structural consequences +A later executor consumes one retained wake notification inside the run's transaction and appends one filtered `machine_wake` event for that exact `waitId`; an authorized operator resume appends the same event with `source: "operator-resume"`. Consuming the wake and ending the retained wait are one transaction, so a crash before it commits leaves both the wait and the notification pending, and a replay after it commits restores the wake event without consuming or appending anything again. -This factory adds the following structural contracts. +After the wake event, authored control flow invokes `PullRequest.Merged` again. Only the compatible adoption of §7.11's first row advances the terminal sequence. An observation that is still open may retry and wait again at a new durable position; a merge at another commit and a pull request closed unmerged remain conflicts and end the wait as one rather than continuing it. -### 7.1 XMD workflow +An explicit resume with neither a pending provider wake nor operator-resume authority ends nothing. It reports the same machine wait and settles `suspended` again. Cancellation follows ordinary run cancellation and invents neither a wake nor a merged observation. + +The wait appends no lifecycle outcome and moves no stage, and terminal settlement stays absent until the observation adopts. + +## 11. Exact public contracts + +### 11.1 Authored construct inventory + +These are the authored public forms. `as` is mandatory wherever a result is +bound, and form validation runs before any context, provider or credential +access. Durable effect identity is always engine-derived from the run and the +expansion; it is never a document prop. + +| Construct | Exact authored form and result | Owner and durable boundary | +| --- | --- | --- | +| `Issue.Comment` | Paired ``; the content is the body; binds `{ url }` | Issue-provider effect; the natural key is the canonical issue URL plus the engine effect identity, so the body is not identity | +| `PullRequest.Comment` | Paired ``; the content is the body; binds `{ url }` | Git-host effect; canonical pull-request URL plus engine effect identity | +| `Project.Status` | Self-closing ``; binds the normalized `{ item, field, option }` | Project-provider effect; the exact configured Project, item, field and option ceiling, against the current option as pre-state | +| `PullRequest.Ready` | Self-closing ``; binds normalized ready pull-request evidence | Git-host effect; only an accepted Stage 6 outcome authorizes invocation | +| `PullRequest.Close` | Self-closing ``; binds normalized `{ url, state: "closed", merged: false }` | Git-host effect; used only by a retained abandonment | +| `Issue.Close` | Self-closing ``, or the same form with `reason="not_planned"`; binds the normalized URL, state and reason | Issue-provider effect; `reason` is a closed enum and must match the retained terminal intent | +| `Git.Merge` | Self-closing ``, or the same form with `purpose="publish"`; binds the `GitMergeResult` union of [Workflow workspaces](./workflow-workspace-spec.md) §7.8 — `{ outcome: "clean", purpose, firstParent, secondParent, mergeBase, commit, workspaceRoot }` or `{ outcome: "conflicted", purpose, firstParent, secondParent, mergeBase, workspaceRoot, conflicts }` | Workspace-local Git effect; repository, checkout, root and acquisition are authenticated provider state; a clean publication is atomic and a conflict restores before the result is published | +| `Git.PublishTarget` | Self-closing ``; binds normalized target, expected and published evidence | Git-host effect; remote, ref, credential and non-force ceiling are host-owned; exact compare-and-swap reconciliation | +| `Evidence.Run` | Self-closing ``, where `commands` is an ordered non-empty list of non-empty argv vectors; binds the `EvidenceRunResult` of [Workflow workspaces](./workflow-workspace-spec.md) §10.5 — `{ completion, authoredCommands, executed, runTimeout? }`, where each executed row is `{ argv, outcome, status?, signal?, limit?, stdout, stderr }` and each channel is `{ text, retainedBytes, producedBytes, truncated }` | Trusted runner-host effect; the exact retained root and the executable, environment, working-root, per-command duration, whole-run duration, output and process-tree ceilings; a fail-fast pipeline binding the executed prefix, launch/output-pump/teardown failures binding no result and retaining bounded error evidence, cancellation committing nothing; absent from Agent and generated-XMD capabilities; a completed replay runs nothing | +| `PullRequest.Merged` | Self-closing ``; binds `{ subject, state: "closed", merged: true, mergeCommit, decision: "adopted" }` | Git-host reconciled observation, [Workflow workspaces](./workflow-workspace-spec.md) §7.11; it mutates nothing and adoption is its only completion; keyed by the canonical pull-request URL | +| Remote `WorkflowHost` | The existing four-method host boundary — `useRunHost()`, `useLifecycle()`, `useDelivery()`, `attach()` — with a Cloudflare runtime-named implementation beside the Deno one; start, lookup, execute, deliver and inspect are lifecycle operations reached through it rather than method names of their own, and a remote host receives no transitions type of its own. Its transition and request types are provider-neutral and become package-root public types; the runner-to-owner messages are private to one release, admitted by an exact build fingerprint ([Workflow workspaces](./workflow-workspace-spec.md) §13.2) | A host assembly contract rather than an XMD component; the execution, delivery and inspection planes stay distinct across it | +| Factory protocol records | The closed versioned schemas of §11.2 | A provider-neutral durable protocol; neither an XMD component nor a TypeScript lifecycle controller | + +Each construct's closed props, form, binding, request, natural key, compatible pre-state, normalized result, refusal and unavailability behavior, cancellation, replay, provider ownership, credential boundary, and whether it is Workspace-local or an external reconciled effect are defined normatively in [Workflow workspaces](./workflow-workspace-spec.md): §7.8 `Git.Merge`, §7.9 `Git.PublishTarget`, §7.10 `PullRequest.Comment`, `PullRequest.Ready` and `PullRequest.Close`, §7.11 `PullRequest.Merged`, §10.3 `Issue.Comment` and `Issue.Close`, §10.5 `Evidence.Run`, §10.6 `Project.Status`, §10.7 the credential boundary they share, and §13.2 the remote host and its transport. A later implementation may choose ordinary private function and module names; it may not change these public forms, their records or their ownership. + +### 11.2 Factory protocol records + +The factory's lifecycle is journaled as closed immutable records, not held in a controller. These are the schemas the journal retains and every role outcome is parsed into. They are provider-neutral data and parsers; nothing here becomes a TypeScript state machine beside the journal, and nothing here is an XMD component. + +Every record carries `schema`, its discriminant, and `version`, which is `1` for all of them. Parsing is strict in both directions: an unknown `schema`, an unknown `version`, an unknown member, a missing required member, and a value outside a closed enum each refuse the record rather than being ignored or defaulted. A refusal names the member path and never the value behind it, on the same terms retained props and journal payloads are described. + +#### Identities and subjects + +```ts +type FactoryStage = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; + +type FactoryRole = "user" | "architect" | "planner" | "implementor"; + +interface FactorySubject { + readonly schema: "factory-subject"; + readonly version: 1; + readonly runId: string; + readonly authority: string; + readonly issueNodeId: string; + readonly issueUrl: string; + readonly repositoryId: string; + readonly projectItemId: string; + readonly statusFieldId: string; +} + +interface ImplementationRevision { + readonly schema: "implementation-revision"; + readonly version: 1; + readonly headSha: string; + readonly baseSha: string; +} +``` + +`FactoryStage` is the numeric stage; the nine status strings of §2 are its projection and are never parsed back into it. `runId` is the §1.1 derivation, and `authority` and `issueNodeId` are the exact bytes it was derived from, retained so drift is detectable without re-deriving. `repositoryId`, `projectItemId` and `statusFieldId` are provider identities compared byte for byte. `headSha` and `baseSha` are lowercase hexadecimal commit IDs. A revision is compared as the whole pair: two revisions are equal only when both halves are. + +#### Evidence and role outcomes + +```ts +interface EvidenceReference { + readonly schema: "evidence-reference"; + readonly version: 1; + readonly effectId: string; + readonly revision: ImplementationRevision; + readonly passed: boolean; +} + +interface FactoryHandoff { + readonly schema: "factory-handoff"; + readonly version: 1; + readonly stage: FactoryStage; + readonly role: FactoryRole; + readonly actor: FactoryActor; + readonly summary: string; + readonly revision?: ImplementationRevision; + readonly evidence?: readonly EvidenceReference[]; +} + +interface FactoryActor { + readonly schema: "factory-actor"; + readonly version: 1; + readonly kind: "human" | "agent"; + readonly id: string; +} + +type FactoryOutcome = + | { readonly schema: "factory-outcome"; readonly version: 1; readonly kind: "advance"; readonly from: FactoryStage; readonly to: FactoryStage; readonly handoff: FactoryHandoff } + | { readonly schema: "factory-outcome"; readonly version: 1; readonly kind: "amend"; readonly stage: FactoryStage; readonly handoff: FactoryHandoff; readonly supersedes: string } + | { readonly schema: "factory-outcome"; readonly version: 1; readonly kind: "invalidate"; readonly invalidation: FactoryInvalidation } + | { readonly schema: "factory-outcome"; readonly version: 1; readonly kind: "verdict"; readonly verdict: FactoryVerdict } + | { readonly schema: "factory-outcome"; readonly version: 1; readonly kind: "suspend"; readonly suspension: ConflictSuspension }; + +interface FactoryInvalidation { + readonly schema: "factory-invalidation"; + readonly version: 1; + readonly from: FactoryStage; + readonly earliestInvalidated: FactoryStage; + readonly reason: string; + readonly actor: FactoryActor; +} + +type FactoryVerdict = + | { readonly schema: "factory-verdict"; readonly version: 1; readonly stage: 5; readonly role: "planner"; readonly revision: ImplementationRevision; readonly decision: "pass" | "changes"; readonly reason: string; readonly evidence: readonly EvidenceReference[] } + | { readonly schema: "factory-verdict"; readonly version: 1; readonly stage: 6; readonly role: "architect"; readonly revision: ImplementationRevision; readonly decision: "pass" | "changes"; readonly reason: string; readonly plannerVerdict: string }; +``` + +`summary` and `reason` are the only presentation fields, and both are bounded: they are what a comment renders, never what identity compares. `supersedes`, `effectId` and `plannerVerdict` are journal event identities, so a record points at the history it replaces or depends on instead of copying it. An `advance` whose `to` is not `from + 1` refuses, and so does an `amend` whose `stage` is not the current frontier stage. A Stage 6 verdict whose `plannerVerdict` names a verdict for another revision refuses. + +#### Conflict suspension + +```ts +interface ConflictSuspension { + readonly schema: "conflict-suspension"; + readonly version: 1; + readonly revision: ImplementationRevision; + readonly mergeBase: string; + readonly workspaceRoot: string; + readonly conflictIdentity: string; + readonly mergeEffectId: string; + readonly suspensionId: string; +} +``` + +`workspaceRoot` is the *restored* pre-merge root, `mergeEffectId` names the `Git.Merge` event whose conflicted result holds the normalized conflict set of [Workflow workspaces](./workflow-workspace-spec.md) §7.8, and `conflictIdentity` is derived from the checkpointed identities and that normalized set. The conflict set is not copied here: one account of it, in the effect that produced it. + +#### Stage 7 decisions + +```ts +type Stage7Decision = + | { readonly schema: "stage-7-decision"; readonly version: 1; readonly kind: "merge"; readonly revision: ImplementationRevision; readonly actor: FactoryActor; readonly deliveryId: string } + | { readonly schema: "stage-7-decision"; readonly version: 1; readonly kind: "abandon"; readonly revision: ImplementationRevision; readonly actor: FactoryActor; readonly deliveryId: string; readonly reason: string } + | { readonly schema: "stage-7-decision"; readonly version: 1; readonly kind: "change"; readonly revision: ImplementationRevision; readonly actor: FactoryActor; readonly deliveryId: string; readonly earliestInvalidated: FactoryStage; readonly reason: string }; +``` + +All three bind the exact revision they were made against and the authenticated actor who made them, and all three name the delivery identity they arrived under. `abandon` requires a reason and `change` requires both a reason and the earliest stage it invalidates; `merge` takes neither, because approving what two reviews already passed adds no new claim. A decision whose `revision` is not the current frontier revision refuses. `merge` and `abandon` are terminal intents that §10.3 orders; `change` is not terminal and reduces to an invalidation. + +#### Waiting for the merged observation + +```ts +interface MergedObservationWait { + readonly schema: "merged-observation-wait"; + readonly version: 1; + readonly waitId: string; + readonly subject: string; + readonly expectedMergeCommit: string; + readonly decisionId: string; + readonly revision: ImplementationRevision; + readonly retriesExhausted: true; +} + +interface MergedObservationWake { + readonly schema: "merged-observation-wake"; + readonly version: 1; + readonly waitId: string; + readonly source: "provider-intake" | "operator-resume"; + readonly intakeId?: string; +} +``` + +`waitId` is the machine wait's own identity, derived from the run and the authored expansion; it is not a suspension ID, and no typed-answer record names it. `subject` is the canonical pull-request URL and `decisionId` names the retained `merge` decision this wait belongs to. `retriesExhausted` is literal: the record exists only after §10.4's bounded retry has run out, so a wait retained before that would be a run skipping the cheap path. The wait carries no stage, no outcome, no verdict and no response schema. + +`MergedObservationWake` is what a later executor appends for that exact `waitId`. `intakeId` is required exactly when `source` is `"provider-intake"` and absent exactly when it is `"operator-resume"`, because the operator path is authenticated executor-side control rather than a delivery, and a wake that claimed an intake it does not have would be a forged one. A wake says only that another observation attempt may occur; it carries no observation result, and consuming it authorizes exactly one reobservation. + +#### The configured stage-to-option table + +```ts +interface StageOptionTable { + readonly schema: "stage-option-table"; + readonly version: 1; + readonly projectId: string; + readonly statusFieldId: string; + readonly options: readonly StageOption[]; +} + +interface StageOption { + readonly stage: FactoryStage; + readonly optionId: string; + readonly displayName: string; +} +``` + +`options` holds exactly nine entries, one per stage `0` through `8`, ordered by `stage`. Every `optionId` is distinct, and every `displayName` equals the §2 status string for its stage. This is the retained form of §5.1's configuration: it is validated against a complete reread of the Project before it is used, and a table that does not satisfy every one of those conditions refuses rather than being partially applied. + +#### The active frontier and its reduction + +```ts +interface FactoryFrontier { + readonly schema: "factory-frontier"; + readonly version: 1; + readonly stage: FactoryStage; + readonly revision?: ImplementationRevision; + readonly accepted: readonly string[]; + readonly terminal?: FactoryTerminal; +} +``` + +`accepted` is the ordered list of journal event identities forming the active chain, oldest first, one per stage that has been passed. `revision` is absent before Stage 4 produces one. `terminal` is present only on a settled run. + +The frontier is a reduction over the retained outcomes, and its inputs and outputs are exactly these: + +| Input | Resulting frontier | +| --- | --- | +| `advance` from stage *n* to *n + 1* | `stage` becomes *n + 1*; the handoff's event is appended to `accepted` | +| `amend` at the current stage | `stage` is unchanged; the superseded event is replaced in `accepted` by the amending one, and the superseded event stays in the journal | +| `invalidate` naming earliest stage *e* | `stage` becomes *e*; every entry in `accepted` for a stage at or after *e* is dropped from the active chain and kept in the journal | +| a head change: a new `headSha` | `stage` becomes 4, `revision` becomes the new pair, and the Stage 5, 6 and 7 entries drop | +| a base-only change requiring no work | `stage` becomes 5, `revision` becomes the new pair, and the Stage 5, 6 and 7 entries drop | +| a base change requiring synchronization or implementation work | `stage` becomes 4 on the same terms as a head change | +| a `merge` or `abandon` decision on a run whose `terminal` is already present | refused; the frontier is unchanged | + +A reduction that would leave `stage` outside `0`-`8`, leave `accepted` holding two entries for one stage, or advance past a stage with no accepted entry refuses rather than producing a frontier. + +#### Terminal settlement + +```ts +type FactoryTerminal = + | { + readonly schema: "factory-terminal"; + readonly version: 1; + readonly kind: "merged"; + readonly revision: ImplementationRevision; + readonly actor: FactoryActor; + readonly decisionId: string; + readonly mergeCommit: string; + readonly publication: string; + readonly mergedObservation: string; + readonly issueClosure: string; + readonly projectClosure: string; + } + | { + readonly schema: "factory-terminal"; + readonly version: 1; + readonly kind: "abandoned"; + readonly revision: ImplementationRevision; + readonly actor: FactoryActor; + readonly decisionId: string; + readonly reason: string; + readonly pullRequestClosure: string; + readonly issueClosure: string; + readonly projectClosure: string; + }; +``` + +The two kinds do not share a member list, and that asymmetry is the contract: a `merged` terminal names a merge commit, a target publication and a merged observation, and an `abandoned` terminal names a pull-request closure and a reason and can name none of the first three. + +Every member ending in `Id` or naming a step is a **journal event identity**, and it stays one. The terminal record points at the reconciled effects that completed rather than restating their results, so each provider identity keeps one durable source — the effect result that observed it — and settlement ordering stays checkable against the journal instead of against a copy that could disagree with it. + +Validation follows those references rather than copying through them. Every referenced event must belong to this run and this active terminal intent, parse under its exact effect kind, be complete, and agree with the terminal record's revision, actor and decision where each applies; the referenced results carry the normalized provider identities, and terminal validation checks compatibility without duplicating them. A missing, foreign, wrong-kind, incomplete, invalidated, duplicated or cross-path event refuses settlement — and cross-path is exact: a `merged` terminal cannot name a pull-request close-unmerged or any abandonment effect, and an `abandoned` terminal cannot name a merge construction, a target publication or a merged observation. That is what makes §10.3's two orders checkable from the record alone. + +## 12. Structural consequences + +### 12.1 XMD workflow - The workflow holds one issue lifecycle in one durable run while Stage 4 emits zero or more implementation revisions. -- Every role outcome is validated against the current stage and active handoff - frontier. Review outcomes additionally carry the exact implementation +- Every role outcome is validated against the current stage and the active + handoff frontier. Review outcomes additionally carry the exact implementation revision. - Stage transition, revision observation, synchronization, conflict handling, - review, and closure remain authored XMD control flow. GitHub Actions contains - no parallel decision procedure. + review, and closure are authored XMD control flow. GitHub Actions contains no + parallel decision procedure, and neither does the Durable Object: it owns + state and admission, not stage choice. - The trusted definition comes from the run's immutable definition SHA. Draft pull-request content never becomes the workflow definition executed with GitHub credentials. -### 7.2 Journal +### 12.2 Journal - The immutable workflow definition continues to identify the run. -- The journal additionally retains implementation-revision observations, - active and invalidated handoffs, exact review subjects, merge checkpoints, - conflict classifications, conflict-scoped admission ceilings, evidence - outcomes, pushes, Project updates, and terminal reason. -- A conflict record must be sufficient to reject stale resolution without +- The journal additionally retains implementation-revision observations, active + and invalidated handoffs, exact review subjects, merge checkpoints, conflict + classifications, evidence outcomes, pushes, target publications, Project + updates, retained decisions and terminal reason. +- A conflict record is sufficient to reject a stale resolution without inspecting Agent output or trusting current Project state. - Exported `.xmd` artifacts remain immutable evidence. They are not the live run - store, executor lock, or continuation authority used by later Actions jobs. + store, the executor acquisition, or continuation authority for a later Actions + job. -### 7.3 Workspace and Git effects +### 12.3 Workspace and Git effects -- The suspend-on-conflict profile restores the pre-merge Workspace root and - retains conflict evidence only. A structured-resolution profile must instead - retain the conflicted Git index and working tree with the journal result that - identifies them, or retain equivalent provider-owned state from which that - exact conflict can be reconstructed and reverified. +- A conflicted merge restores the pre-merge Workspace root and retains conflict + evidence only. - A trusted merge effect observes and fixes head, base, and merge base before - mutation. Clean and conflicted results are distinct closed outcomes. -- Conflict-scoped file writes and deletions use the existing Workspace-local - transaction boundary but add exact path and conflict-identity ceilings. -- Merge commit creation verifies both parents and the empty conflict set before - publication. Push remains the existing reconciled non-force effect. -- Rebase and force-with-lease are absent. Adding either later requires a new - external-effect contract, reconciliation semantics, and invalidation proof; - it cannot be represented as another spelling of Push. + mutation. Clean and conflicted results are distinct closed outcomes, and the + parent order differs by purpose. +- Push remains the existing reconciled non-force effect. Target publication is a + separate reconciled effect with its own subject and compare-and-swap + pre-state. +- Rebase, force-with-lease and reset-based replacement are absent. -### 7.4 Invalidation frontier +### 12.4 Invalidation frontier - A changed implementation head always places the frontier at Stage 4. - A base-only change places it at Stage 5 unless Stage 4 work is required. -- A merge attempt, conflict proposal, clean merge, manual resolution, evidence - correction, or push does not advance the item by itself. +- A merge attempt, a clean merge, a manual resolution, an evidence correction, + or a push does not advance the item by itself. - Review approval is keyed by the complete SHA pair, so neither half can drift while Stages 5-7 remain accepted. -## 8. Remaining material decisions - -### 8.1 Product decision: first-release conflict scope - -The remaining product decision for conflict handling is whether the first -factory release implements conflict-scoped generated-XMD resolution for -ordinary text conflicts, or suspends for human resolution on every conflict. - -The recommended first release is the smaller contract: - -- perform clean merges automatically; -- suspend on every conflict; -- prohibit rebases and force pushes; and -- retain the structured conflict evidence needed to add ordinary text-conflict - resolution later as a bounded capability. - -This release proves the revision, invalidation, merge, non-force publication, -and human-resumption boundaries without making conflicted Workspace state and -conflict-scoped mutation admission prerequisites for the first useful factory. - -### 8.2 Deployment architecture decisions - -The lifecycle still requires three deployment choices before it can run across -ephemeral GitHub Actions runners: - -1. Select a durable WorkflowRun, Workspace, Agent-session, and executor-lock - provider reachable by every invocation. An Actions artifact is immutable - evidence and does not satisfy live continuation or locking. -2. Select the authorized ingress for Project admission, human answers, and - resume requests, including how the GitHub actor is authenticated. That - ingress may wake Actions but may not interpret role outcomes or own stage - transitions. -3. Select the GitHub principal and exact repository, pull-request, issue, and - Project permission ceilings, including who authorizes the Stage 7 merge or - abandonment and which target-branch merge method is permitted. - -These choices configure the host boundary. They do not create a second state -machine and do not transfer procedure authority out of XMD. +## 13. Structural acceptance checklist + +A factory implementation satisfies this specification when every item holds: + +1. The run ID equals the §1.1 derivation for its issue, and no mutable value + takes part in it. +2. Duplicate admission for one authenticated subject routes to one run; a + changed retained provider identity refuses as drift. +3. The nine Project statuses are exactly §2's, `User` included, and forward + progress is strictly adjacent. +4. Same-stage correction replaces the frontier without erasing history, and + backward invalidation reruns every later stage. +5. Stages 5-7 name an exact `{ headSha, baseSha }`, and no verdict is inherited + across a changed pair. +6. Only an accepted Stage 6 verdict takes the pull request out of draft. +7. Every Git conflict suspends, restores the pre-merge root, and retains + normalized conflict evidence; nothing resolves a conflict automatically. +8. A manual resolution is observed as a new Stage 4 revision and inherits no + Stage 5-7 conclusion. +9. Start, resume, recovery, mutation, transition, publication and settlement + validate the exact live executor acquisition and the expected Workspace root. +10. Intake, answer delivery and decision delivery take no acquisition, append no + lifecycle outcome and change no run status. +11. Inspection is read-only and authorizes no transition. +12. Native Git, evidence processes and Agent clients run only on the ephemeral + runner; the Durable Object runs none of them. +13. Webhook signature verification precedes parsing, and reread precedes + authorization. +14. `repository_dispatch` carries only a retained intake identity. +15. The OIDC admission validates issuer, audience, repository ID, owner ID, + event name, workflow ref and SHA, and the configured workflow identity. +16. Answers and Stage 7 decisions come only from the authenticated form bound to + the exact subject; no comment carries authority. +17. The App holds exactly §6's permissions, `.github/workflows/**` mutation is + refused, and no secret reaches props, context, durable records, comments, + output or diagnostics. +18. Every GitHub mutation observes before mutating, adopts only a compatible + completion, performs once, and refuses conflict, ambiguity, incomplete + observation and temporary unavailability. +19. The Stage 4 merge parent order is `[implementationHead, targetBase]` and the + Stage 7 order is `[reviewedBase, reviewedHead]`. +20. Target publication updates the ref only from an observed `baseSha`, adopts + only the exact merge commit, and never force-updates. +21. The merged state of the pull request is observed as its own retained step + after publication and before issue closure, and is adopted only at the exact + published merge commit. A pull request still open is a bounded retry and + then a durable machine wait; one closed unmerged is a conflict. +22. The merged and abandoned paths of §10.3 run in their stated orders, and + neither borrows a step from the other. +23. A terminal `merged` or `abandoned` state is published only after every + required projection completes, and a completed replay attaches no external + provider. +24. Every authored construct binds the exact record + [Workflow workspaces](./workflow-workspace-spec.md) defines for it, and + every factory protocol record parses under §11.2 with strict refusal of an + unknown schema, version or member. diff --git a/specs/workflow-spec.md b/specs/workflow-spec.md index a84a29315..c38d37387 100644 --- a/specs/workflow-spec.md +++ b/specs/workflow-spec.md @@ -114,6 +114,15 @@ base is any revision expression, so both are external text on the same terms as retained props. A value installed without a run id, a base or a pinned commit identifies no run and is refused before any document executes. +A host that keeps its runs remotely has decided the same things in the same +order, and installs the same value. `retainedWorkflowInstallation()` names a run +its host already created and a commit its host already pinned; whether the +record behind that name lives in a local file or in a remote owner is host +arrangement the execution never learns. What the execution requires is +unchanged: the run id, base and pinned commit it was handed must be exactly what +the journal it reads records, and a journal recording a different run is +`StaleInputError` wherever the journal is kept. + ### 3.2 Where workflow-run identity is decided **Workflow-run identity is execution-owned, and it is not middleware of any @@ -328,6 +337,16 @@ after an interruption. The Deno host installs its own with entrypoint is the only place SQLite, run-id hashing, filesystem paths and host behavior appear. Shared modules import none of them and detect no runtime. +A second host installs its own the same way. Cloudflare is a runtime-named +adapter beside the Deno one, not a second contract: it answers `create()` and +`lookup()` with a `WorkflowRunDatabase` of its own, and every shared +WorkflowRun surface above it stays host-neutral. The lifecycle transition and request types a host assembly speaks — `WorkflowExecutionTransitions`, `WorkflowBeginRequest`, `WorkflowExecutionBegun`, `WorkflowForkRequest`, `WorkflowForkSelection` and `WorkflowRunCreation` — are part of that neutral surface and are published from the package root; a runtime-named entrypoint may re-export them for source compatibility, but what belongs behind one is the implementation and its retained encoding, not the shape of the request. Nothing below changes for it — +immutable run identity is compared the same way, recognition stays strict, +events reach storage already filtered, a caller still owns the transaction it +opened, and a completed run still replays without attaching a provider. What a +remote adapter adds is where the bytes live and how an executor reaches them +(§9.8), which identity and recognition already treat as host arrangement. + A handle is a lease belonging to the scope that asked for it. Lease teardown makes that handle unusable, and every later call answers with a closed-handle failure rather than reopening the file. It does not close the run's physical @@ -401,6 +420,23 @@ and a local checkout path are **retrieval metadata** — replaceable, excluded from the comparison, never containing credentials, and reauthorized by the host before use. A run that moves between hosts is the same run. +#### A host may derive the id it selects + +A run id is opaque, and §3.3 of the Workspace specification already lets an +authorized caller select one. A host may equally derive one from the subject the +run is about, and a derived id is an ordinary selected id: it has to be a +non-empty string containing no NUL, and it has to be the same string every time +the same subject is admitted. Nothing else about it is constrained, and nothing +here narrows the ids an ordinary caller may choose. + +The software factory derives its ids that way. A factory run id is the lowercase unpadded RFC 4648 Base32 encoding of the full SHA-256 digest of the UTF-8 bytes `github-issue-v1`, a NUL, the canonical GitHub authority, a NUL, and the exact GitHub issue GraphQL node ID — 52 characters of `a`-`z` and `2`-`7`, so the storage rule above is satisfied by construction. + +Those two inputs are the ones [the software factory](./github-actions-software-factory-spec.md) §1.1 defines, byte for byte, and this paragraph restates rather than generalizes them: the authority is the lowercase DNS hostname plus a non-default port, with no scheme, path, query, fragment, user information or trailing separator, and the node ID is GitHub's exact returned string with no case folding and no Unicode normalization. There is no broader Issue-provider authority in this hash — a hash whose inputs two documents spell differently is two hashes. + +That specification also owns every other factory protocol record: its §11.2 holds the closed versioned schemas, and no other document restates them. + +Because every input is immutable, admitting one issue twice derives one id and reaches one run through ordinary compatible reuse, and no separate idempotency concept appears. Two independent implementations given the same authority and node ID therefore produce the same id. A changed authority or node ID for a subject the host already retains is unsupported provider-identity drift, refused under §1.1 of that specification rather than derived into a second run. + ### 9.2 Creating a run is also how it is found `create()` answers with the stored run when the request describes it, and @@ -833,6 +869,84 @@ Version 1 reads and writes version 1. Unsupported versions are refused without the file being touched; partial version-1 initialization is corruption and is also left unchanged. +### 9.8 A remote host owns the same run + +Serialization decides who uses one connection next; it has never decided who may +advance a run. A remote host keeps both answers and gives each a different +mechanism. + +The owner of one run is selected from the public run id by the same arithmetic +§9.3 uses, so a remote run has exactly one durable owner and no second registry +can disagree with it. Inside that owner, operations on the run's storage are +serialized and each runs in a transaction, exactly as §9.6 states: the +connection queue, the transaction identities and the savepoint allocator are +provider-private and say nothing about lifecycle authority. + +**Executor acquisition is an authenticated connection.** The acquisition is that +connection's lifetime: the 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 an operating system releasing +a file lock. Like the local lock it is not a time lease — 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 or is refused, and cannot advance the run either way. + +**These requests require the acquisition**, and each validates the exact live +acquisition and the expected Workspace root inside its own mutating +transaction: start and resume, stale-execution recovery, document execution, +Workspace mutation, provider attachment, native execution performed against a +materialized root, lifecycle transition, accepted-outcome publication, and +terminal settlement. + +**These requests take no acquisition.** Delivery retains one externally supplied +value for one exact retained subject and does what typed answer delivery already +does: it begins no document execution, attaches no Workspace, inserts no +document-execution record, appends no journal event and changes no run status. +What authorizes it is the subject, not the caller's position in the lifecycle — +a suspension id the run's retained `suspension_request` names for an answer, and +the exact retained decision subject for a terminal decision. A value for a +subject the run is not holding is refused with nothing written. + +A **wake notification** is delivered on the same terms and is the one delivery that carries no value at all. A run may wait on a fact about a provider rather than on an answer, and that machine wait is a distinct event kind identified by a `waitId` rather than by a suspension id. An authenticated intake correlated to the exact wait subject retains a bounded notification saying that another observation may occur — no answer, verdict, stage, transition or observation result — and a later executor consumes it and appends the wake event in the run's own transaction. Delivery still stores and execution still decides; what changes is that here there is nothing stored for the execution to read except permission to look again. Read-only +inspection takes no acquisition either, and returns the immutable snapshot +surface of the lifecycle contract rather than a writable handle. + +A later executor is what turns retained delivery into progress: it consumes the +value inside the run's own transaction, appends the accepted event once, and +only then may execution continue past the wait. + +**The owner parses, and the owner transacts.** A request is adopted by the owner alone: it validates the release identity of the connection before parsing anything, parses the request itself rather than accepting a caller's account of it, and opens and commits every transaction. Content arrives content-addressed and is validated against the name it arrived under before it is stored, and against the expected Workspace root before it is published. The runner parses only responses. A caller therefore cannot describe a state change into existence, and a refusal is a refusal of a request the owner read. + +**Creation happens once, and is immutable.** Creating a run initializes pristine storage with the immutable run record, its retrieval metadata, an empty Workspace, the first execution record and `running`, in one transaction. A second compatible creation of the same run id finds that run rather than making another: one owner, one run, one initial root, one lifecycle. A creation whose immutable identity conflicts with what the owner holds is refused, and so is one meeting storage that is damaged or not pristine — in both cases nothing is written, and the distinct condition is what the caller is told. + +**Lookup answers one committed reading, and creates nothing.** An exact lookup returns the complete run storage of §9.4 as a handle the caller may transact against. Absent, foreign, incompatible, damaged, unparseable, wrong-run and closed-scope lookups stay distinct conditions; none of them creates, repairs or partially answers, and a handle whose scope has ended answers nothing. + +**One execution is one envelope.** Beginning inserts exactly one document-execution record — its identity minted by the caller so a retried request is the same bytes and an owner cannot begin a second execution for a request it already answered — and publishes the run state that goes with it in the same transaction, against the exact expected root. Settlement closes that same record with the semantic outcome and its exact stop reason, in one transaction, and only a settled record's status may be reported. An injected failure on either side exposes the whole old state or the whole new one. + +**Stale recovery needs no timer.** An unfinished record found after acquisition belonged to the previous executor and is proven stale by the closed connection rather than by a timestamp, PID, timeout or status row. The next acquisition reconciles the retained root history first, restores or closes that execution accordingly, and only then begins another; a committed effect is never repeated, and cancellation instead finishes that exact stale execution and publishes `cancelled` without beginning one. + +**Fork copies from one selected source, or does nothing.** A fork names one source prefix and root and produces one destination run and one lineage. The destination commits whole or not at all, its copied prefix outlives the source, and an incompatible selection or a failure part-way mutates neither the source nor the destination. + +**The planes are three requests, and one client carries them.** Which plane a request is for is its path, and the run id in that path is what selects the owner — arithmetically, through the namespace's own naming, with a gateway that forwards on that id and decides nothing else. A trusted host reaches all three through one configured client bound to one run: it holds an already-selected run id, one credential-free endpoint parsed before anything is sent, the exact release identity, and an operation that mints a short-lived token for the immediate request. Every plane requires the configured run id before a token exists, a URL is built or any I/O happens; the endpoint, the release and the token stay in the host's closure and reach no record, event or diagnostic; and the request shapes, header names and refusal spellings stay private to one release, save for the one category that is a fact about the run — another live executor holds it. + +**A handle's authority ends with its scope.** Teardown closes the handle and, for an executor, releases ownership; it rolls back nothing already committed and settles nothing that was not settled. Closing the connection is the only staleness proof a remote host needs, and a closed one authorizes nothing while leaving every committed transaction exactly as it was. + +### 9.9 A retained terminal, and what may be concluded from it + +A finished run is read before it is trusted, and one shared judgment does the reading, because a conclusion reached two ways is two contracts. Recovery publishing an outcome and admission reusing one ask the same functions of the same events. + +**One semantic outcome.** A root `Close` has two layers and both decide something. The outer layer is the coroutine's own settlement: it returned, it was raised out of, or it was cancelled. Returning is what a document does whether it succeeded or failed, so an outer `ok` says only that the value beneath it is the document's own result, and that result's `status` is what decides `completed` or `failed`. A returned value that is not a document result at all is neither outcome. A failed run names its reason exactly: the last retained row that failed, or — for a failure raised outside any durable operation — one categorical code, and never a message lifted out of an exception. + +**One final terminal.** Exactly one final root `Close` may exist. A second one, or any work recorded after the one that is there, is a history no single execution produced; the run is damaged rather than resolved, because choosing between them would be this build deciding which execution the run was. + +**The terminal has to agree with the history around it.** A run that failed before importing anything carries the root binding core writes for exactly that case, and no root import. A run that produced an ordinary document result carries exactly one root import: exactly one retained event names it, whichever coroutine recorded it, and that one event belongs to the root coroutine. Either shape found with the other's history is damaged. + +**The root import is read by canonical execution's own parser.** The same function that admits a partial history parses the retained selection here, so a selection the executor would refuse cannot publish an outcome instead. It proves rather than recognizes: the retained document parses; an exact target is canonically encoded and resolves against that document to the exact target recorded; and a recorded selection failure re-derives from the same selector to the same kind, matches and available catalog, so a failure record reduced to its selector, carrying another catalog, or naming a selector that actually resolves is not a failure any selection produced. A selection that named no target is raised out of the root import, so the document never ran and a successful result beside one is two histories rather than one. + +**Damage outranks the row.** History those readings refuse is damaged, and damage decides before the stored status does. A run whose row says `completed` or `failed` over a terminal that cannot be 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. What the run keeps is exactly what it had — the row, the journal, the Workspace frontier and any unfinished execution the previous executor left open. Stale recovery reads the same judgment first and publishes nothing over damaged history. + +**A coherent terminal replays, and changes only its own envelope.** A completed or failed run named again is replayed rather than refused, under whichever command named it. The replay opens its own document-execution record and closes it, and the run row is left exactly what it was, `updatedAt` included: an outcome that already won does not become mutable again by being read. What the replay runs on comes from the run's own retained state and nowhere else — the root document its root import retained, and the component bundle rebuilt from the immutable definition with each retained component's bytes named the way Git names a blob and compared to the object id the definition holds. No repository, working tree, live import, Workspace, Agent, process, Git-host, Issue, Project or credential provider is reached, no effect is performed again, no native operation starts, no retained answer is consumed and no event is appended. A retained root that does not agree with the run's own definition path refuses before anything is replayed from it. + ## 10. The document filesystem of a run A host attaches one run's Workspace to a document execution with @@ -899,7 +1013,4 @@ uncontained filesystem this boundary exists to prevent. ## 11. Intentionally excluded -Public `xmd workflow` lifecycle commands; lifecycle transition policy, executor -leases and stale-owner recovery; public root selection, history checkpoints and -forks; workflow-owned worktrees; and deterministic Git and GitHub effects. -Retained roots and private restoration do not expose any of those behaviors. +Public `xmd workflow` lifecycle commands; public root selection, history checkpoints and forks; workflow-owned worktrees; and deterministic Git and GitHub effects. Retained roots and private restoration do not expose any of those behaviors. The lifecycle policy those commands are built on — which transitions exist, what a stale executor's unfinished execution becomes, and what a retained terminal permits — is specified by §9.6, §9.8 and §9.9 here and by [Workflow workspaces](./workflow-workspace-spec.md) §3, and reaching it is not something a retained root or a restoration does. diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index 5bcec74f9..5b54ecaf6 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -336,10 +336,15 @@ workflow answer: () A value is accepted only when the run is `suspended`, its stop reason names a retained `suspension_request` event carrying the supplied suspension ID, and the -value satisfies the response schema that request retained. Secret detection -applies to the retained state and the answer event it would become, on the same -terms as durable journal persistence, and is on unless `--no-secret-detection` -disables it for that delivery. Duplicate, consumed, wrong-run, wrong-request, +value satisfies the response schema that request retained. That schema is +judged as draft-07 by one shared implementation which generates no code, so a +value receives one verdict whether the run's storage is a local file or an +object somewhere else — and the boundary that writes the value is the boundary +that judges it, rather than accepting a judgment somebody else reports having +made. Secret detection applies to the retained state and the answer event it +would become, on the same terms as durable journal persistence — the same +configured scanner, at whichever boundary writes — and is on unless +`--no-secret-detection` disables it for that delivery. Duplicate, consumed, wrong-run, wrong-request, late, invalid, cancelled-run and missing-run delivery is refused, and a refusal leaves the run's storage unchanged. A refusal retains neither the rejected value nor a secret match in its diagnostic. @@ -359,6 +364,14 @@ resume, watchers, unattended iteration and remote host selection — is #300's. Nothing above waits on it: a suspended run continues through `xmd workflow answer` followed by an explicit `xmd workflow resume`. +**A machine wait is a second wait kind, not a second protocol.** Everything above describes a wait that ends when somebody delivers one typed value. A run can also wait on a fact about a provider — a remote object whose state a later observation will read — and that wait asks nobody anything. It has no request to answer, no response schema, no `xmd workflow answer` route and no bound value, so it publishes no `suspension_request`, consumes no retained answer and appends no `suspension_answer`. + +What it shares is this section's boundary and nothing else. Its retained event kind is `machine_wait`, distinct from `suspension_request`; its stable identity is a `waitId` the trusted execution derives from the run and the authored expansion, on the same terms every other durable position uses, and it is never a suspension ID. The wait event and the `suspended` run status commit together, the executor acquisition is released only after that commit, and a settlement the host refuses publishes neither. The stop reason references the filtered `machine_wait` event, so inspection reports a run waiting on provider state and offers no response schema and no answer command. + +A machine wait ends by being woken and then looking again. An authenticated intake correlated to the exact wait subject retains one bounded **wake notification** as an ordinary delivery-plane transaction — no executor acquisition, no lifecycle outcome, no run-status change — and it carries no answer, verdict, stage, transition or observation result. A duplicate notification changes nothing, and one naming another, spent, invalidated or terminal wait refuses without touching the active wait. An authorized operator resume is executor-side control rather than a delivery and reaches the same place. A later executor consumes one wake and appends one filtered `machine_wake` event for that exact `waitId` in one transaction, so a crash before the commit leaves both pending and a replay after it restores the event without consuming or appending again. The wake permits one further observation and decides nothing about its outcome; a resume with neither a pending wake nor operator authority reports the same wait and settles `suspended` again. + +The software factory's merged-observation wait ([the software factory](./github-actions-software-factory-spec.md) §10.4) is the first machine wait, and its closed records are §11.2 there. + ### 3.6 Interruption and cancellation differ Interrupting foreground execution, including with Ctrl-C, releases the current @@ -472,6 +485,45 @@ owning the same run lifecycle remotely. `export` produces the immutable portable evidence contract in `specs/xmd-artifact-spec.md`; it does not expose the live run database. +A remote host owns that surface unchanged. One durable owner is selected from +the public run ID by the same arithmetic local discovery uses, and it holds the +run record, the filtered journal, the immutable Workspace roots and their +content-addressed bytes, the Agent-session mappings, the retained delivery state +and executor ownership. + +**Executor ownership is the lifetime of one authenticated connection.** A remote +start or resume opens that connection, and the acquisition lives and dies with +it: the owner registers the exact acquisition on admission and invalidates it on +close, which is the staleness proof that replaces an operating system releasing +a file lock. It is not a time lease, and closing it rolls back nothing already +committed. Every request that advances the run — start, resume, stale recovery, +document execution, Workspace mutation, provider attachment, native execution +against a materialized root, lifecycle transition, accepted-outcome publication +and terminal settlement — validates that exact live acquisition and the expected +Workspace root inside its own mutating transaction. + +**Delivery and inspection are the exceptions, and they stay exceptions.** A +delivery retains one externally supplied value for one exact retained subject +under §3.5's rules — no acquisition, no execution, no attachment, no journal +event, no status change — whether the subject is a suspension request, a +terminal decision, or a machine wait whose notification carries no value at all. +Inspection is read-only and returns immutable snapshots. A +remote host that let either one advance a lifecycle would have built a second +state machine beside the journal. + +**A remote host admits its executor before it trusts it.** Where the connection +comes from an ephemeral CI runner, admission validates that runner's OIDC claims +— issuer, configured audience, repository ID, repository-owner ID, event name, +workflow ref and SHA, and the configured immutable workflow identity — before an +acquisition exists. Repository names are mutable and are not what is checked. + +**An ephemeral runner recovers like any interrupted executor.** It materializes +one selected retained root, works in it, and submits content-addressed changes +that the owner validates and publishes atomically with the filtered journal +result, so a runner that dies mid-flight exposes only a prior or a new complete +transaction. The next acquisition performs the ordinary stale-execution recovery +of §3.3 and resumes from the exact committed run and Workspace frontier. + ### 3.9 What is shipped The lifecycle above is the whole design, including §3.7's rule that a status @@ -1603,6 +1655,305 @@ not name its own number and repository are all refused. A well-formed answer to another question is still the wrong answer. +### 7.8 Ordered merge: `Git.Merge` + +A merge is Workspace-local. It runs inside the retained checkout, against exact +commits the document names, and it publishes through the ordinary effect +transaction of §10.1 rather than through a Git host. + +```md + +``` + +The props are closed and all five are required: two exact parent commits, the +exact merge base, a `purpose` of `"synchronize"` or `"publish"`, and `as`. Which +Repository and checkout the merge runs in is decided the way §7.1 decides it, +and the repository, checkout, Workspace root and executor acquisition are +authenticated provider state rather than props. Form validation runs first: a +missing prop, an unknown prop, a `purpose` outside the enum and a missing `as` +each fail before a Repository is observed or a provider is reached. + +**The parent order is the caller's, and `purpose` authorizes it.** `purpose="synchronize"` brings a target into an implementation and is authored `[implementationHead, targetBase]`. `purpose="publish"` brings a reviewed implementation onto a target and is authored `[reviewedBase, reviewedHead]`. The component neither infers the order from the purpose nor reorders what it was given — but it does not merely record the purpose either. It validates that what was authored equals the retained authority for the purpose declared. + +The provider-authenticated merge ceiling supplies that authority. For `purpose="synchronize"` it supplies the exact current implementation head and the observed target base, and `firstParent` must equal that head while `secondParent` must equal that base. For `purpose="publish"` it supplies the exact reviewed `{ headSha, baseSha }` the retained Stage 7 decision authorized, and `firstParent` must equal `baseSha` while `secondParent` must equal `headSha`. In both cases `mergeBase` must equal the completely observed merge base for that same authenticated pair. + +A missing ceiling, a purpose the ceiling does not authorize, a swapped parent, a stale parent, a stale merge base, a revision other than the authorized one, and a ceiling belonging to another Repository or checkout each refuse before any Git mutation. Retaining the purpose without checking it would leave the one mistake this contract most needs to catch — a Stage 7 publication authored in Stage 4's order — detectable only by reading the history afterwards. + +**The request is the four authored inputs plus what the provider authenticates.** The durable request carries `firstParent`, `secondParent`, `mergeBase` and `purpose` exactly as authored, together with the Repository identity, the checkout, the pre-merge Workspace root, the executor acquisition and the merge ceiling the provider validated. Those are provider-authenticated state and never authored props, and the effect is named by the run and the expansion like every other one. + +**The result is a closed discriminated union of exactly two shapes**, keyed on `outcome`: + +```ts +type GitMergeResult = + | { + readonly outcome: "clean"; + readonly purpose: "synchronize" | "publish"; + readonly firstParent: string; + readonly secondParent: string; + readonly mergeBase: string; + readonly commit: string; + readonly workspaceRoot: string; + } + | { + readonly outcome: "conflicted"; + readonly purpose: "synchronize" | "publish"; + readonly firstParent: string; + readonly secondParent: string; + readonly mergeBase: string; + readonly workspaceRoot: string; + readonly conflicts: readonly GitMergeConflict[]; + }; + +interface GitMergeConflict { + readonly path: string; + readonly classification: + | "content" + | "add/add" + | "modify/delete" + | "delete/modify" + | "rename" + | "mode" + | "binary" + | "submodule" + | "symlink" + | "unrecognized"; + readonly stages: readonly (1 | 2 | 3)[]; + readonly base?: GitMergeSide; + readonly ours?: GitMergeSide; + readonly theirs?: GitMergeSide; +} + +interface GitMergeSide { + readonly objectId: string; + readonly mode: string; +} +``` + +Every member is required unless the declaration marks it optional, every commit and object identity is a lowercase hexadecimal object ID of the repository's own object format, `mode` is the six-digit octal Git records, and `path` is an already-normalized repository-relative POSIX path under the same rules §9.1 of [Workflow runs](./workflow-spec.md) states for a root document path. `classification` is the closed enum above and `unrecognized` is its own value rather than an absent one, because a class this build cannot name is a fact about the merge and not a gap in the record. An unknown member and an unknown classification each refuse the record rather than being ignored. + +A **clean** result names the exact merge commit and the Workspace root published with it. Its mutation, root publication and filtered result commit together, so a crash before the commit leaves the checkout, the current root and the effect history the ones the run had. A **conflicted** result names the *unchanged* pre-merge root it restored and the complete conflict set; it offers no file mutation under that evidence and adopts no partial merge state. Both are successful effects with different outcomes, not a success and a failure. + +The conflict set is complete and ordered. Entries sort by `path` in UTF-8 byte order, and two entries for one path refuse the record rather than being merged or deduplicated. `stages` is the ascending list of unmerged-index stage numbers Git retained for that path, and side presence agrees with it exactly: stage 1 is `base`, 2 is `ours`, 3 is `theirs`, a stage the index does not hold has its member absent rather than null, empty or zeroed, and a side present without its stage — or a stage without its side — refuses. A set mixing classifications is retained whole and unaltered: every conflict suspends, so there is no partial handling for a mixed set to select. Rendered conflict markers alone are never the record, because a later reader has to tell a stale conflict from the one it is looking at without reparsing text. + +**Restoration is part of the conflicted outcome, not cleanup after it.** If the pre-merge root cannot be restored, the effect publishes no conflicted result and no new root: it is an infrastructure failure that activates the durable fail-stop fence, because a conflicted result naming a root the Workspace is not actually at would be evidence of a state nothing holds. + +Cancellation between the merge and the commit rolls the outer transaction back and publishes no completion at all. A completed record of either outcome replays without running Git. + +A merge never contacts a Git host, never pushes, and never rewrites a published identity. Rebase, force, force-with-lease and reset-based replacement are absent from this component and from every other one in this specification. + +### 7.9 Publishing a reviewed merge: `Git.PublishTarget` + +Publishing to a protected target branch is a Git-host effect and a different +question from advancing a branch this run owns. + +```md + +``` + +All four props are required and the set is closed. The remote, the target ref, +the credential and the non-force policy are host-owned: they are not props, and +no authored value widens them. Form validation runs before the host's ceiling is +read and before any credential exists. + +**The request names the target the host chose and the commits the document did.** Its natural key is the target identity alone — the retained Repository, the configured remote and the configured target ref — because one ref has one publication at a time whoever is asking: + +```ts +interface GitPublishTargetRequest { + readonly kind: "git-publish-target"; + readonly target: GitPublishTarget; + readonly expectedRemoteCommit: string; + readonly sourceCommit: string; + readonly reviewedHead: string; +} + +interface GitPublishTarget { + readonly repository: string; + readonly remote: string; + readonly ref: string; +} +``` + +`repository` is the Workspace-local Repository name, `remote` is the configured remote's name, and `ref` is the fully qualified destination ref. The credential, the locator behind the remote and the non-force policy are provider closure state and appear in neither the request nor the result. The three commits are lowercase hexadecimal object IDs of the repository's object format. + +**It is a compare-and-swap**, and its five observed pre-states are exhaustive: + +| Observation | Decision | +| --- | --- | +| the target equals `expectedRemoteCommit` | perform one non-force update, once | +| the target equals `sourceCommit` | adopt; nothing is performed | +| the target equals some third commit | conflict; refuse without mutating | +| the observation did not complete | incomplete observation; refuse as itself, adopt nothing, perform nothing | +| the observation cannot be decided, or the host is temporarily unreachable | permanent ambiguity and temporary unavailability respectively; each refuses as itself and never performs | + +A race that moves the target before or during publication therefore cannot publish over it, and an interrupted attempt is reobserved rather than repeated: a target that now equals `sourceCommit` is the adoption, and one that does not is not silently published over. + +**The result is one closed record**, and the binding is that record: + +```ts +interface GitPublishTargetResult { + readonly target: GitPublishTarget; + readonly expectedRemoteCommit: string; + readonly reviewedHead: string; + readonly sourceCommit: string; + readonly observedCommit: string; + readonly decision: "performed" | "adopted"; +} +``` + +`observedCommit` is what the target held when this attempt looked, so the record says what the publication moved from or found already done; `decision` is the closed pair above and no third value exists. `reviewedHead` is carried so the record says what the publication was authorized against, which is what lets an exact-revision review be invalidated by a target that moved. It is stable evidence of what the effect settled on, not a live branch snapshot. Every member is required, and an unknown member or an unknown `decision` refuses the record. + +Cancellation tears the provider call down and publishes no completion. A completed record replays without contacting a Git host. + +**Three operations stay distinct.** `Git.Push` (§7.4) advances a branch this run published, from an ancestry relation proved inside the authenticated object source. `Git.PublishTarget` updates a ref it does not own, from an exact expected pre-state. A Git host's own pull-request merge endpoint is neither, and this specification defines no component for one: a squash or a rebase performed by the host would publish a commit no reviewer saw, under parents the review never named. Whether the host has *noticed* that its pull request is now merged is a fourth question, and §7.11 owns it. + +### 7.10 Pull-request comments, readiness and closure + +Three more Git-host effects act on a pull request a canonical URL names. Each requires `as`, validates its form before any provider, ceiling or credential is reached, and reconciles under §10.2: observe, adopt a compatible completion, perform a proven absence once, refuse conflict, permanent ambiguity, incomplete observation and temporary unavailability. Every identity below is a canonical URL or a lowercase hexadecimal object ID; no credential, endpoint, raw payload, cursor or host path appears in any request, natural key or result. + +```md + +The Architect accepted {revision.headSha} against {revision.baseSha}. + + + + +``` + +#### `PullRequest.Comment` + +The component is paired, takes one required `url` and one required `as`, and its rendered content is the body verbatim. `url` is the **canonical pull-request URL** — the normalized single spelling of one pull request, on the terms §10.3 already states for a canonical target URL — and the durable request is exactly that URL, the engine-derived effect identity and the rendered body: + +```ts +interface PullRequestCommentRequest { + readonly kind: "pull-request-comment"; + readonly subject: string; + readonly effect: string; + readonly body: string; +} + +interface PullRequestCommentResult { + readonly subject: string; + readonly url: string; + readonly decision: "performed" | "adopted"; +} +``` + +`subject` is the canonical pull-request URL, `effect` is the engine-derived effect identity of [Workflow runs](./workflow-spec.md) §8, and `url` is the canonical URL of the comment this effect settled on. The binding is `{ url }`: that comment's own URL, which is the only fact the effect produces — the subject was already in hand at the call site. + +**The natural key is `subject` plus `effect`, and the body is never part of it.** A Git host issues no client-supplied idempotency key for a comment, so the effect identity has to be observable on the host for an interrupted creation to be found again. A comment provider therefore has to support one **stable opaque correlation marker**: a value it can write with a comment, preserve unchanged, and query completely. A provider that cannot do all three refuses the effect before its first mutation, the way a plain Git server refuses pull requests today — there is no fallback that searches prose. + +The marker is provider transport metadata. The **authored logical body is preserved byte for byte as the authored portion of the projection**, and the correlation representation lives outside that logical body rather than inside it; GitHub's adapter encodes it as a non-rendered HTML comment in its provider payload, so the payload the provider sends is not claimed to equal the authored bytes. It is not authored prose, not a credential and not lifecycle authority — publishing an engine-derived effect identity as an opaque non-secret correlation value is what it is for. The public binding and every replay expose the authored body and the provider's comment identity, never the transport encoding. + +**Observation is judged against the attempt state, not against the host alone.** Before any provider mutation, the durable effect retains that this exact request is prepared and unattempted; a live attempt is what moves it past that. What a complete observation means then depends on which side of that line the effect is on: + +| Attempt state and observation | Decision | +| --- | --- | +| unattempted, and no marker | proven absence; create once | +| unattempted or attempted, and exactly one marker | compatible completion; adopt with nothing performed | +| any state, and more than one marker | permanent ambiguity; refuse | +| **attempted with no committed local completion, and no marker** | **permanent ambiguity; refuse** | +| an observation that did not complete | incomplete observation; refuse, adopt nothing, perform nothing | +| the host is temporarily unreachable | temporary unavailability; refuse as itself | + +The fourth row is the one that matters. A marker that is absent *after* an attempt does not prove the comment was never created — it equally describes a person having edited or deleted it inside the interrupted window — so treating that as absence is how a duplicate gets published. Refusing it as ambiguity costs a stall and buys the guarantee. Once a local completion has committed, the marker no longer decides anything: a completed replay reads its own record and contacts no provider, so removing the marker afterwards changes nothing. + +An incomplete observation is never absence. A comment list the adapter could not finish reading is a search that did not answer, and an unfinished search reported as absence is the same duplicate by another route. + +#### `PullRequest.Ready` and `PullRequest.Close` + +Both are self-closing, take one required `url` and one required `as`, and are keyed by that exact canonical pull-request URL — one readiness and one closure per pull request, so neither carries an effect identity in its key. Their bindings are the closed records below, and each durable result is its binding plus the observation the attempt made: + +```ts +interface PullRequestReadyBinding { + readonly url: string; + readonly state: "open"; + readonly draft: false; +} + +interface PullRequestCloseBinding { + readonly url: string; + readonly state: "closed"; + readonly merged: false; +} + +interface PullRequestReadyResult extends PullRequestReadyBinding { + readonly observed: PullRequestObservation; + readonly decision: "performed" | "adopted"; +} + +interface PullRequestCloseResult extends PullRequestCloseBinding { + readonly observed: PullRequestObservation; + readonly decision: "performed" | "adopted"; +} + +interface PullRequestObservation { + readonly state: "open" | "closed"; + readonly draft: boolean; + readonly merged: boolean; +} +``` + +`state`, `draft` and `merged` are literal in each binding rather than observed values copied through, because a binding that could say `draft: true` would be a component reporting that it did not do what it is for. + +`PullRequest.Ready` performs once from an observed `{ state: "open", draft: true, merged: false }`, adopts an observed `{ state: "open", draft: false, merged: false }` with nothing performed, and refuses every other observation as a conflict — a merged or closed pull request among them, since readiness is not a thing to restore. `PullRequest.Close` performs once from an observed `{ state: "open", merged: false }` at either draft state, adopts an observed `{ state: "closed", merged: false }`, and conflicts with an observed `merged: true`, which is a completion of a different kind that closing must not overwrite. A pull request belonging to another repository, or one the URL names but the host does not hold, is a conflict for both. An incomplete observation, a permanent ambiguity and a temporary unavailability each refuse as themselves and perform nothing. + +Neither reopens, merges, comments on or pushes anything. Cancellation tears the provider call down and publishes no completion; a completed record of any of the three replays without contacting a Git host. Which of them a document may invoke, and what authorizes the invocation, is authored control flow above them. + +### 7.11 Observing that a pull request merged: `PullRequest.Merged` + +Publishing a merge commit to a target ref and a Git host recording that pull request as merged are two different facts, and the second one is not implied by the first. A host observes its own ref moving and closes the pull request on its own schedule, so a run that needs the merged state in its history has to observe it — and has to observe it as its own retained step rather than as a side effect of something else. + +```md + +``` + +The component is self-closing, its three props are required and the set is closed, and it is a Git-host effect of its own. It is not `PullRequest.Ready`, not `PullRequest.Close`, not a pull-request upsert and not `Git.PublishTarget`: overloading any of them would make one record answer two questions, and the two can disagree. + +**It mutates nothing.** It is a reconciled observation: adoption is its only completion, and there is no `performed` decision for it to reach. What it reconciles is *when* the fact becomes true, because a host that has not yet noticed the ref move is not a host that refused. + +```ts +interface PullRequestMergedRequest { + readonly kind: "pull-request-merged"; + readonly subject: string; + readonly expectedMergeCommit: string; +} + +interface PullRequestMergedResult { + readonly subject: string; + readonly state: "closed"; + readonly merged: true; + readonly mergeCommit: string; + readonly decision: "adopted"; +} +``` + +`subject` is the canonical pull-request URL and is the whole natural key. The binding is the result record. + +The observation is complete or it is nothing, and its five outcomes are distinct: + +| Observation | Decision | +| --- | --- | +| `merged: true` at exactly `expectedMergeCommit` | compatible completion; adopt | +| `merged: true` at another commit | conflict; the target carries somebody else's merge, and the exact-revision reviews invalidate rather than this step succeeding | +| `state: "open"`, `merged: false` | temporary unavailability; the host has not yet recognized the merge, and a later attempt starts again at observation | +| `state: "closed"`, `merged: false` | conflict; a pull request somebody closed by hand is a state incompatible with the merged path, not lag | +| an incomplete read, or an undecidable one | incomplete observation and permanent ambiguity respectively, each refusing as itself | + +The third and fourth rows are deliberately not one row. Still open after a publication is eventual consistency and is worth waiting for; closed unmerged is a person having intervened, and waiting for that to resolve itself would wait forever. + +Cancellation publishes no completion, and a completed record replays without contacting a Git host. What the run does while the third row persists — a bounded host-configured retry, then a machine wait that ends on reobservation rather than on a delivered answer — and where this step sits in the terminal sequence belong to [the software factory](./github-actions-software-factory-spec.md) §10.3 and §10.4. + ## 8. Agents inspect; XMD mutates ### 8.1 No directory registration @@ -1654,6 +2005,15 @@ This is what the host asks for and what it refuses. It is not a claim that every ACP adapter exposes no tool when asked for none; that portable proof is tracked by #496 and does not widen this ceiling. +Constructs added for a trusted host do not reach the Agent either, and they do +not reach it for a different reason than the tool set: an Agent never expands a +document. Merging, publishing a target, observing that a pull request merged, +commenting, changing draft state, closing an issue or a pull request, moving a +Project item and running evidence +are authored XMD the trusted host expands under its own acquisition. What an +Agent may return is text, and a fragment it returns is admitted only against the +tables §8.4 states — which name none of them. + `Session.Launch` is unsupported by this profile. The trusted workflow host states both ordinary-run native capability sets empty and installs no native foreground launcher, so a launch is refused before provider preparation, @@ -1973,8 +2333,16 @@ The write table is authority, not prompting guidance. Generated source cannot grant itself Push, PullRequest, an issue upsert, a repository, a process, an eval or exec block, a native command, a credential or an arbitrary network write merely by naming a component; the table excludes local Git even though those -effects are also Workspace-local. Trusted reusable Markdown components may be -admitted explicitly; generated XMD admits none of them. +effects are also Workspace-local. The constructs §§7.8-7.10, §10.5 and §10.6 add +change nothing about that: `Git.Merge`, `Git.PublishTarget`, +`PullRequest.Comment`, `PullRequest.Ready`, `PullRequest.Close`, +`PullRequest.Merged`, `Issue.Comment`, `Issue.Close`, `Project.Status` and +`Evidence.Run` appear in no +table this specification states, so a fragment naming one is refused in the +preflight before any generated effect, exactly as `` is. Adding a +construct to a table is a host act, and a factory host adds none of them. +Trusted reusable Markdown components may be admitted explicitly; generated XMD +admits none of them. **Approval is authored, and it is ordinary.** `` neither prompts nor approves. A workflow that requires approval reaches a branch, an elicitation, a @@ -2134,6 +2502,8 @@ Workspace, Agent or external providers. It still reconstructs the bundle and still applies that admission, so retained output is accepted only for a history this run is a run of. +Where the inputs for that come from depends on what the host still has. A local `resume` reads the retained components from the retained commit, because the repository is there. A completed replay may have nothing but the run's own storage — an ephemeral runner holds no clone — so it is assembled from retained state alone: the root document its own root import retained, and the bundle rebuilt from the immutable definition with each retained component's bytes named the way Git names a blob and compared to the object id the definition already holds. That is the same admission reaching the same conclusion from evidence the run carries, rather than a weaker one. Which histories are readable enough to be replayed at all, and what a replay may change, is [Workflow runs](./workflow-spec.md) §9.9. + A partial replay that reaches a completed Git-host effect may still reconstruct what that effect needed locally — a Push rebuilds its checkout from the Workspace in order to name the request it is asking about — and then hands back the @@ -2288,6 +2658,16 @@ performance retains is that observation, which is how the record says what the external resource held before this attempt moved it. Temporary unavailability is neither absence nor conflict, and never authorizes a mutation: a later explicit attempt starts again at observation. +**Whether absence can be proved at all depends on where the completion is visible**, and effects divide into two kinds. Most of them mutate a subject that already exists and whose own state answers the question: a Push reads the destination ref, a numbered pull-request update reads that pull request, `PullRequest.Ready` and `PullRequest.Close` read its state, `Issue.Close` reads the issue, `Project.Status` reads the field's current option. For those, a complete observation of the subject is decisive whether or not this effect has attempted anything, because what the observation reports is the resource itself. + +The other kind **creates a new object the host names**. Creating one is safely reconcilable through either of two mechanisms, and which one an effect has decides whether attempt state takes part. An effect the provider gives a native client idempotency or correlation key — the key an Issue upsert derives from the canonical target and this run's own effect identity is one — reconciles on that key under its already-stated natural-key and complete-observation contract, and carries no attempt state: creating an object is not by itself what makes an effect attempt-stateful. A pull-request upsert is the same, reconciling on its explicit head-and-base or numbered identity. + +A comment is the one construct here with neither. Nothing pre-exists to read, and the host issues no client-supplied idempotency key, so the completion is observable only through a correlation value the effect itself wrote. Absence then means "that value is not there", which is a different claim before and after a mutation has been attempted, and the effect therefore retains its **attempt state**: the exact request is retained as prepared and unattempted before any provider mutation, and a live attempt moves it past that. + +For such an effect the decision above narrows in exactly one place. Unattempted with nothing found is proven absence and performs once. **Attempted with no committed local completion and nothing found is permanent ambiguity, not absence** — the correlation value is equally missing because the object was never created and because somebody removed it inside the interrupted window, and performing on that reading is how a duplicate gets published. Exactly one correlation match is compatible completion in either state; more than one is permanent ambiguity in either state; and an incomplete observation stays incomplete rather than becoming absence, since an unfinished search reported as absence is the same duplicate by another route. Once a local completion has committed, none of it decides anything further: replay reads the record and contacts no provider. + +A provider that cannot write, preserve and completely query such a correlation value cannot supply this kind of effect at all, and refuses it from observation before any mutation — the same refusal a plain Git server gives for pull requests. A future host-named create effect that has neither a provider-native client key nor a preservable marker refuses on the same terms. That refusal is the contract rather than a gap in it: an effect that can prove neither absence nor completion has no safe way to run once. + **The record.** A decision publishes one journal result holding the request, the normalized pre-state, the normalized observations, the decision — `adopted` or `performed` — and the normalized result. Replaying it contacts no provider and @@ -2653,6 +3033,71 @@ used. With no configuration there is no Issue provider, so every request reaches `NoIssueProvider` — absence of configuration is fail-closed, never an open default. +#### Commenting on and closing an issue + +Two more Issue-provider effects act on an issue a canonical URL names. Both +require `as`, both validate their form before any provider, ceiling or +credential is reached, and both reconcile the way an upsert does — observe, +adopt a compatible completion, perform a proven absence once, refuse conflict +and ambiguity — inside the provider rather than through the Git host's shared +state machine. + +```md + +The Planner accepted the plan at {plan.revision}. + + + +``` + +`Issue.Comment` is paired, takes one required `url` and one required `as`, and its rendered content is the body verbatim. Its records mirror the pull-request comment of §7.10 exactly, under this boundary instead of the Git host's: + +```ts +interface IssueCommentRequest { + readonly kind: "issue-comment"; + readonly subject: string; + readonly effect: string; + readonly body: string; +} + +interface IssueCommentResult { + readonly subject: string; + readonly url: string; + readonly decision: "performed" | "adopted"; +} +``` + +`subject` is the canonical issue URL — the normalized single spelling this section already requires of a target — `effect` is the engine-derived effect identity, and `url` is the canonical URL of the comment the effect settled on. The binding is `{ url }`, that comment's own URL. + +**The natural key is `subject` plus `effect`, and the body is never part of it.** An Issue provider carries the same requirement §7.10 states for a pull-request comment: it supports one stable opaque correlation marker it can write, preserve and completely query, or it refuses the effect before its first mutation. The authored logical body is preserved byte for byte as the authored portion of the projection, and the correlation representation lives outside it. The attempt-state table of §7.10 governs the decision unchanged, including its fourth row — an absent marker after an attempted-but-uncommitted creation is permanent ambiguity rather than proven absence. + +`Issue.Close` is self-closing and takes one required `url`, one required `reason` from the closed enum `"completed" | "not_planned"`, and one required `as`. Its natural key is the canonical issue URL alone: + +```ts +interface IssueCloseRequest { + readonly kind: "issue-close"; + readonly subject: string; + readonly reason: "completed" | "not_planned"; +} + +interface IssueCloseBinding { + readonly url: string; + readonly state: "closed"; + readonly reason: "completed" | "not_planned"; +} + +interface IssueCloseResult extends IssueCloseBinding { + readonly observed: { readonly state: "open" | "closed"; readonly reason?: "completed" | "not_planned" }; + readonly decision: "performed" | "adopted"; +} +``` + +`state` is literal in the binding: a component for closing an issue does not report that the issue is open. The observed `reason` is absent rather than null when the issue is open or when the host records no reason for a closure it holds. + +The reason is part of what the effect means rather than a label on it, so a host that retained one terminal intent refuses a close naming the other. An observed open issue is performed once. An observed issue closed with the same reason is adopted with nothing performed. An observed issue closed with the other reason is a conflict, and so is one closed with no reason the host will state, because adopting it would let a `not_planned` closure stand as a `completed` one. An incomplete observation, a permanent ambiguity and a temporary unavailability each refuse as themselves. + +Neither reopens an issue, and neither derives authority from what it observes. Cancellation publishes no completion, and a completed record of either replays without contacting a provider. + ### 10.4 Worker Shell Worker Shell means Cloudflare's Workspace Shell capability implemented by @@ -2682,6 +3127,154 @@ Network is denied unless explicitly authorized. A committed result restores without starting a Worker; an effect interrupted before commit executes again against its pre-effect Workspace root. +### 10.5 Trusted evidence execution + +Some evidence can only be produced by running the project's own commands with +the project's own tools. That is not Worker Shell, and it is not a capability a +document may reach for by itself. + +```md + +``` + +`commands` is an authored **structured argv list**: an ordered, non-empty list whose every member is a non-empty list of strings. There is no interpreter, no quoting layer and no string to mis-split, which is what makes the record of what ran the same thing as what ran. `as` is required, the prop set is closed, and the form is validated before the host's ceilings are read — an empty list, an empty vector, a member that is not a list of strings, an unknown prop and a missing `as` each fail before any child exists. + +**The pipeline is fail-fast.** Commands run in authored order, and the first command that does not complete successfully is the last one that runs. A command is successful only when it exits normally with status `0`; a non-zero exit, a signal termination and a timeout are each retained as the final row and start no successor. A plan's evidence list is a pipeline — build, then test, then lint — and continuing past a failed build produces later rows evaluated against missing or stale prerequisites, which is evidence that is confidently wrong rather than absent. + +Breadth belongs inside one command whose own contract runs a corpus to completion, such as this repository's runtime-test shards, or in several separately authored `Evidence.Run` elements where the plan says the groups are independent. That a shard runs its files to the end says nothing about whether one arbitrary pipeline should continue after a failure. + +The host owns everything else. Which executables may run, what environment they see, the logical working root, how long each command and the whole list may take, how much output is retained, and what happens to a process tree are host ceilings rather than props. No host path, shell string, ambient environment or command authority enters an authored prop. The commands run against one exact retained Workspace root the host materialized, on the trusted runner where the native toolchain lives — never inside the run's durable owner, which has no toolchain and must not acquire one. + +**The result is the executed prefix, not one row per authored command:** + +```ts +interface EvidenceRunResult { + readonly completion: "passed" | "failed"; + readonly authoredCommands: number; + readonly executed: readonly EvidenceCommandResult[]; + readonly runTimeout?: EvidenceRunTimeout; +} + +interface EvidenceCommandResult { + readonly argv: readonly string[]; + readonly outcome: "exited" | "signalled" | "timeout"; + readonly status?: number; + readonly signal?: string; + readonly limit?: "command" | "run"; + readonly stdout: EvidenceChannel; + readonly stderr: EvidenceChannel; +} + +interface EvidenceRunTimeout { + readonly limit: "run"; + readonly notStartedAt: number; +} + +interface EvidenceChannel { + readonly text: string; + readonly retainedBytes: number; + readonly producedBytes: number; + readonly truncated: boolean; +} +``` + +`completion` is `"passed"` exactly when `executed` holds `authoredCommands` rows and every one of them is an `"exited"` row with `status: 0`; it is `"failed"` in every other case. `authoredCommands` is retained beside `executed` so a reader can tell a complete pass from a deliberately stopped prefix without knowing the authored list, which is the whole reason a prefix is safe to publish. + +`executed` holds the commands that ran, in authored order, and `argv` repeats the vector that ran. `status` is present exactly when `outcome` is `"exited"` and is the numeric exit status; `signal` is present exactly when `outcome` is `"signalled"`; `limit` is present exactly when `outcome` is `"timeout"` and names which ceiling fired. An unknown member, an unknown `outcome`, an unknown `limit`, and any of those three members beside the wrong outcome each refuse the record. + +`runTimeout` is present exactly when the whole-run ceiling expired **between** commands, with no child running. `notStartedAt` is the zero-based index into the authored list of the command that did not start. It is a member of its own rather than a row in `executed`, because a row would have to invent an argv that never ran and a channel that captured nothing. + +Both channels are retained separately and neither is folded into the other: `text` is the retained UTF-8 prefix, `retainedBytes` is its length in bytes, `producedBytes` is what the child actually produced, and `truncated` is `producedBytes > retainedBytes`. Truncation is stated rather than inferred from a length, so a reader never has to guess whether a command was quiet or cut off. + +**Two ceilings bound the work, and both are host-owned.** A per-command ceiling stops one command monopolizing the run; a whole-`Evidence.Run` ceiling bounds total wall-clock cost across the list, including process startup, output draining and teardown. Neither is an authored prop. Before starting each command the host requires positive remaining whole-run time, and while a command runs its effective deadline is the earlier of its own deadline and the whole-run deadline — so a timeout row's `limit` says which of the two fired. A whole-run ceiling that expires between commands ends the result with `completion: "failed"` and the `runTimeout` record above, and starts no successor. + +**A timeout is an ordinary unsuccessful outcome, not an infrastructure failure.** It records that the host enforced its ceiling successfully: it terminated and reaped the process tree and captured bounded channels. It becomes the last row and stops the pipeline. If termination, output draining or reaping *fails* while the host is enforcing that ceiling, the case is an infrastructure failure below rather than a timeout result — the difference is whether the host is reporting what it did or reporting that it could not. + +**Which cases bind, which fail, and which commit nothing.** + +| Case | Outcome | +| --- | --- | +| a command exits with status `0` | an ordinary row; the next command starts | +| a command exits non-zero, is terminated by a signal, or hits either duration ceiling | an ordinary final row; `completion` is `"failed"` and no successor starts | +| the whole-run ceiling expires between commands | `completion: "failed"` with a `runTimeout` record and no successor | +| the executable or environment ceiling refuses a command, or the child cannot be created | **launch failure**: the effect fails and produces no `EvidenceRunResult` | +| the host cannot read a channel it promised to bound | **output-pump failure**: the effect fails and produces no `EvidenceRunResult` | +| the host cannot terminate or reap a child or its process tree | **teardown failure**: the effect fails and produces no `EvidenceRunResult` | +| the effect is cancelled | the complete process tree is terminated and no completion and no failure record is committed | + +A non-zero status is evidence, not an infrastructure failure — it is the answer the evidence exists to obtain. An infrastructure failure is the host being unable to say what happened, which is why it publishes no result: half an answer read as a whole one is worse than no answer. + +**Precedence is fixed.** Cancellation wins over every other outcome, terminates the complete process tree, and commits neither a completion nor a failure record. Otherwise the first infrastructure failure is authoritative, and a teardown failure that follows it is retained as secondary evidence rather than replacing it. With no earlier infrastructure failure, a teardown failure is itself authoritative even when every command produced an observed exit: a host that cannot prove its process ownership settled cannot publish a successful binding. This is the rule the workflow lifecycle already applies to settlement, where teardown is part of the evidence rather than work performed after the outcome. + +**No successful binding is not the same as no retained evidence.** A failed effect retains bounded diagnostic evidence on its `Error`: the safely collected executed-command prefix, the separate bounded stdout and stderr channels, the primary infrastructure-failure category, and the secondary teardown category when there is one. That is filtered diagnostic failure evidence and it is deliberately not an `EvidenceRunResult` — nothing binds it, and no document reads it as a pass or a fail of the commands. Replaying a failed effect starts no process. Cancellation retains neither, because no completion won. + +The operation returns Effection's `Result` at the implementation boundary and puts its failure data on an `Error`, like every other outcome in this repository; the authored binding exists only for a successful `EvidenceRunResult`. There is no local success-or-failure union. + +`Evidence.Run` is not Worker Shell (§10.4) and does not replace it: Worker Shell is a contained interpreter over the Workspace filesystem, while this is native execution of an authored list under a trusted host's ceiling. It is absent from the workflow Agent's capabilities (§8.3) and from every generated-XMD table (§8.4), so neither an Agent nor a fragment it wrote can reach it. + +### 10.6 Project effects + +A **Project provider** is an external service that owns project boards and the +status of the items on them. GitHub Projects V2 is one adapter. + +This is a boundary of its own for the reason §10.3 gives about issues: 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 as an +`issue_effect`. `Project.Status` therefore reaches its own contextual operation +and journals its own durable effect type, and it reuses the shape of the +reconciliation rather than the Git host's state machine. + +```md + +``` + +The component is self-closing, its four props are required and the set is closed, and it binds the normalized `{ item, field, option }`. Its natural key is the exact item plus the exact field — one item has one value of one field, whoever is asking — and its request and result are these: + +```ts +interface ProjectStatusRequest { + readonly kind: "project-status"; + readonly item: string; + readonly field: string; + readonly option: string; +} + +interface ProjectStatusBinding { + readonly item: string; + readonly field: string; + readonly option: string; +} + +interface ProjectStatusResult extends ProjectStatusBinding { + readonly observedOption?: string; + readonly decision: "performed" | "adopted"; +} +``` + +`item`, `field` and `option` are the provider's own opaque identities, compared byte for byte and never normalized, decoded or repaired — they are provider identities on the same terms an issue node ID is. `option` in the binding is the requested one, which after a successful effect is the one the item holds. `observedOption` is what the field held when this attempt looked, and it is absent rather than null when the field held no option at all. An unknown member and an unknown `decision` refuse the record. + +Its compatible pre-state is the option that item currently holds. An item already at the requested option is adopted with nothing performed; an item at another option the host's ceiling allows is performed once; an item at an option outside that ceiling is a conflict, because moving it would be publishing through a status this factory does not own. An unreadable board, an unavailable field, an ambiguous item and a partial permission read are **unavailable** rather than absent — reading an unreadable board as an empty one is how an unauthorized item would be moved — and a temporary unreachability refuses as itself. Cancellation publishes no completion, and a completed record replays without contacting a provider. + +Which project, item, field and options may be reached at all is a host ceiling +installed beside the credential. An authored prop selects within that ceiling +and can never widen it, which is the same rule §10.3's tracker follows. + +**A board is a projection.** The status it shows is published from the run's own +journaled lifecycle, never read as it. A board ahead of the journal is drift the +next execution reconciles, and it is not evidence that a transition happened. + +### 10.7 What never crosses these boundaries + +Every effect in §10.2, §10.3, §10.5 and §10.6 reaches its provider the same way, +and the same things stay out of the record. Credentials are not inputs: an +application private key, a webhook secret, an issued installation token, an +OIDC verification configuration, a provider endpoint, a raw provider payload, a +pagination cursor and a host path stay in the selected provider's own closure. +None of them enters a component prop, context composition data, a durable +request, a natural key, a retained result, a comment body, document output or a +diagnostic. What a durable record holds is the normalized request, the natural +key, the observed pre-state and the normalized result — enough to reconcile the +effect, and nothing that would make the journal a place to read a secret from. + ## 11. History forks Normal resume always uses the same immutable definition, normalized props, @@ -3146,6 +3739,83 @@ There is no public `Git.Fetch` here. The shipped Git scope is Repository clone and its remote reads, plus `Git.Push` observation and mutation; a future public fetch operation requires its own language and durability contract. +### 13.2 Remote topology + +A remote host runs the same contracts with the durable state and the native +tools in two different places. + +One SQLite-backed Cloudflare Durable Object per run is the durable owner, +selected from the public run ID by the same arithmetic §9.3 of the workflow +specification uses. It holds the run record and filtered journal, the immutable +Workspace roots and their content-addressed bytes, the Repository and Worktree +records, the Agent-session mappings, the retained delivery state and the +authenticated intake records, and it owns executor admission. It is a +runtime-named adapter beside the Deno one: shared modules reach it through the +same contextual storage, lifecycle and Workspace APIs, detect no runtime, and +import nothing Cloudflare-specific. + +**The host assembly contract does not change.** `WorkflowHost` keeps its four methods — `useRunHost()`, `useLifecycle()`, `useDelivery()` and `attach()` — and the Cloudflare adapter is one more implementation of them beside the Deno one. Starting, looking up, executing, delivering into and inspecting a run are lifecycle operations reached *through* that boundary, exactly as they are locally; they are not replacement method names, and no fifth method appears. A remote host receives no transitions type of its own either. What a remote adapter changes is where each of those four reaches, not what the shared CLI asks for. + +**The transition types those methods speak are provider-neutral.** `WorkflowExecutionTransitions`, `WorkflowBeginRequest`, `WorkflowExecutionBegun`, `WorkflowForkRequest`, `WorkflowForkSelection` and `WorkflowRunCreation` describe what any host's lifecycle does, not what one adapter retains, and they are already defined in the provider-neutral lifecycle module. They become package-root public types, and the Deno entrypoint may keep re-exporting them for source compatibility without owning their meaning. Runtime-specific implementations and retained encodings — SQLite, DOFS, run-id hashing, filesystem paths — stay behind their runtime-named entrypoints, which is the boundary that rationale was always about. They are package-root public types now, and the Deno entrypoint re-exports them for source compatibility without owning their meaning. + +Executor ownership is one authenticated WebSocket connection whose lifetime is the acquisition. The owner registers the exact acquisition on admission and invalidates it on close; there is no duration, expiry, renewal, heartbeat, generation record or liveness poll, and a close rolls back nothing already committed. Every mutating transaction validates that exact acquisition and the expected Workspace root together. + +Native Git, evidence processes and Agent clients run on the ephemeral runner and nowhere else. The runner materializes one selected retained root, works in it, and submits content-addressed changes; the owner validates acquisition, root and content and then atomically publishes the new root with the filtered journal result. That is §10.1's effect transaction with the mutation performed where the tools are and the publication performed where the authority is, so a runner crash exposes only a prior or a new complete transaction and the next acquisition resumes from the exact committed frontier. + +#### The transport between them is private to one release + +The runner client and the durable owner ship as one software-factory release identity, so the messages between them are not a compatibility boundary and are not a public contract. They are journaled by neither side, exported by neither, authored by nobody, and never expected to interoperate across independently versioned builds. Their decomposition is implementation detail. + +What replaces a wire contract is a release-identity check at admission. Connection admission validates an exact immutable client and server build or protocol fingerprint supplied by trusted deployment configuration, and a mismatch refuses closed — before request parsing, before acquisition, before any state access. There is no cross-version adaptation, no downgrade and no compatibility promise, because two builds that disagree about what was committed is the failure this check exists to prevent rather than to survive. + +Privacy of the transport is not privacy of the authority. These constraints are public and exact however the messages are decomposed: + +- one authenticated connection is one executor acquisition; +- every execution mutation validates that acquisition and the expected Workspace root inside the owner's transaction; +- the owner alone parses and adopts requests and alone opens and commits transactions; +- content-addressed data is validated before publication; +- delivery and inspection use separate authenticated paths that take no acquisition; +- credentials and raw transport payloads are never durable public records; +- a runner-to-owner release-identity mismatch refuses closed; and +- a completed replay may read its durable owner but attaches no execution or external-effect provider. + +#### Which side owns what + +| Concern | Owner | +| --- | --- | +| connection admission, including OIDC claim validation and acquisition registration | the durable owner | +| parsing every request | the durable owner; the runner parses only responses | +| opening, committing and rolling back transactions | the durable owner | +| content-addressed transfer | the runner produces content and names it; the owner validates and stores it | +| attaching Workspace, Agent, process, Git, Git-host, Issue and Project providers | the runner | +| cancellation | whichever side owns the scope being cancelled: the runner cancels its own document execution, and the owner cancels nothing on its behalf | +| closing the connection | either side; the owner invalidates the acquisition when it closes | +| stale-execution recovery | the durable owner, at the next acquisition | + +Delivery and inspection reach the owner without an acquisition, under §3.8, on authenticated paths of their own. + +#### What the owner implements, and how a runner reaches it + +Everything above is implemented behind the four methods and nothing wider: creating a run and finding it, coherent reads of its committed state, lifecycle transitions and terminal settlement, stale recovery at the next acquisition, fork and the staged candidate a fork is admitted by, root validation and atomic Workspace publication, typed delivery and its later consumption, read-only inspection and history, and canonical completed replay. The owner refuses in its own vocabulary and mutates nothing when it does, on every one of those paths. + +The three planes are three requests, and the path says which. One public run id selects its owner arithmetically through the namespace's own `idFromName`, and a gateway in front of the objects routes on that id and forwards the request whole — it parses no command, verifies no token and commits nothing, because an owner that trusted a gateway's account of any of those would have moved its own admission outside itself. The executor plane is a real WebSocket upgrade: the owner creates the pair, checks the release before the token and the token before the run, registers the acquisition last, and hands back the client half. The read and delivery planes are ordinary authenticated requests that accept no socket and take no acquisition. + +A runner reaches all three through one configured client, which is the supported public surface: an already-selected run id, one credential-free endpoint parsed at construction, the exact release identity, an operation that mints a short-lived token for the immediate request, and the HTTP and WebSocket I/O the host performs. The client is bound to that one run — every plane requires the configured id before a token exists, a URL is built or anything is sent — and the endpoint, the release and the token stay in its closure, reaching no workflow record, journal event, public error or document-visible value. What crosses on the planes stays private: the paths, the header names, the commands and the refusal spellings are one release talking to itself, and the one refusal category that is a fact about the run rather than about the connection — another live executor holds it — is the only one that becomes a public answer. + +Trusted code assembles that client into the same four-method host the local entrypoint installs. `useRunHost()` installs the executor lifecycle over the client's acquisition and returns the provider-neutral transitions; `useLifecycle()` installs status, list and history over the read plane, where the complete domain of one bound owner is zero or one coherent snapshot and no namespace-wide claim; `useDelivery()` installs typed answer delivery over the delivery plane; and `attach()` installs everything a live or partial document execution reaches. A completed replay reaches none of it: it reads the owner and opens and closes its own execution envelope, and `attach()` is never called. + +**A remote attachment installs the same capabilities a local one does.** The logical Workspace working directory, the document `API.Files` provider, the Repository, Worktree and `` composition, the transactional Git components, the composition components, the pull-request and Issue middleware, elicitation and — where the host supplied one — the Agent profile. They are one set for the reason §10.1 gives: the Files provider alone would resolve an authored path against whatever working directory the surrounding host adapter answers with, so a workflow document would write to the runner's own filesystem instead of the run's Workspace. The document rules are written once and reach both hosts, and each host contributes four things to them: the effect its mutations become, the savepoint that undoes a part of one mutation which could not be finished, the read an ephemeral attachment needs, and the transaction an Agent-session mapping is retained by. Locally the effect is bound to the validated lease, the savepoint is a real transaction savepoint, and both reads are that lease's own transaction. On a runner the effect is bound to the exact remote run; the savepoint restores the disposable attempt from the accepted root; the attachment read takes one coherent owner snapshot, materializes the selected root into a directory this invocation owns, hands the attachment only the retained metadata and that filesystem, and closes before native Git runs — so no owner read and no transaction is held across a subprocess; and an Agent-session mapping is staged from the admitted state on the runner, where the provider is, then submitted as bounded deltas to one owner transaction that revalidates each subject and commits them all or refuses them together. No provider call happens inside that transaction, a body that failed or was cancelled sends nothing, and a conflicting assertion never replaces a retained one. Which binding answers is decided by the exact storage handle the attachment registered — not by a context value, a run id or anything else a caller could hold — so a handle another client opened finds that client's binding or none, and a document with no attachment is refused rather than performing its write somewhere. + +The host-owned inputs those capabilities need — the credential helper, the Issue and pull-request configuration, the Agent profile installer — are explicit configuration to whoever assembles the runner. Nothing about them is read from a flag, an environment variable, a document prop or a global, and an absent one installs that capability's unconfigured behavior rather than a different capability. + +**An attachment is bound to the handle that produced it.** The handle a begin transition hands back carries its own routed journal and the provenance taken over it, and it remembers the exact link it was opened from. Attaching compares that link to the ones this runner's own acquisitions produced, by object identity. Two clients on two owners can hold handles whose run id, root and journal anchor are identical, so nothing a handle says about itself could establish which run it is; what establishes it is where it came from, and a handle another client opened — or one shaped like a handle and opened by nothing — is refused before a temporary tree, a materialization or a request exists. + +What does not exist yet is anything that would *choose* this host. There is no runtime or CLI selector, no ambient endpoint, release-identity or OIDC source, no flag, prop or environment reading for any of it, and no deployment. The shipped Deno and compiled entrypoints install the local host, Node and Bun remain unsupported hosts, and the explicit installer above is the seam a later trusted factory assembly passes its configuration to. + +#### What completed replay does and does not reach + +A completed run replays there as it does locally: it attaches no Workspace, Agent, process, Git, Git-host, Issue, Project or credential provider and performs no effect a second time. It does reach the run's durable owner, because that is where the retained result is; an ephemeral client holds nothing of its own to replay from. Reading retained completion from the owner that holds it is not attaching a provider, and the distinction is the whole point of the rule: what a completed replay must not do is contact an *external* service or repeat an effect, not refrain from reading its own history. What it reads, and what makes a retained terminal readable at all, is [Workflow runs](./workflow-spec.md) §9.9: one final root `Close`, one semantic outcome, a root import parsed by canonical execution's own parser and verified against the document it recorded, damage outranking the stored row, and a coherent replay changing nothing but the execution envelope it opened. + ## 14. Contract inventory | Contract | Status at this design revision | @@ -3154,25 +3824,36 @@ fetch operation requires its own language and durability contract. | retained run record and filtered journal | built by #291 | | caller-owned storage transaction | built by #291; Workspace mutations join it in #365 | | provider-backed retained Workspace | document filesystem built by #366 and repository composition by #293; document deletion (§10.1) built by #567 for both providers; process capabilities unbuilt (#218) | -| `xmd workflow start` / `resume` | built by #366, Deno entrypoints only; both acquire #367's executor lock | -| ``, `` and `` composition | built by #293, Deno provider only | -| transactional Git components (`Git.Switch`, `Git.Add`, `Git.Commit`) | built by #294, Deno provider only | +| `xmd workflow start` / `resume` | built by #366, Deno entrypoints only; both acquire #367's executor lock. The same two lifecycle actions are implemented against the durable owner, where the acquisition is the connection rather than the lock, and trusted code assembles that host explicitly (§13.2); no selector chooses it, so the shipped entrypoints are local | +| ``, `` and `` composition | built by #293; both owners by #698 — one set of rules over whichever host the exact storage handle is bound to. Native Git runs on the runner against invocation-owned materialization in either case; what differs is where the checkout's bytes and its immutable identity are retained, and which read an ephemeral reattachment goes through. No selector chooses the remote owner (§13.2) | +| transactional Git components (`Git.Switch`, `Git.Add`, `Git.Commit`) | built by #294; both owners by #698 — the mutation runs on the runner and its resulting root and filtered journal result are published in one transaction by whichever owner holds the run. No selector chooses the remote one (§13.2) | | `` read and upsert, and the `issue_effect` boundary (§10.3) | built by #296; GitHub middleware, Deno host | | ``, ``, `` (§7.7) | built by #576; GitHub middleware, Deno host. Named by canonical URL and asked of `PullRequestApi`, which carries the upsert too; ordinary durable reads rather than reconciled effects, inheritable by a fork; complete or unavailable, never truncated. Which URLs may be read is operator configuration | -| lifecycle status/list/history | built by #367 | -| lifecycle cancel/delete and executor lock | built by #367 | +| lifecycle status/list/history | built by #367; the durable owner answers the same three from one committed reading, taking no acquisition, attaching nothing and appending nothing | +| lifecycle cancel/delete and executor lock | built by #367; remote cancellation follows what the run retains and takes an acquisition of its own, and a terminal the owner cannot read refuses cancellation without mutating anything | | durable suspension request and executor-lock release | built by #367 | -| `xmd workflow answer` and the `suspension_answer` effect | built by #300 | +| `xmd workflow answer` and the `suspension_answer` effect | built by #300; delivery and consumption are implemented on the owner's own authenticated path, which takes no acquisition, and the response schema is judged by the boundary that writes the value (§3.5) | | workflow scheduling (watchers, unattended iteration, remote hosts) | #300 | -| history fork | built (§11); Deno provider only | +| history fork | built (§11); both providers — the owner copies one selected prefix and its roots into a destination that commits whole or not at all, over a staged candidate assembled without contacting it, and a refusal mutates neither side | | XMD artifact export, inspection and fork source | specified in `specs/xmd-artifact-spec.md`; `xmd workflow export` and artifact `status`/`history` are built, Deno provider only. The artifact-backed fork remains unbuilt | | Agent session portability evidence in an artifact | specified in `specs/xmd-artifact-spec.md` §2.5; the format and its complete verifier are built. Provider bundle capture, Agent-aware export, intrinsic Agent-aware inspection and artifact-backed fork are unbuilt | | workflow Agent isolation | built by #302: no directory attachment, an empty host-owned working directory, no MCP servers, an empty requested tool set and deny-all with a failing permission path; the portable no-tool proof is tracked by #496 | -| workflow Agent session retention | built by #302: a row in the run's own database, keyed by the engine-derived Session expansion identity alone — the authored name is descriptive — with provider, agent command and policy fingerprint beside it as compatibility attributes. The mapping commits after the provider's canonical tagged assertion and before the first Prompt; occupancy of a provider key is never identity, and missing, mismatched, replaced or ambiguous assertions each refuse instead of starting a replacement session | +| workflow Agent session retention | built by #302, and by #698 for a run whose storage is elsewhere: the provider is established or asserted on the runner, outside any transaction, and the mapping is submitted as bounded deltas to one owner transaction that revalidates the subject and commits or refuses it whole. a row in the run's own database, keyed by the engine-derived Session expansion identity alone — the authored name is descriptive — with provider, agent command and policy fingerprint beside it as compatibility attributes. The mapping commits after the provider's canonical tagged assertion and before the first Prompt; occupancy of a provider key is never identity, and missing, mismatched, replaced or ambiguous assertions each refuse instead of starting a replacement session | | generated-XMD admission | built by #369, through `@executablemd/core/host`; the workflow policy wrapper is internal. Host policy is a read table and a write table of exact pinned identities, each entry carrying the authored forms it is admitted for, and an authored `allow` selects a canonical subset of the closed classes `read` and `write` — omitted means `read`. The complete fragment is preflighted inside one `generated_xmd` effect before its first generated effect | | `` and the authored loop | built by #302 and #369: a workflow-host component with a closed schema of one required `source` and one optional `allow`, declared to the execution rather than registered by the attachment — canonical execution calls the host's factory with the claimant it minted and registers what comes back, which provides availability only. Its ceilings come from the run's own storage, core's pinned `` read and write identities and this package's lexical ``; iteration, branching, approval and exhaustion are ordinary Markdown | | generated-XMD mutation-proposal admission | built by #369 and #567: the standard Deno profile's write table is core's paired `File:write`, this package's lexical `Dir` and core's self-closing `File.Delete`, in that retained order and followed by any host extension; admitted mutations run as the ordinary components they are through the run's effect transactions, a generated deletion publishing the same `workspace_file` effect an authored one does; the evaluator adds no receipt or result entry, so a write-only fragment still binds `{ observations: [], output: "" }`; and approval is authored control flow before the element. Local Git, Git-host, issue, process, execution, credential and external-write effects are outside the class | | Deno-local DOFS persistence | POC proven by #349 / PR #350 | | scoped Deno Worker Shell | containment proven by #351 / PR #353 and transactions by #357 / PR #362; production integration unbuilt | +| `Git.Merge` ordered Workspace-local merge (§7.8) | specified by #710; implementation unbuilt | +| `Git.PublishTarget` compare-and-swap target publication (§7.9) | specified by #710; implementation unbuilt | +| `PullRequest.Comment`, `PullRequest.Ready`, `PullRequest.Close` (§7.10) | specified by #710; implementation unbuilt | +| `PullRequest.Merged` reconciled merged observation (§7.11) | specified by #710; implementation unbuilt | +| `Issue.Comment` and `Issue.Close` (§10.3) | specified by #710; implementation unbuilt | +| `Evidence.Run` trusted native evidence execution (§10.5) | specified by #710; implementation unbuilt | +| `Project.Status` and the Project-provider boundary (§10.6) | specified by #710; implementation unbuilt | +| factory protocol records consumed by these effects | specified by #710 and owned normatively by [the software factory](./github-actions-software-factory-spec.md) §11.2, which this specification links to rather than duplicating: `Git.Merge`'s publish ceiling reads the Stage 7 decision, `PullRequest.Merged`'s wait is one of those records, and `Project.Status` projects a stage through the configured stage-to-option table | +| remote lifecycle host, executor connection, versioned runner transport and remote topology (§3.8, §13.2) — the existing four-method `WorkflowHost` boundary, with a Cloudflare implementation beside the Deno one | built by #698: one SQLite-backed Durable Object per run, connection-lifetime acquisition, owner-side parsing and transactions, release identity at admission, the three request planes and the gateway that routes to them, one configured client bound to one run, and an explicit trusted assembly of all of it into the same four methods. What remains unbuilt is choosing it: no runtime or CLI selector, no ambient endpoint/release/OIDC source, and no deployment | +| terminal-decision delivery on the delivery plane (§3.8) | specified by #710; implementation unbuilt. Answer delivery and consumption on that plane are built by #698; a terminal decision as a delivery subject belongs to the factory records that define it | +| canonical completed replay from retained state (§9, §13.2) | built by #698 for both providers: the retained root document and the bundle admission come from the run's own committed state, component bytes are authenticated by Git blob identity, an unreadable terminal, import or selection refuses before anything is replayed, and a coherent replay changes only the execution envelope it opened | | Worker JavaScript | deferred | | bundled workerd local host | omitted; POC #347 / PR #348 retained as provider evidence | diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..6517e2270 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "vitest/config"; +import { cloudflareTest } from "@cloudflare/vitest-plugin"; + +/** + * The workerd suite. + * + * These tests run against a real Durable Object namespace, real SQLite storage + * and a real WebSocket, because acquisition lifetime, owner eviction and + * transaction atomicity are properties of that runtime rather than of any model + * of it. Nothing here is discoverable by `deno task test`: the corpus walks + * `*.test.ts`, and these are `*.vitest.ts`, so the Deno, Node and Bun shards + * never see a file importing `cloudflare:test`. + */ +export default defineConfig({ + test: { + projects: [ + { + test: { + name: "cloudflare", + include: ["packages/workflow/tests/cloudflare/**/*.vitest.ts"], + }, + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./packages/workflow/tests/cloudflare/wrangler.jsonc" }, + }), + ], + }, + ], + }, +});