From 8f27b04986712c49eaa77c63321e00d0a9ff6020 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 15:34:01 -0400 Subject: [PATCH 01/42] =?UTF-8?q?=F0=9F=94=A7=20Own=20the=20neutral=20life?= =?UTF-8?q?cycle=20types=20and=20derive=20a=20factory=20run=20id=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first slice of F1: the seams a remote host needs, and the identity it is addressed by. No Cloudflare code yet — this is the boundary work that has to be true before a second host can exist. `WorkflowExecutionTransitions`, `WorkflowBeginRequest`, `WorkflowExecutionBegun`, `WorkflowForkRequest`, `WorkflowForkSelection` and `WorkflowRunCreation` now export from the package root, which is where they mean what they mean. They were already defined in the provider-neutral lifecycle module but published only through `@executablemd/workflow/deno`, so the shared CLI imported its own return type from an adapter and a second host would have had to load that adapter to name it. The Deno entrypoint keeps its re-exports for source compatibility with a comment saying what belongs behind it — implementations and retained encodings, not the shape of a request — and the CLI and its tests now import from the root. `deriveFactoryRunId()` implements the settled derivation: lowercase unpadded RFC 4648 Base32 over all 32 SHA-256 bytes of `github-issue-v1`, NUL, the canonical GitHub authority, NUL, the exact issue node id. The authority folds case and keeps a non-default port; a scheme, user information, path, query, fragment, whitespace, malformed host or port, and a written-out default port each refuse by name rather than being repaired, because two spellings that both became one authority would be two runs quietly becoming one. The node id is held to non-empty and NUL-free and otherwise passes through byte for byte. Three tests. The identity suite checks fixed vectors computed outside this implementation, the RFC 4648 §10 encoding vectors, that the NUL separators make (authority, node id) unambiguous, and every refusal. A host-neutrality suite walks the shared modules and fails on a host-owned specifier or a runtime detection outside a runtime-named entrypoint, so a Cloudflare import cannot leak into shared code unnoticed. A host-boundary suite pins `WorkflowHost` to exactly four methods and proves it is satisfiable from root-exported types alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/cli/src/deno-workflow.ts | 3 +- packages/cli/src/workflow-fork.ts | 5 +- packages/cli/src/workflow.ts | 2 +- .../cli/tests/workflow-host-boundary.test.ts | 95 +++++++ .../cli/tests/workflow-installation.test.ts | 2 +- .../tests/workflow-lifecycle-control.test.ts | 2 +- .../cli/tests/workflow-suspension.test.ts | 2 +- packages/workflow/deno.ts | 9 + packages/workflow/mod.ts | 24 ++ packages/workflow/src/factory/run-id.ts | 251 ++++++++++++++++++ .../workflow/tests/factory-run-id.test.ts | 159 +++++++++++ .../workflow/tests/host-neutrality.test.ts | 104 ++++++++ 12 files changed, 648 insertions(+), 10 deletions(-) create mode 100644 packages/cli/tests/workflow-host-boundary.test.ts create mode 100644 packages/workflow/src/factory/run-id.ts create mode 100644 packages/workflow/tests/factory-run-id.test.ts create mode 100644 packages/workflow/tests/host-neutrality.test.ts 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/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..ebed19b44 100644 --- a/packages/cli/src/workflow.ts +++ b/packages/cli/src/workflow.ts @@ -90,7 +90,7 @@ import type { WorkflowExecutionBegun, WorkflowExecutionTransitions, WorkflowRunCreation, -} from "@executablemd/workflow/deno"; +} from "@executablemd/workflow"; import type { SuspensionControllerOptions, SuspensionNotice } from "@executablemd/workflow/deno"; import { SUSPENSION_REQUEST } from "@executablemd/workflow"; import { describeError } from "./props.ts"; 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..f2a71bf0d 100644 --- a/packages/cli/tests/workflow-installation.test.ts +++ b/packages/cli/tests/workflow-installation.test.ts @@ -26,7 +26,7 @@ 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"; 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-suspension.test.ts b/packages/cli/tests/workflow-suspension.test.ts index 9a14429b5..a635d3a97 100644 --- a/packages/cli/tests/workflow-suspension.test.ts +++ b/packages/cli/tests/workflow-suspension.test.ts @@ -49,7 +49,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"; diff --git a/packages/workflow/deno.ts b/packages/workflow/deno.ts index 368e3ea1c..012dada52 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, diff --git a/packages/workflow/mod.ts b/packages/workflow/mod.ts index 85f09ea39..e0d6fabf3 100644 --- a/packages/workflow/mod.ts +++ b/packages/workflow/mod.ts @@ -314,6 +314,30 @@ export type { WorkflowLifecycleApi, WorkflowLifecycleSnapshot, } from "./src/lifecycle/api.ts"; +export { + admitFactoryRunSubject, + admitIssueNodeId, + base32Unpadded, + canonicalGitHubAuthority, + deriveFactoryRunId, + FACTORY_RUN_ID_LENGTH, + factoryRunIdPreimage, + FactoryRunSubjectError, +} from "./src/factory/run-id.ts"; +export type { FactoryRunSubject, FactoryRunSubjectFailure } from "./src/factory/run-id.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/src/factory/run-id.ts b/packages/workflow/src/factory/run-id.ts new file mode 100644 index 000000000..5ad6d0474 --- /dev/null +++ b/packages/workflow/src/factory/run-id.ts @@ -0,0 +1,251 @@ +/** + * 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. + */ +export 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. + */ +export 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. */ +export 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): Uint8Array { + const encoder = new TextEncoder(); + const scheme = encoder.encode(SCHEME); + const authority = encoder.encode(subject.authority); + const node = encoder.encode(subject.issueNodeId); + const bytes = new Uint8Array(scheme.length + 1 + authority.length + 1 + node.length); + 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 bytes; +} + +/** 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) as BufferSource), + ); + return base32Unpadded(new Uint8Array(digest)); +} diff --git a/packages/workflow/tests/factory-run-id.test.ts b/packages/workflow/tests/factory-run-id.test.ts new file mode 100644 index 000000000..37a2759ab --- /dev/null +++ b/packages/workflow/tests/factory-run-id.test.ts @@ -0,0 +1,159 @@ +/** + * 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, + base32Unpadded, + canonicalGitHubAuthority, + deriveFactoryRunId, + FACTORY_RUN_ID_LENGTH, + factoryRunIdPreimage, + FactoryRunSubjectError, +} from "@executablemd/workflow"; + +/** 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; + +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(FACTORY_RUN_ID_LENGTH); + 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 = 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(canonicalGitHubAuthority("GitHub.Com")).toEqual("github.com"); + expect(canonicalGitHubAuthority("GitHub.Example.COM:8443")).toEqual("github.example.com:8443"); + }); + + it("refuses every part an authority may not carry", function* () { + expect(reason(() => canonicalGitHubAuthority(""))).toEqual("authority-empty"); + expect(reason(() => canonicalGitHubAuthority("https://github.com"))).toEqual( + "authority-has-scheme", + ); + expect(reason(() => canonicalGitHubAuthority("user@github.com"))).toEqual( + "authority-has-userinfo", + ); + expect(reason(() => canonicalGitHubAuthority("github.com/octo"))).toEqual("authority-has-path"); + expect(reason(() => canonicalGitHubAuthority("github.com/"))).toEqual("authority-has-path"); + expect(reason(() => canonicalGitHubAuthority("github.com?a=b"))).toEqual("authority-has-query"); + expect(reason(() => canonicalGitHubAuthority("github.com#top"))).toEqual( + "authority-has-fragment", + ); + expect(reason(() => canonicalGitHubAuthority("git hub.com"))).toEqual( + "authority-has-whitespace", + ); + expect(reason(() => canonicalGitHubAuthority("-github.com"))).toEqual( + "authority-malformed-host", + ); + expect(reason(() => canonicalGitHubAuthority("github.com:https"))).toEqual( + "authority-malformed-port", + ); + expect(reason(() => canonicalGitHubAuthority("github.com:0"))).toEqual( + "authority-malformed-port", + ); + expect(reason(() => canonicalGitHubAuthority("github.com:70000"))).toEqual( + "authority-malformed-port", + ); + }); + + it("refuses a default port written out, so one deployment has one spelling", function* () { + expect(reason(() => canonicalGitHubAuthority("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/host-neutrality.test.ts b/packages/workflow/tests/host-neutrality.test.ts new file mode 100644 index 000000000..d178dad81 --- /dev/null +++ b/packages/workflow/tests/host-neutrality.test.ts @@ -0,0 +1,104 @@ +/** + * 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. + * + * `vendor` is pinned upstream source whose drift verifier owns its bytes. + */ +const RUNTIME_OWNED = ["deno.ts", "cloudflare.ts", "src/deno", "src/cloudflare", "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.includes("/src/deno/"))).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([]); + }); +}); From 95a8f0e219971de37175d64c4a80539f615b9d19 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 15:46:22 -0400 Subject: [PATCH 02/42] =?UTF-8?q?=F0=9F=9A=9A=20Keep=20the=20GitHub-named?= =?UTF-8?q?=20run-id=20derivation=20out=20of=20the=20neutral=20surface=20(?= =?UTF-8?q?#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DLC13` in packages/workflow/tests/workspace-effect.test.ts scans `packages/workflow/mod.ts` and `packages/workflow/src/**` (minus the Deno adapter) and refuses a set of names that includes `GitHub`. Its reason is that the shared external-effect boundary exists so any Git host can be adapted to it, and "the first adapter naming itself in a shared contract is how a neutral surface quietly becomes one provider's." 8f27b04 put `deriveFactoryRunId()` under `packages/workflow/src/factory/` and exported it from the package root, which broke that test — correctly. The derivation names GitHub in its scheme tag, its authority rule and its node id, because the software factory is a GitHub product by definition. Move it to `packages/cli/src/factory-run-id.ts`, beside the CLI's existing `github-issues-config.ts` where naming GitHub is already legitimate, and take it back out of `mod.ts`. The module and its tests are unchanged otherwise. The host-neutrality scan added in 8f27b04 no longer exempts `cloudflare.ts` or `src/cloudflare`: neither exists yet, and a scan that exempts a path nothing occupies is a claim about a boundary nobody drew. Where the derivation lives permanently is not settled by this commit. The Durable Object owner needs it too, and F1 cannot place it until DLC13's scanned set is reconciled with a Cloudflare adapter subtree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- .../factory/run-id.ts => cli/src/factory-run-id.ts} | 0 .../{workflow => cli}/tests/factory-run-id.test.ts | 2 +- packages/workflow/mod.ts | 12 ------------ packages/workflow/tests/host-neutrality.test.ts | 2 +- 4 files changed, 2 insertions(+), 14 deletions(-) rename packages/{workflow/src/factory/run-id.ts => cli/src/factory-run-id.ts} (100%) rename packages/{workflow => cli}/tests/factory-run-id.test.ts (99%) diff --git a/packages/workflow/src/factory/run-id.ts b/packages/cli/src/factory-run-id.ts similarity index 100% rename from packages/workflow/src/factory/run-id.ts rename to packages/cli/src/factory-run-id.ts diff --git a/packages/workflow/tests/factory-run-id.test.ts b/packages/cli/tests/factory-run-id.test.ts similarity index 99% rename from packages/workflow/tests/factory-run-id.test.ts rename to packages/cli/tests/factory-run-id.test.ts index 37a2759ab..8af3467a2 100644 --- a/packages/workflow/tests/factory-run-id.test.ts +++ b/packages/cli/tests/factory-run-id.test.ts @@ -19,7 +19,7 @@ import { FACTORY_RUN_ID_LENGTH, factoryRunIdPreimage, FactoryRunSubjectError, -} from "@executablemd/workflow"; +} from "../src/factory-run-id.ts"; /** An opaque node id of the shape GitHub's GraphQL API returns for an issue. */ const NODE = "I_kwDOABCD12M5abcdef"; diff --git a/packages/workflow/mod.ts b/packages/workflow/mod.ts index e0d6fabf3..18a0a049c 100644 --- a/packages/workflow/mod.ts +++ b/packages/workflow/mod.ts @@ -314,18 +314,6 @@ export type { WorkflowLifecycleApi, WorkflowLifecycleSnapshot, } from "./src/lifecycle/api.ts"; -export { - admitFactoryRunSubject, - admitIssueNodeId, - base32Unpadded, - canonicalGitHubAuthority, - deriveFactoryRunId, - FACTORY_RUN_ID_LENGTH, - factoryRunIdPreimage, - FactoryRunSubjectError, -} from "./src/factory/run-id.ts"; -export type { FactoryRunSubject, FactoryRunSubjectFailure } from "./src/factory/run-id.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 diff --git a/packages/workflow/tests/host-neutrality.test.ts b/packages/workflow/tests/host-neutrality.test.ts index d178dad81..1487d4baf 100644 --- a/packages/workflow/tests/host-neutrality.test.ts +++ b/packages/workflow/tests/host-neutrality.test.ts @@ -28,7 +28,7 @@ const PACKAGE = fileURLToPath(new URL("..", import.meta.url)); * * `vendor` is pinned upstream source whose drift verifier owns its bytes. */ -const RUNTIME_OWNED = ["deno.ts", "cloudflare.ts", "src/deno", "src/cloudflare", "vendor"]; +const RUNTIME_OWNED = ["deno.ts", "src/deno", "vendor"]; /** Specifiers only a host adapter may import. */ const HOST_SPECIFIERS = [ From 61b63fcee831bb99943a63a9a48ac2224f27f98c Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 16:33:51 -0400 Subject: [PATCH 03/42] =?UTF-8?q?=F0=9F=9A=9A=20Give=20the=20software=20fa?= =?UTF-8?q?ctory=20its=20own=20package=20subpath=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the Architect's answers to Q1 and Q2 in .vscode/698/698-a-1.md. Q1. `DLC13` now excludes `packages/workflow/src/cloudflare/**` and `packages/workflow/src/software-factory/**`, each with its own 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 those names. The software factory is the other kind of exception: not a runtime adapter, still held to the host-import and runtime-detection rules by `host-neutrality.test.ts`, and allowed only the product vocabulary, because §1.1 of the factory specification makes GitHub the subject matter of that contract rather than one provider capturing a neutral boundary. `packages/workflow/mod.ts` and every shared module stay scanned, and no forbidden word gained an exception. Beside the existing Deno assertion, `found` is now checked to contain no `/src/cloudflare/` and no `/src/software-factory/` path, so an exclusion that matched nothing or matched too little cannot pass quietly. `host-neutrality.test.ts` gains `cloudflare.ts` and `src/cloudflare` in `RUNTIME_OWNED` and asserts the Cloudflare subtree is absent from what it scans. `software-factory.ts` is deliberately not runtime-owned there: it uses the cross-runtime Web primitives and is checked like any shared module, which the new assertion that `src/software-factory/run-id.ts` is among the scanned modules now proves. Q2. The derivation moves from `packages/cli/src/factory-run-id.ts` to `packages/workflow/src/software-factory/run-id.ts`, published as `@executablemd/workflow/software-factory` from both manifests. One implementation, reachable by the provider host and by F2's GitHub intake without either carrying a hash that has to agree byte for byte with the other's. The package root does not re-export it — that neutrality is the point — and no forwarding copy is left in the CLI. Its tests move to the workflow package and import the published subpath, so the export map is what they exercise. Q3 needs no change yet; it governs §8 and §9, which are unstarted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/deno.json | 1 + packages/workflow/package.json | 1 + packages/workflow/software-factory.ts | 46 +++++++++++++++++++ .../src/software-factory/run-id.ts} | 0 .../workflow/tests/host-neutrality.test.ts | 10 +++- .../tests/software-factory-run-id.test.ts} | 2 +- .../workflow/tests/workspace-effect.test.ts | 34 ++++++++++++-- 7 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 packages/workflow/software-factory.ts rename packages/{cli/src/factory-run-id.ts => workflow/src/software-factory/run-id.ts} (100%) rename packages/{cli/tests/factory-run-id.test.ts => workflow/tests/software-factory-run-id.test.ts} (99%) diff --git a/packages/workflow/deno.json b/packages/workflow/deno.json index ed6dfab38..ab4ae02a3 100644 --- a/packages/workflow/deno.json +++ b/packages/workflow/deno.json @@ -5,6 +5,7 @@ "exports": { ".": "./mod.ts", "./deno": "./deno.ts", + "./software-factory": "./software-factory.ts", "./credential-helper": "./src/deno/composition/credential-helper.ts" }, "publish": { diff --git a/packages/workflow/package.json b/packages/workflow/package.json index bb7e1758e..5c2043bde 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -6,6 +6,7 @@ "exports": { ".": "./mod.ts", "./deno": "./deno.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..5f5f69320 --- /dev/null +++ b/packages/workflow/software-factory.ts @@ -0,0 +1,46 @@ +/** + * @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. + * + * 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, + admitIssueNodeId, + base32Unpadded, + canonicalGitHubAuthority, + deriveFactoryRunId, + FACTORY_RUN_ID_LENGTH, + factoryRunIdPreimage, + FactoryRunSubjectError, +} from "./src/software-factory/run-id.ts"; +export type { FactoryRunSubject, FactoryRunSubjectFailure } from "./src/software-factory/run-id.ts"; diff --git a/packages/cli/src/factory-run-id.ts b/packages/workflow/src/software-factory/run-id.ts similarity index 100% rename from packages/cli/src/factory-run-id.ts rename to packages/workflow/src/software-factory/run-id.ts diff --git a/packages/workflow/tests/host-neutrality.test.ts b/packages/workflow/tests/host-neutrality.test.ts index 1487d4baf..94074295a 100644 --- a/packages/workflow/tests/host-neutrality.test.ts +++ b/packages/workflow/tests/host-neutrality.test.ts @@ -26,9 +26,13 @@ const PACKAGE = fileURLToPath(new URL("..", import.meta.url)); /** * The subtrees that are allowed to know a host, because naming one is their job. * - * `vendor` is pinned upstream source whose drift verifier owns its bytes. + * 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", "src/deno", "vendor"]; +const RUNTIME_OWNED = ["deno.ts", "cloudflare.ts", "src/deno", "src/cloudflare", "vendor"]; /** Specifiers only a host adapter may import. */ const HOST_SPECIFIERS = [ @@ -82,7 +86,9 @@ describe("the shared workflow package", () => { 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.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* () { diff --git a/packages/cli/tests/factory-run-id.test.ts b/packages/workflow/tests/software-factory-run-id.test.ts similarity index 99% rename from packages/cli/tests/factory-run-id.test.ts rename to packages/workflow/tests/software-factory-run-id.test.ts index 8af3467a2..faff85d3b 100644 --- a/packages/cli/tests/factory-run-id.test.ts +++ b/packages/workflow/tests/software-factory-run-id.test.ts @@ -19,7 +19,7 @@ import { FACTORY_RUN_ID_LENGTH, factoryRunIdPreimage, FactoryRunSubjectError, -} from "../src/factory-run-id.ts"; +} from "@executablemd/workflow/software-factory"; /** An opaque node id of the shape GitHub's GraphQL API returns for an issue. */ const NODE = "I_kwDOABCD12M5abcdef"; diff --git a/packages/workflow/tests/workspace-effect.test.ts b/packages/workflow/tests/workspace-effect.test.ts index 25a352056..b0f2175af 100644 --- a/packages/workflow/tests/workspace-effect.test.ts +++ b/packages/workflow/tests/workspace-effect.test.ts @@ -686,10 +686,31 @@ 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. + // + // 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/durable-streams/http-stream.ts", + ], })) .map((entry) => entry.path) .sort(); @@ -729,7 +750,12 @@ 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); const crossings: Record = {}; const unread: string[] = []; From 937a3887c040dd30046ca5fc496c4e1e8bc6193d Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 16:43:17 -0400 Subject: [PATCH 04/42] =?UTF-8?q?=F0=9F=93=A6=20Add=20the=20Cloudflare=20W?= =?UTF-8?q?orkers=20test=20runtime=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §13. `@cloudflare/vitest-plugin@1.1.3` and `vitest@4.1.11` as root development dependencies, which is what brings workerd in — plan §12 requires the real Durable Object namespace, real SQLite storage and real WebSocket admission, and an in-process fake is not evidence for any of them. Both lock layouts are updated in the documented order: the pnpm add, then `deno install --frozen=false`, then `deno task setup`. `pnpm-lock.yaml` gains 1231 lines and deletes none; `deno.lock` gains the npm graph for the same packages. Two things worth knowing for anyone reproducing this. The lockfile is `lockfileVersion: '9.0'` and `package.json` declares `pnpm@9.15.0`, but the pnpm on PATH here is 7.5.0, which cannot read a v9 lockfile — it warns "Ignoring broken lockfile" and rewrites it as `lockfileVersion: 5.4`. That downgrade happened once and was reverted; this commit was produced with the declared version through `corepack pnpm`. Anything that shells out to a bare `pnpm`, `deno task setup` included, needs 9.15.0 ahead of 7.5.0 on PATH or it will silently downgrade the lockfile again. pnpm 9 also re-sorts `package.json` dependency keys, which is why `effection`, `mdast-util-to-string` and `zod` move. That is the package manager's own canonical ordering; hand-restoring it would only be undone by the next install. `deno task setup` still exits 1 at its last step, `build:web`, with `Module not found "file:///…/bundle"` from `deno bundle --packages=bundle`. That is not this change: the identical argv succeeds when run directly and under a preflight-shaped parent, it fails only through `@effectionx/process`'s `exec()`, `packages/web/generated/` has never existed in this checkout, and the browser bundle's module graph does not reach vitest or workerd. `deno task check`, `deno task lint` and the focused suites all pass on the new dependency state. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- deno.lock | 560 +++++++++++++++++++++- package.json | 10 +- pnpm-lock.yaml | 1231 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1788 insertions(+), 13 deletions(-) diff --git a/deno.lock b/deno.lock index b255615bd..6cf3f3547 100644 --- a/deno.lock +++ b/deno.lock @@ -41,6 +41,7 @@ "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:@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_@opentelemetry+api@1.9.1_@types+node@24.13.3_vite@7.3.6__@types+node@24.13.3__tsx@4.23.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", @@ -111,6 +112,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 +314,7 @@ "debug", "gensync", "json5", - "semver" + "semver@6.3.1" ] }, "@babel/generator@7.29.7": { @@ -321,7 +323,7 @@ "@babel/parser", "@babel/types", "@jridgewell/gen-mapping", - "@jridgewell/trace-mapping", + "@jridgewell/trace-mapping@0.3.31", "jsesc" ] }, @@ -338,7 +340,7 @@ "@babel/helper-validator-option", "browserslist", "lru-cache", - "semver" + "semver@6.3.1" ] }, "@babel/helper-globals@7.29.7": { @@ -481,9 +483,66 @@ "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_@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-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"] + }, "@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 +652,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 +1081,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 +1290,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 +1313,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 +1614,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 +2092,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 +2245,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 +2380,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 +2462,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 +2593,9 @@ "tslib" ] }, + "assertion-error@2.0.1": { + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==" + }, "b4a@1.8.1": { "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==" }, @@ -2308,6 +2640,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 +2666,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 +2694,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 +2770,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 +2836,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 +2958,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 +3204,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 +3590,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 +3644,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 +3669,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 +3761,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 +3976,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 +4027,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 +4065,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 +4109,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 +4122,7 @@ "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", "dependencies": [ "has-flag", - "supports-color" + "supports-color@7.2.0" ] }, "tailwind-merge@3.6.0": { @@ -3740,6 +4173,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 +4189,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 +4221,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 +4351,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 +4393,43 @@ ], "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": { + "integrity": "sha512-jNXy9e8/pbx8iqTzXPiuflnitKJZoAfEUSUUDLW87bwyeMvJ7kb3yQMSbxEcfNdfHqJW38KRcKaLljOYV4N/4w==", + "dependencies": [ + "@cloudflare/kv-asset-handler", + "@cloudflare/unenv-preset", + "blake3-wasm", + "esbuild@0.28.1", + "miniflare", + "path-to-regexp", + "unenv", + "workerd" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + }, "wrap-ansi@7.0.0": { "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dependencies": [ @@ -3918,6 +4438,9 @@ "strip-ansi" ] }, + "ws@8.21.0": { + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==" + }, "y18n@5.0.8": { "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" }, @@ -3939,6 +4462,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 +4520,7 @@ ], "packageJson": { "dependencies": [ + "npm:@cloudflare/vitest-plugin@1.1.3", "npm:@durable-streams/client@~0.2.2", "npm:@durable-streams/server@~0.3.8", "npm:@effectionx/context-api@0.6.0", @@ -4010,6 +4551,7 @@ "npm:tsx@^4.19.0", "npm:typescript@5", "npm:unist-util-select@5", + "npm:vitest@4.1.11", "npm:zod@^4.3.6" ] }, diff --git a/package.json b/package.json index 4e507ca7f..950a7f2d0 100644 --- a/package.json +++ b/package.json @@ -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,19 @@ "@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", "@durable-streams/server": "^0.3.8", "@executablemd/acp": "workspace:*", "@executablemd/cli": "workspace:*", @@ -65,7 +66,8 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 140cced90..487618c30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@cloudflare/vitest-plugin': + specifier: 1.1.3 + version: 1.1.3(@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.21.0))) '@durable-streams/server': specifier: ^0.3.8 version: 0.3.8 @@ -132,6 +135,9 @@ importers: 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.21.0)) packages/acp: dependencies: @@ -498,10 +504,64 @@ 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] + '@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,6 +644,9 @@ packages: peerDependencies: effection: ^3 || ^4 + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} @@ -920,6 +983,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 +1153,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 +1235,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 +1466,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 +1881,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 +1996,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 +2057,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 +2140,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 +2192,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 +2204,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 +2233,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 +2279,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,6 +2336,12 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + 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.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} @@ -1972,9 +2365,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 +2414,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} @@ -2116,6 +2525,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 +2731,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 +2748,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 +2771,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 +2809,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 +2822,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} @@ -2414,6 +2924,11 @@ packages: 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 +2936,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 +2956,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 +2975,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 +2989,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 +3016,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 +3052,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==} @@ -2542,6 +3098,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 +3160,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.8.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 +3252,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 +3300,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==} @@ -2660,9 +3345,51 @@ 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(@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.21.0)))': + 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.21.0)) + wrangler: 4.128.0 + 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 + '@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,6 +3475,11 @@ snapshots: dependencies: effection: 4.1.0 + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.4': optional: true @@ -2927,6 +3659,112 @@ snapshots: '@harperfast/extended-iterable@1.0.3': {} + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + '@jest/diff-sequences@30.3.0': {} '@jest/expect-utils@30.3.0': @@ -2954,8 +3792,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 +3844,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 +3960,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 +4323,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 +4389,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 +4446,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.21.0))': + 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.21.0) + + '@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 +4540,8 @@ snapshots: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + b4a@1.8.1: {} bail@2.0.2: {} @@ -3613,12 +4575,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 +4602,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 +4657,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,6 +4697,10 @@ snapshots: environment@1.1.0: {} + error-stack-parser-es@1.0.5: {} + + es-module-lexer@2.3.2: {} + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -3789,12 +4765,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 +4816,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 @@ -3950,6 +4936,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 +5222,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 +5258,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 +5279,8 @@ snapshots: object-assign@4.1.1: {} + obug@2.1.4: {} + ordered-binary@1.6.1: {} oxfmt@0.41.0: @@ -4286,10 +5339,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 @@ -4391,6 +5456,27 @@ snapshots: 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 +5484,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 +5526,8 @@ snapshots: shellwords-ts@3.0.1: {} + siginfo@2.0.0: {} + sisteransi@1.0.5: {} skillflag@0.2.1: @@ -4423,6 +5545,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 +5555,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 +5589,8 @@ snapshots: dependencies: boundary: 2.0.0 + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -4506,8 +5636,19 @@ 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: {} @@ -4531,6 +5672,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 +5744,89 @@ 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.21.0): + 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.21.0 + + vitest@4.1.11(@types/node@22.19.15)(vite@8.2.2(@types/node@22.19.15)(esbuild@0.28.1)(tsx@4.21.0)): + 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.21.0)) + '@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.3 + 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.21.0) + 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: + 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: + 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 +5841,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: {} From 299ca744522b233c2851f9f02672e4a3b1d14130 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 17:17:20 -0400 Subject: [PATCH 05/42] =?UTF-8?q?=F0=9F=94=92=20Narrow=20the=20software-fa?= =?UTF-8?q?ctory=20seam=20to=20the=20product=20promise=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@executablemd/workflow/software-factory` now publishes only `deriveFactoryRunId`, `admitFactoryRunSubject`, `FactoryRunSubject`, `FactoryRunSubjectFailure` and `FactoryRunSubjectError`. The scheme tag, the Base32 alphabet, the authority rule, the preimage layout, the encoder and the length constant are implementation: 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. `factoryRunIdPreimage()` and `base32Unpadded()` stay visible to their own module so the encoding tests can prove an internal algorithm, and are absent from the entrypoint. Everything the tests assert about public behavior — the fixed vectors, the 52-character shape, case folding, the non-default port, and every authority and node-id refusal — now goes through `admitFactoryRunSubject()` and `deriveFactoryRunId()`. The `as BufferSource` assertion is gone. `factoryRunIdPreimage()` returns an `ArrayBuffer` rather than a view, which is what `crypto.subtle.digest()` accepts with no assertion at the call site, so Code Rule 6 holds without one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/software-factory.ts | 11 +-- .../workflow/src/software-factory/run-id.ts | 19 +++--- .../tests/software-factory-run-id.test.ts | 67 ++++++++----------- 3 files changed, 44 insertions(+), 53 deletions(-) diff --git a/packages/workflow/software-factory.ts b/packages/workflow/software-factory.ts index 5f5f69320..e5d70442c 100644 --- a/packages/workflow/software-factory.ts +++ b/packages/workflow/software-factory.ts @@ -27,6 +27,12 @@ * `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 @@ -35,12 +41,7 @@ export { admitFactoryRunSubject, - admitIssueNodeId, - base32Unpadded, - canonicalGitHubAuthority, deriveFactoryRunId, - FACTORY_RUN_ID_LENGTH, - factoryRunIdPreimage, 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/software-factory/run-id.ts b/packages/workflow/src/software-factory/run-id.ts index 5ad6d0474..16b5e8b54 100644 --- a/packages/workflow/src/software-factory/run-id.ts +++ b/packages/workflow/src/software-factory/run-id.ts @@ -37,7 +37,7 @@ const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; * unpadded encoding is `ceil(256 / 5)` characters. Stated rather than computed * because it is a contract a second implementation is held to. */ -export const FACTORY_RUN_ID_LENGTH = 52; +const FACTORY_RUN_ID_LENGTH = 52; /** Why a subject could not be turned into a run id. */ export type FactoryRunSubjectFailure = @@ -103,7 +103,7 @@ const HOSTNAME = * usable is a value somebody meant differently, and two spellings that both * became one authority would be two runs quietly becoming one. */ -export function canonicalGitHubAuthority(value: string): string { +function canonicalGitHubAuthority(value: string): string { if (value === "") { throw new FactoryRunSubjectError("authority-empty", "the authority is empty"); } @@ -166,7 +166,7 @@ export function canonicalGitHubAuthority(value: string): string { } /** Hold a node id to what a retained identity has to be, and change nothing about it. */ -export function admitIssueNodeId(value: string): string { +function admitIssueNodeId(value: string): string { if (value === "") { throw new FactoryRunSubjectError("node-id-empty", "the issue node id is empty"); } @@ -183,12 +183,15 @@ export function admitIssueNodeId(value: string): string { * 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): Uint8Array { +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); - const bytes = new Uint8Array(scheme.length + 1 + authority.length + 1 + node.length); + // 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; @@ -199,7 +202,7 @@ export function factoryRunIdPreimage(subject: FactoryRunSubject): Uint8Array { bytes[at] = 0; at += 1; bytes.set(node, at); - return bytes; + return buffer; } /** Lowercase unpadded RFC 4648 Base32 of exactly these bytes. */ @@ -244,8 +247,6 @@ export function admitFactoryRunSubject(subject: FactoryRunSubject): FactoryRunSu */ export function* deriveFactoryRunId(subject: FactoryRunSubject): Operation { const admitted = admitFactoryRunSubject(subject); - const digest = yield* until( - crypto.subtle.digest("SHA-256", factoryRunIdPreimage(admitted) as BufferSource), - ); + const digest = yield* until(crypto.subtle.digest("SHA-256", factoryRunIdPreimage(admitted))); return base32Unpadded(new Uint8Array(digest)); } diff --git a/packages/workflow/tests/software-factory-run-id.test.ts b/packages/workflow/tests/software-factory-run-id.test.ts index faff85d3b..2e259b567 100644 --- a/packages/workflow/tests/software-factory-run-id.test.ts +++ b/packages/workflow/tests/software-factory-run-id.test.ts @@ -13,13 +13,13 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { admitFactoryRunSubject, - base32Unpadded, - canonicalGitHubAuthority, deriveFactoryRunId, - FACTORY_RUN_ID_LENGTH, - factoryRunIdPreimage, 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"; @@ -48,6 +48,11 @@ const VECTORS = [ }, ] 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(); @@ -68,7 +73,7 @@ describe("the factory run id", () => { issueNodeId: vector.issueNodeId, }); expect(runId).toEqual(vector.runId); - expect(runId.length).toEqual(FACTORY_RUN_ID_LENGTH); + expect(runId.length).toEqual(52); expect(/^[a-z2-7]{52}$/.test(runId)).toEqual(true); } }); @@ -87,51 +92,35 @@ describe("the factory run id", () => { }); it("writes the scheme tag, both separators and both inputs", function* () { - const bytes = factoryRunIdPreimage({ authority: "github.com", issueNodeId: "x" }); + 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(canonicalGitHubAuthority("GitHub.Com")).toEqual("github.com"); - expect(canonicalGitHubAuthority("GitHub.Example.COM:8443")).toEqual("github.example.com:8443"); + 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(() => canonicalGitHubAuthority(""))).toEqual("authority-empty"); - expect(reason(() => canonicalGitHubAuthority("https://github.com"))).toEqual( - "authority-has-scheme", - ); - expect(reason(() => canonicalGitHubAuthority("user@github.com"))).toEqual( - "authority-has-userinfo", - ); - expect(reason(() => canonicalGitHubAuthority("github.com/octo"))).toEqual("authority-has-path"); - expect(reason(() => canonicalGitHubAuthority("github.com/"))).toEqual("authority-has-path"); - expect(reason(() => canonicalGitHubAuthority("github.com?a=b"))).toEqual("authority-has-query"); - expect(reason(() => canonicalGitHubAuthority("github.com#top"))).toEqual( - "authority-has-fragment", - ); - expect(reason(() => canonicalGitHubAuthority("git hub.com"))).toEqual( - "authority-has-whitespace", - ); - expect(reason(() => canonicalGitHubAuthority("-github.com"))).toEqual( - "authority-malformed-host", - ); - expect(reason(() => canonicalGitHubAuthority("github.com:https"))).toEqual( - "authority-malformed-port", - ); - expect(reason(() => canonicalGitHubAuthority("github.com:0"))).toEqual( - "authority-malformed-port", - ); - expect(reason(() => canonicalGitHubAuthority("github.com:70000"))).toEqual( - "authority-malformed-port", - ); + 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(() => canonicalGitHubAuthority("github.com:443"))).toEqual( - "authority-default-port", - ); + expect(reason(() => authorityOf("github.com:443"))).toEqual("authority-default-port"); }); it("compares a node id byte for byte", function* () { From 28814ef12bd04a9571764f9713a3c303a2b256b6 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 17:28:54 -0400 Subject: [PATCH 06/42] =?UTF-8?q?=F0=9F=A7=AA=20Stand=20up=20the=20workerd?= =?UTF-8?q?=20suite=20and=20measure=20real=20Durable=20Object=20storage=20?= =?UTF-8?q?(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §12's harness, and the first thing it was built to answer. `vitest.config.ts` at the repository root runs `*.vitest.ts` under `@cloudflare/vitest-plugin` against real workerd, with a wrangler config declaring `StorageProbeObject` as a `new_sqlite_classes` Durable Object. The suffix keeps these files out of `deno task test`, `pnpm test:node` and `bun run test:bun`: discovery matches `*.test.ts`, and `scripts/tests/test-file-discovery.test.ts` still agrees with it, so nothing is stranded and no runtime exclusion was needed. `pnpm test:cloudflare` and `deno task test:cloudflare` run it. Two things had to be settled to get it running. The plugin binds to `vitest` through peer dependencies, and pnpm was resolving a second `vitest@4.1.11` copy with a different peer set, so the pool was configured but never took over and every test reported "failed to find the current suite" — adding the plugin's declared `@vitest/runner` and `@vitest/snapshot` peers at the root collapses that. The config lives at the repository root rather than in the package, because a package-level config resolves `vitest` from the package's own isolated `node_modules` and multiplies the copies again. `StorageProbeObject` asks the runtime what it accepts rather than assuming. Recorded on workerd 1.20260831.1: - `PRAGMA application_id` and `PRAGMA user_version` — refused, read and write, with `not authorized: SQLITE_AUTH`. - `sqlite_schema` introspection, ordinary DDL, and a plain metadata table — all fine. - One outer `ctx.storage.transactionSync()` — fine. - A reentrant `transactionSync`, and a direct `SAVEPOINT` — refused: the runtime requires its own transaction API instead of SQL transaction statements. - DOFS schema initialization and DOFS filesystem writes outside a transaction — fine. - A DOFS filesystem write *inside* one `transactionSync` — refused, because `writeFileSync` opens a `transactionSync` of its own and that nests. The last two contradict plan §4, which has the owner reuse the pragma-based recognition the Deno host uses and commit Workspace mutations inside one `ctx.storage.transactionSync()`. Neither is possible as written. The probe is committed so the finding is reproducible rather than reported. An earlier version of the probe called the asynchronous `WorkspaceFilesystem` wrapper and never awaited it, which reported success for work that had failed. It now uses the same synchronous primitives the Deno provider imports. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- deno.json | 13 +- deno.lock | 4 + package.json | 7 +- .../workflow/src/cloudflare/probe-object.ts | 141 ++++++++++++++++++ packages/workflow/tests/cloudflare/env.d.ts | 5 + .../cloudflare/storage-capabilities.vitest.ts | 16 ++ packages/workflow/tests/cloudflare/worker.ts | 15 ++ .../workflow/tests/cloudflare/wrangler.jsonc | 10 ++ .../workflow/tests/host-neutrality.test.ts | 10 +- pnpm-lock.yaml | 8 +- vitest.config.ts | 30 ++++ 11 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 packages/workflow/src/cloudflare/probe-object.ts create mode 100644 packages/workflow/tests/cloudflare/env.d.ts create mode 100644 packages/workflow/tests/cloudflare/storage-capabilities.vitest.ts create mode 100644 packages/workflow/tests/cloudflare/worker.ts create mode 100644 packages/workflow/tests/cloudflare/wrangler.jsonc create mode 100644 vitest.config.ts diff --git a/deno.json b/deno.json index 97508df8b..c04d787ee 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,10 @@ "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" ], "nodeModulesDir": "auto", "lock": { @@ -71,6 +77,7 @@ "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" } } diff --git a/deno.lock b/deno.lock index 6cf3f3547..43038154b 100644 --- a/deno.lock +++ b/deno.lock @@ -75,6 +75,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", @@ -4535,6 +4537,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", diff --git a/package.json b/package.json index 950a7f2d0..a4f74091e 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", @@ -62,6 +62,8 @@ "@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", @@ -74,7 +76,8 @@ "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": "vitest run --config vitest.config.ts" }, "workspaces": [ "packages/*" diff --git a/packages/workflow/src/cloudflare/probe-object.ts b/packages/workflow/src/cloudflare/probe-object.ts new file mode 100644 index 000000000..fa65555c2 --- /dev/null +++ b/packages/workflow/src/cloudflare/probe-object.ts @@ -0,0 +1,141 @@ +/** + * 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, before anything is built + * on the answer. + */ + +import { DurableObject } from "cloudflare:workers"; +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(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/env.d.ts b/packages/workflow/tests/cloudflare/env.d.ts new file mode 100644 index 000000000..d81369bca --- /dev/null +++ b/packages/workflow/tests/cloudflare/env.d.ts @@ -0,0 +1,5 @@ +declare module "cloudflare:test" { + interface ProvidedEnv { + STORAGE_PROBE: DurableObjectNamespace; + } +} 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..eda9754cd --- /dev/null +++ b/packages/workflow/tests/cloudflare/storage-capabilities.vitest.ts @@ -0,0 +1,16 @@ +import { env, runInDurableObject } from "cloudflare:test"; +import { expect, it } from "vitest"; +import type { StorageProbeObject } from "../../src/cloudflare/probe-object.ts"; + +it("reports what real Durable Object SQLite storage accepts", async () => { + const id = env.STORAGE_PROBE.idFromName("capabilities"); + const stub = env.STORAGE_PROBE.get(id); + const capabilities = await runInDurableObject(stub, (instance: StorageProbeObject) => + instance.capabilities(), + ); + // Printed rather than asserted: this test exists to establish the contract + // the owner is written against, and a hard assertion here would encode a + // guess about an answer nobody has yet. + console.log(JSON.stringify(capabilities, null, 2)); + expect(capabilities).toBeTruthy(); +}); diff --git a/packages/workflow/tests/cloudflare/worker.ts b/packages/workflow/tests/cloudflare/worker.ts new file mode 100644 index 000000000..7cd4831a3 --- /dev/null +++ b/packages/workflow/tests/cloudflare/worker.ts @@ -0,0 +1,15 @@ +/** + * 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 "../../src/cloudflare/probe-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..df1a8d9a4 --- /dev/null +++ b/packages/workflow/tests/cloudflare/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "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" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["StorageProbeObject"] }], +} diff --git a/packages/workflow/tests/host-neutrality.test.ts b/packages/workflow/tests/host-neutrality.test.ts index 94074295a..986e2bac5 100644 --- a/packages/workflow/tests/host-neutrality.test.ts +++ b/packages/workflow/tests/host-neutrality.test.ts @@ -32,7 +32,15 @@ const PACKAGE = fileURLToPath(new URL("..", import.meta.url)); * 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", "vendor"]; +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 = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 487618c30..22b13ec61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,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 @@ -5771,7 +5777,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.7 std-env: 4.2.0 tinybench: 2.9.0 tinyexec: 1.3.0 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" }, + }), + ], + }, + ], + }, +}); From 670ad0caf3b90722f0aca781258fecf4c0d86b77 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 18:57:08 -0400 Subject: [PATCH 07/42] =?UTF-8?q?=F0=9F=93=8C=20Collapse=20the=20duplicate?= =?UTF-8?q?=20vitest=20peer=20context=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@cloudflare/vitest-plugin` binds to `vitest` through peer dependencies, and the pool only takes over when the plugin and the CLI hold the same instance. pnpm was producing two: `acpx` depends on `tsx@4.23.1` exactly, the root range `^4.19.0` resolved to `4.21.0`, and `vite` peers on `tsx`, so `vitest@4.1.11` existed twice under different peer contexts. The plugin bound to one and the root link to the other, which is why the pool reported itself configured while every test failed with "Vitest failed to find the current suite". One `pnpm.overrides` entry pinning `tsx` collapses that. Both the root link and the plugin now resolve the same `vitest`, and `pnpm test:cloudflare` passes. Ordering note for anyone reproducing this: `deno install` prunes the links pnpm placed — `scripts/deps.ts` says so in its own header — so `deno task setup` leaves the pnpm-owned test tooling unlinked and `pnpm install` afterwards restores it. Setup itself completes: exit 0, "ready", browser bundle generated, lockfile still v9. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- package.json | 7 +- pnpm-lock.yaml | 323 +++---------------------------------------------- 2 files changed, 23 insertions(+), 307 deletions(-) diff --git a/package.json b/package.json index a4f74091e..ce9f34268 100644 --- a/package.json +++ b/package.json @@ -81,5 +81,10 @@ }, "workspaces": [ "packages/*" - ] + ], + "pnpm": { + "overrides": { + "tsx": "4.23.1" + } + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22b13ec61..388492231 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + tsx: 4.23.1 + importers: .: @@ -83,7 +86,7 @@ importers: devDependencies: '@cloudflare/vitest-plugin': specifier: 1.1.3 - version: 1.1.3(@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.21.0))) + version: 1.1.3(@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))) '@durable-streams/server': specifier: ^0.3.8 version: 0.3.8 @@ -136,14 +139,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.21.0)) + 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: @@ -653,312 +656,156 @@ packages: '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} 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'} @@ -2348,11 +2195,6 @@ packages: es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -2442,9 +2284,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==} @@ -2923,9 +2762,6 @@ 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'} @@ -3086,11 +2922,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'} @@ -3181,7 +3012,7 @@ packages: stylus: '>=0.54.8' sugarss: ^5.0.0 terser: ^5.16.0 - tsx: ^4.8.1 + tsx: 4.23.1 yaml: ^2.4.2 peerDependenciesMeta: '@types/node': @@ -3359,14 +3190,14 @@ snapshots: optionalDependencies: workerd: 1.20260831.1 - '@cloudflare/vitest-plugin@1.1.3(@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.21.0)))': + '@cloudflare/vitest-plugin@1.1.3(@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.21.0)) + 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 zod: 4.4.3 transitivePeerDependencies: @@ -3486,159 +3317,81 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.27.4': - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.27.4': - optional: true - '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.27.4': - optional: true - '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.27.4': - optional: true - '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.27.4': - optional: true - '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.27.4': - optional: true - '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.27.4': - optional: true - '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.27.4': - optional: true - '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.27.4': - optional: true - '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.27.4': - optional: true - '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.27.4': - optional: true - '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.27.4': - optional: true - '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.27.4': - optional: true - '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.27.4': - optional: true - '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.27.4': - optional: true - '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.27.4': - optional: true - '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.27.4': - optional: true - '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.27.4': - optional: true - '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.27.4': - optional: true - '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.27.4': - optional: true - '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.27.4': - optional: true - '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.27.4': - optional: true - '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.27.4': - optional: true - '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.27.4': - optional: true - '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.27.4': - optional: true - '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.27.4': - optional: true - '@esbuild/win32-x64@0.28.1': optional: true @@ -4461,13 +4214,13 @@ snapshots: 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.21.0))': + '@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.21.0) + vite: 8.2.2(@types/node@22.19.15)(esbuild@0.28.1)(tsx@4.23.1) '@vitest/pretty-format@4.1.11': dependencies: @@ -4707,35 +4460,6 @@ snapshots: es-module-lexer@2.3.2: {} - 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 - esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -4833,10 +4557,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: @@ -5458,8 +5178,6 @@ snapshots: require-from-string@2.0.2: {} - resolve-pkg-maps@1.0.0: {} - reusify@1.1.0: {} rolldown@1.2.7: @@ -5661,13 +5379,6 @@ snapshots: 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 @@ -5750,7 +5461,7 @@ 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.21.0): + 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 @@ -5761,12 +5472,12 @@ snapshots: '@types/node': 22.19.15 esbuild: 0.28.1 fsevents: 2.3.3 - tsx: 4.21.0 + 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.21.0)): + 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.21.0)) + '@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 @@ -5783,7 +5494,7 @@ snapshots: 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.21.0) + 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 From da758d91ce934239a94d44f255975c6d7e33ba92 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 19:15:51 -0400 Subject: [PATCH 08/42] =?UTF-8?q?=F0=9F=A7=B1=20Share=20the=20SQLite=20sch?= =?UTF-8?q?ema=20and=20give=20Cloudflare=20its=20own=20identity=20carrier?= =?UTF-8?q?=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The storage correction from `.vscode/698/698-c.md`. `packages/workflow/src/sqlite/workflow-schema.ts` now holds version 1 once: the application identity, the logical version, every declared table and index, the DDL, the expected inventory, the pre-release shapes, and `declaredStructureFailure()`, which compares what a database declares against what this build writes and answers with the disagreement instead of raising it. Two adapters keeping their own copy of that DDL would be two schemas that happen to look alike, and the first amendment either missed would be a run neither could recognize. The module names no runtime, owns no connection, path, transaction or lifecycle authority, and is published from no entrypoint. `src/deno/schema.ts` loses 505 lines and keeps what is genuinely its own: `DatabaseSync`, filesystem paths, SQLite error translation, and its existing `PRAGMA application_id`/`PRAGMA user_version` carrier. It reports the shared finding as its own failures, so the Deno host's behavior and its released recognition are unchanged. `src/cloudflare/marker.ts` is the other carrier. A Durable Object's SQLite refuses both pragmas — `not authorized: SQLITE_AUTH`, on read as well as write — so that adapter records the same two values in `_xmd_workflow_schema`, a singleton row fixed at `id = 1` by a CHECK and a primary key so a second identity row cannot exist. Same logical version, different physical carrier; adapter-private recognition metadata, not a WorkflowRun record, journal value, exported field or second schema. `src/cloudflare/storage.ts` reconciles the runtime's concrete `SqlStorageValue` rows with the vendor's structural row type in one place, and deliberately does not forward `transactionSync`. `DLC13` excludes `src/sqlite/**` with its own recorded reason and asserts the subtree is absent from what it scanned; `host-neutrality.test.ts` keeps scanning it and now proves it is among the modules checked. The exploratory probe moves out of production source into `tests/cloudflare/support/`, and its console dump becomes three asserted tests matching stable categories rather than platform wording: the pragmas are refused as unauthorized; direct savepoints, reentrant transactions and a DOFS filesystem write inside an owner transaction are all refused toward the storage transaction API; and ordinary DDL, `sqlite_schema`, an outer transaction, DOFS schema initialization and a strict metadata table are accepted. `pnpm check:cloudflare` type-checks the Cloudflare production source and its tests against `@cloudflare/workers-types`, which the root Deno check cannot do because `cloudflare:` modules do not resolve there. Two `pnpm.overrides` pin `tsx` and `@cloudflare/workers-types`: both are peers that were splintering `vitest` and the plugin into several instances, and a single copy of each is what makes the pool engage. `@cloudflare/workers-types` is pinned to `5.20260831.1` rather than the newest, which Deno's minimum-dependency-age policy blocks. Ordering, unchanged from `670ad0c` and now deterministic in both directions: `deno task setup` completes and leaves the workerd suite unable to run, and a `pnpm install` afterwards restores it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- deno.json | 6 +- deno.lock | 15 +- package.json | 7 +- packages/workflow/src/cloudflare/marker.ts | 88 +++ packages/workflow/src/cloudflare/storage.ts | 53 ++ packages/workflow/src/deno/schema.ts | 589 ++--------------- .../workflow/src/sqlite/workflow-schema.ts | 595 ++++++++++++++++++ packages/workflow/tests/cloudflare/env.d.ts | 10 +- .../cloudflare/storage-capabilities.vitest.ts | 82 ++- .../cloudflare/support}/probe-object.ts | 17 +- packages/workflow/tests/cloudflare/worker.ts | 2 +- .../workflow/tests/host-neutrality.test.ts | 1 + .../workflow/tests/workspace-effect.test.ts | 9 + packages/workflow/tsconfig.cloudflare.json | 19 + pnpm-lock.yaml | 20 +- 15 files changed, 927 insertions(+), 586 deletions(-) create mode 100644 packages/workflow/src/cloudflare/marker.ts create mode 100644 packages/workflow/src/cloudflare/storage.ts create mode 100644 packages/workflow/src/sqlite/workflow-schema.ts rename packages/workflow/{src/cloudflare => tests/cloudflare/support}/probe-object.ts (87%) create mode 100644 packages/workflow/tsconfig.cloudflare.json diff --git a/deno.json b/deno.json index c04d787ee..32374cba5 100644 --- a/deno.json +++ b/deno.json @@ -13,7 +13,8 @@ "packages/acp/vendor/acpx/generated/**/*.d.ts", "packages/workflow/src/cloudflare", "packages/workflow/tests/cloudflare", - "vitest.config.ts" + "vitest.config.ts", + "packages/workflow/tsconfig.cloudflare.json" ], "nodeModulesDir": "auto", "lock": { @@ -78,6 +79,7 @@ "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", - "test:cloudflare": "pnpm test:cloudflare" + "test:cloudflare": "pnpm test:cloudflare", + "check:cloudflare": "pnpm check:cloudflare" } } diff --git a/deno.lock b/deno.lock index 43038154b..c89620201 100644 --- a/deno.lock +++ b/deno.lock @@ -41,7 +41,8 @@ "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:@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_@opentelemetry+api@1.9.1_@types+node@24.13.3_vite@7.3.6__@types+node@24.13.3__tsx@4.23.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", @@ -498,7 +499,7 @@ "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_@opentelemetry+api@1.9.1_@types+node@24.13.3_vite@7.3.6__@types+node@24.13.3__tsx@4.23.1": { + "@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", @@ -536,6 +537,9 @@ "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==" }, @@ -4415,11 +4419,12 @@ "scripts": true, "bin": true }, - "wrangler@4.128.0": { + "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", @@ -4430,6 +4435,9 @@ "optionalDependencies": [ "fsevents" ], + "optionalPeers": [ + "@cloudflare/workers-types" + ], "bin": true }, "wrap-ansi@7.0.0": { @@ -4523,6 +4531,7 @@ "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", diff --git a/package.json b/package.json index ce9f34268..4a867b228 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ }, "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:*", @@ -77,14 +78,16 @@ "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", - "test:cloudflare": "vitest run --config vitest.config.ts" + "test:cloudflare": "vitest run --config vitest.config.ts", + "check:cloudflare": "tsc -p packages/workflow/tsconfig.cloudflare.json" }, "workspaces": [ "packages/*" ], "pnpm": { "overrides": { - "tsx": "4.23.1" + "tsx": "4.23.1", + "@cloudflare/workers-types": "5.20260831.1" } } } diff --git a/packages/workflow/src/cloudflare/marker.ts b/packages/workflow/src/cloudflare/marker.ts new file mode 100644 index 000000000..e111577dc --- /dev/null +++ b/packages/workflow/src/cloudflare/marker.ts @@ -0,0 +1,88 @@ +/** + * 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, 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: "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 !== 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/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/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/sqlite/workflow-schema.ts b/packages/workflow/src/sqlite/workflow-schema.ts new file mode 100644 index 000000000..1f9c8d0a3 --- /dev/null +++ b/packages/workflow/src/sqlite/workflow-schema.ts @@ -0,0 +1,595 @@ +/** + * 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; + +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, + 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/tests/cloudflare/env.d.ts b/packages/workflow/tests/cloudflare/env.d.ts index d81369bca..547adb507 100644 --- a/packages/workflow/tests/cloudflare/env.d.ts +++ b/packages/workflow/tests/cloudflare/env.d.ts @@ -1,5 +1,9 @@ -declare module "cloudflare:test" { - interface ProvidedEnv { - STORAGE_PROBE: DurableObjectNamespace; +import type { StorageProbeObject } from "./support/probe-object.ts"; + +declare global { + namespace Cloudflare { + interface Env { + STORAGE_PROBE: DurableObjectNamespace; + } } } diff --git a/packages/workflow/tests/cloudflare/storage-capabilities.vitest.ts b/packages/workflow/tests/cloudflare/storage-capabilities.vitest.ts index eda9754cd..ddbe22c9c 100644 --- a/packages/workflow/tests/cloudflare/storage-capabilities.vitest.ts +++ b/packages/workflow/tests/cloudflare/storage-capabilities.vitest.ts @@ -1,16 +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 { expect, it } from "vitest"; -import type { StorageProbeObject } from "../../src/cloudflare/probe-object.ts"; - -it("reports what real Durable Object SQLite storage accepts", async () => { - const id = env.STORAGE_PROBE.idFromName("capabilities"); - const stub = env.STORAGE_PROBE.get(id); - const capabilities = await runInDurableObject(stub, (instance: StorageProbeObject) => - instance.capabilities(), - ); - // Printed rather than asserted: this test exists to establish the contract - // the owner is written against, and a hard assertion here would encode a - // guess about an answer nobody has yet. - console.log(JSON.stringify(capabilities, null, 2)); - expect(capabilities).toBeTruthy(); +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/src/cloudflare/probe-object.ts b/packages/workflow/tests/cloudflare/support/probe-object.ts similarity index 87% rename from packages/workflow/src/cloudflare/probe-object.ts rename to packages/workflow/tests/cloudflare/support/probe-object.ts index fa65555c2..1daf1072e 100644 --- a/packages/workflow/src/cloudflare/probe-object.ts +++ b/packages/workflow/tests/cloudflare/support/probe-object.ts @@ -10,15 +10,18 @@ * 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, before anything is built - * on the answer. + * 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 { 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"; +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; @@ -49,7 +52,7 @@ function attempt(body: () => unknown): string { export class StorageProbeObject extends DurableObject { capabilities(): StorageCapabilities { const sql = this.ctx.storage.sql; - const dofs = new DofsDatabase(this.ctx.storage); + const dofs = new DofsDatabase(dofsStorage(this.ctx.storage)); return { applicationIdWrite: attempt(() => { sql.exec("PRAGMA application_id = 1701078349"); diff --git a/packages/workflow/tests/cloudflare/worker.ts b/packages/workflow/tests/cloudflare/worker.ts index 7cd4831a3..cbbb53d15 100644 --- a/packages/workflow/tests/cloudflare/worker.ts +++ b/packages/workflow/tests/cloudflare/worker.ts @@ -6,7 +6,7 @@ * stubs, so this handler answers no request a test depends on. */ -export { StorageProbeObject } from "../../src/cloudflare/probe-object.ts"; +export { StorageProbeObject } from "./support/probe-object.ts"; export default { fetch(): Response { diff --git a/packages/workflow/tests/host-neutrality.test.ts b/packages/workflow/tests/host-neutrality.test.ts index 986e2bac5..b3a3cf2ec 100644 --- a/packages/workflow/tests/host-neutrality.test.ts +++ b/packages/workflow/tests/host-neutrality.test.ts @@ -95,6 +95,7 @@ describe("the shared workflow package", () => { 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); expect(modules.some((path) => path.includes("/src/deno/"))).toEqual(false); expect(modules.some((path) => path.includes("/src/cloudflare/"))).toEqual(false); }); diff --git a/packages/workflow/tests/workspace-effect.test.ts b/packages/workflow/tests/workspace-effect.test.ts index b0f2175af..0faeb90cc 100644 --- a/packages/workflow/tests/workspace-effect.test.ts +++ b/packages/workflow/tests/workspace-effect.test.ts @@ -695,6 +695,13 @@ describe("Tier DLC — Workspace coordination selection", () => { // 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 @@ -709,6 +716,7 @@ describe("Tier DLC — Workspace coordination selection", () => { "packages/workflow/src/deno/**", "packages/workflow/src/cloudflare/**", "packages/workflow/src/software-factory/**", + "packages/workflow/src/sqlite/**", "packages/durable-streams/http-stream.ts", ], })) @@ -756,6 +764,7 @@ describe("Tier DLC — Workspace coordination selection", () => { 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..96d28350a --- /dev/null +++ b/packages/workflow/tsconfig.cloudflare.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types", "@cloudflare/vitest-plugin/types"], + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "allowImportingTsExtensions": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["src/cloudflare/**/*.ts", "tests/cloudflare/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 388492231..d4709f0e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: tsx: 4.23.1 + '@cloudflare/workers-types': 5.20260831.1 importers: @@ -86,7 +87,10 @@ importers: devDependencies: '@cloudflare/vitest-plugin': specifier: 1.1.3 - version: 1.1.3(@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))) + 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 @@ -563,6 +567,9 @@ packages: 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'} @@ -3104,7 +3111,7 @@ packages: engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^5.20260831.1 + '@cloudflare/workers-types': 5.20260831.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -3190,7 +3197,7 @@ snapshots: optionalDependencies: workerd: 1.20260831.1 - '@cloudflare/vitest-plugin@1.1.3(@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/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 @@ -3198,7 +3205,7 @@ snapshots: 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 + wrangler: 4.128.0(@cloudflare/workers-types@5.20260831.1) zod: 4.4.3 transitivePeerDependencies: - '@cloudflare/workers-types' @@ -3220,6 +3227,8 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260831.1': optional: true + '@cloudflare/workers-types@5.20260831.1': {} + '@colors/colors@1.5.0': optional: true @@ -5520,7 +5529,7 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260831.1 '@cloudflare/workerd-windows-64': 1.20260831.1 - wrangler@4.128.0: + 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) @@ -5531,6 +5540,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260831.1 optionalDependencies: + '@cloudflare/workers-types': 5.20260831.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil From f7b2a24d1e4be3902a27530037200a30b95e1704 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 19:19:10 -0400 Subject: [PATCH 09/42] =?UTF-8?q?=E2=9C=85=20Prove=20the=20owner's=20stora?= =?UTF-8?q?ge=20on=20real=20workerd=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cloudflare owner's storage paths, and the evidence that they hold. `src/cloudflare/owner-transaction.ts` is the transaction every authoritative commit runs inside. It enters `ctx.storage.transactionSync()` once and hands DOFS a wrapper whose `transactionSync` runs its callback directly — inside the real callback that is not a weaker promise, because the outer transaction is already open, and it is the only way DOFS can participate at all: asked to transact while it believes one is open, the vendor falls back to `SAVEPOINT`, which the runtime refuses, and every DOFS filesystem primitive opens a transaction on the way in. The enlistment is created for one callback, refuses use outside it, and clears the DOFS resolve and blob caches on both sides so nothing populated from rows a rollback discarded is read later. The vendored snapshot is untouched and `deno task vendor:verify` still passes. `src/cloudflare/recognition.ts` initializes pristine storage in one transaction — schema, DOFS schema, run row, then the marker last, so the code says what the marker means even though atomicity hides the ordering — and recognizes it again through the same four conditions the Deno host distinguishes: foreign, unsupported version, corrupt, or a version-1 run. Fourteen assertions on real workerd, in two suites. Initialization writes the marker and is recognized again; storage that already holds an object is refused rather than written into; nothing at all, objects without a marker, another application's identity, version 2, version 0, and a dropped table are each refused as their own condition. A mixed commit publishes a DOFS filesystem write and a WorkflowRun row together. A body that throws after changing both leaves neither — the run row is unchanged, the file is absent, the object still recognizes, and a later commit succeeds from the frontier the failure left. That last test is the one the whole design turns on. `pnpm check:cloudflare` type-checks all of it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- .../src/cloudflare/owner-transaction.ts | 117 +++++++++++ .../workflow/src/cloudflare/recognition.ts | 187 ++++++++++++++++++ packages/workflow/tests/cloudflare/env.d.ts | 2 + .../tests/cloudflare/owner-storage.vitest.ts | 122 ++++++++++++ .../tests/cloudflare/support/owner-object.ts | 137 +++++++++++++ packages/workflow/tests/cloudflare/worker.ts | 1 + .../workflow/tests/cloudflare/wrangler.jsonc | 7 +- 7 files changed, 571 insertions(+), 2 deletions(-) create mode 100644 packages/workflow/src/cloudflare/owner-transaction.ts create mode 100644 packages/workflow/src/cloudflare/recognition.ts create mode 100644 packages/workflow/tests/cloudflare/owner-storage.vitest.ts create mode 100644 packages/workflow/tests/cloudflare/support/owner-object.ts diff --git a/packages/workflow/src/cloudflare/owner-transaction.ts b/packages/workflow/src/cloudflare/owner-transaction.ts new file mode 100644 index 000000000..12cd09bde --- /dev/null +++ b/packages/workflow/src/cloudflare/owner-transaction.ts @@ -0,0 +1,117 @@ +/** + * 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; +} + +/** Whether an owner transaction is currently open on this object. */ +let 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. + */ +export function ownerTransaction( + storage: OwnerStorage, + body: (transaction: OwnerTransaction) => T, +): T { + if (open) { + throw new OwnerTransactionNestedError(); + } + open = true; + try { + 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); + } + }); + } finally { + open = false; + } +} diff --git a/packages/workflow/src/cloudflare/recognition.ts b/packages/workflow/src/cloudflare/recognition.ts new file mode 100644 index 000000000..13945e85f --- /dev/null +++ b/packages/workflow/src/cloudflare/recognition.ts @@ -0,0 +1,187 @@ +/** + * 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 { ownerTransaction } 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; +} + +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, 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", + }); + } + ownerTransaction(storage, ({ dofs }) => { + storage.sql.exec(SCHEMA_SQL); + initializeDofsSchema(dofs, () => 0); + 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" + : "its schema marker row does not describe an identity", + }); + } + + const declared = objects.filter((object) => object.name !== MARKER_TABLE); + 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/tests/cloudflare/env.d.ts b/packages/workflow/tests/cloudflare/env.d.ts index 547adb507..8697c38fd 100644 --- a/packages/workflow/tests/cloudflare/env.d.ts +++ b/packages/workflow/tests/cloudflare/env.d.ts @@ -1,9 +1,11 @@ +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; } } } 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..414b1801c --- /dev/null +++ b/packages/workflow/tests/cloudflare/owner-storage.vitest.ts @@ -0,0 +1,122 @@ +/** + * 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("refuses version zero the same way", async () => { + 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:unsupported-version"); + }); + + 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"); + }); +}); + +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, + }); + }); +}); 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..79d5a1df1 --- /dev/null +++ b/packages/workflow/tests/cloudflare/support/owner-object.ts @@ -0,0 +1,137 @@ +/** + * 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 { ownerTransaction } from "../../../src/cloudflare/owner-transaction.ts"; + +/** One run row, so initialization writes what a real run would. */ +const RUN_ID = "run-under-test"; + +export class OwnerObject extends DurableObject { + /** Create the schema, DOFS schema, an empty root and the run row, then mark it. */ + initialize(): string { + try { + initializeObject(this.ctx.storage, () => { + 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 { + ownerTransaction(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); + } + } + + /** 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)}`; +} diff --git a/packages/workflow/tests/cloudflare/worker.ts b/packages/workflow/tests/cloudflare/worker.ts index cbbb53d15..4143c1ffd 100644 --- a/packages/workflow/tests/cloudflare/worker.ts +++ b/packages/workflow/tests/cloudflare/worker.ts @@ -7,6 +7,7 @@ */ export { StorageProbeObject } from "./support/probe-object.ts"; +export { OwnerObject } from "./support/owner-object.ts"; export default { fetch(): Response { diff --git a/packages/workflow/tests/cloudflare/wrangler.jsonc b/packages/workflow/tests/cloudflare/wrangler.jsonc index df1a8d9a4..0315d0393 100644 --- a/packages/workflow/tests/cloudflare/wrangler.jsonc +++ b/packages/workflow/tests/cloudflare/wrangler.jsonc @@ -4,7 +4,10 @@ "compatibility_date": "2026-08-01", "compatibility_flags": ["nodejs_compat"], "durable_objects": { - "bindings": [{ "name": "STORAGE_PROBE", "class_name": "StorageProbeObject" }], + "bindings": [ + { "name": "STORAGE_PROBE", "class_name": "StorageProbeObject" }, + { "name": "OWNER", "class_name": "OwnerObject" }, + ], }, - "migrations": [{ "tag": "v1", "new_sqlite_classes": ["StorageProbeObject"] }], + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["StorageProbeObject", "OwnerObject"] }], } From 0cbd8ef2392bb39c32b99467862fda935e0ff22b Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 19:21:27 -0400 Subject: [PATCH 10/42] =?UTF-8?q?=F0=9F=91=B7=20Give=20the=20workerd=20sui?= =?UTF-8?q?te=20a=20required=20CI=20job=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test-cloudflare` runs `pnpm check:cloudflare` and `pnpm test:cloudflare`, and is in `green.needs`. It runs on every event and carries no condition, so `green` requires success from it unconditionally. The job owns evidence no other job can produce. A Durable Object's acquisition lifetime, its eviction and its transaction atomicity are properties of that runtime, and the `.vitest.ts` files proving them are invisible to the Deno, Node and Bun corpora by design — so without this job the evidence would simply stop running while everything else stayed green. `ci-workflow.test.ts` already fails when a job is missing from the aggregate; verified by removing the entry and watching "requires every other job and no future job can be omitted" fail. Added a test naming this job's two commands as well, so what it is *for* is legible rather than only that it is depended on. `pnpm install` is the job's last preparation step: `deno install` prunes the links pnpm placed, and the plugin only takes over the pool when it and the CLI hold the same vitest. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ scripts/tests/ci-workflow.test.ts | 20 ++++++++++++++++++++ 2 files changed, 49 insertions(+) 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/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'", From b69bab425dd400618b36d6c74124930c557f5179 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 20:05:49 -0400 Subject: [PATCH 11/42] =?UTF-8?q?=F0=9F=94=90=20Admit=20an=20executor=20an?= =?UTF-8?q?d=20give=20it=20a=20private=20transport=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The executor plane: who may advance a run, and what they may say. `release.ts` compares an exact build fingerprint. `admission.ts` holds verified OIDC claims to a configured policy — issuer, audience, repository ID, owner ID, event name, workflow ref, workflow SHA and the immutable workflow identity — checking IDs rather than names, because a repository can be renamed and a check on the name would admit whoever holds it today. It reads a closed claim set, so a claim this contract does not name cannot be depended on, and it takes claims a verifier already authenticated rather than a token: how a signature is checked is not this module's business. `acquisition.ts` makes the connection the acquisition. Hibernation is why it cannot be a field — an evicted object has no memory of what it admitted — so authority is the pair the runtime hands back: `getWebSockets()` says which sockets are real and a bounded attachment says what one was admitted as. Copied attachment bytes prove nothing, because the question is not whether a value looks right but whether this socket is the one live socket holding an acquisition. There is no lease, expiry, renewal, heartbeat, alarm or poll. `commands.ts` reads the private transport strictly: unknown command, unknown member, wrong kind, oversized message, too many chunks — each refuses whole, nothing is partially adopted. The answer is a serialized record keyed on `outcome` rather than an Effection `Result`, because an `Error` does not cross a connection; Code Rule 13 governs in-process results and this is not one. `owner.ts` is the Durable Object. Its admission order is the contract: build before token, token before run, acquisition last, so a refusal at any step leaves no acquisition and no state. A message proves its acquisition before it is parsed. A closed connection releases ownership, rolls nothing back and settles nothing — an executor that disappeared decided nothing. `routing.ts` selects the owner arithmetically and admits the run ID first, because `idFromName` answers for any string and a mistyped id would silently address a fresh, empty owner. Twenty-nine assertions on real workerd across three suites. A mismatched build is refused while its claims are deliberately unusable, proving the order. Every policy claim is refused one at a time. A second healthy executor is refused rather than followed. A closed connection owns nothing and the next executor may take it with no lease having expired. A stranger's socket is refused before its command is read. `./cloudflare` is an npm export only: JSR cannot typecheck a `cloudflare:` entrypoint, and `deno task check:jsr` stays green because the Deno host is what JSR serves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- deno.json | 3 +- packages/workflow/cloudflare.ts | 57 +++++ packages/workflow/package.json | 1 + .../workflow/src/cloudflare/acquisition.ts | 163 +++++++++++++ packages/workflow/src/cloudflare/admission.ts | 121 ++++++++++ packages/workflow/src/cloudflare/commands.ts | 215 ++++++++++++++++++ packages/workflow/src/cloudflare/owner.ts | 162 +++++++++++++ packages/workflow/src/cloudflare/release.ts | 62 +++++ packages/workflow/src/cloudflare/routing.ts | 68 ++++++ packages/workflow/tests/cloudflare/env.d.ts | 2 + .../cloudflare/executor-acquisition.vitest.ts | 206 +++++++++++++++++ .../cloudflare/support/executor-object.ts | 109 +++++++++ packages/workflow/tests/cloudflare/worker.ts | 1 + .../workflow/tests/cloudflare/wrangler.jsonc | 5 +- packages/workflow/tsconfig.cloudflare.json | 2 +- 15 files changed, 1174 insertions(+), 3 deletions(-) create mode 100644 packages/workflow/cloudflare.ts create mode 100644 packages/workflow/src/cloudflare/acquisition.ts create mode 100644 packages/workflow/src/cloudflare/admission.ts create mode 100644 packages/workflow/src/cloudflare/commands.ts create mode 100644 packages/workflow/src/cloudflare/owner.ts create mode 100644 packages/workflow/src/cloudflare/release.ts create mode 100644 packages/workflow/src/cloudflare/routing.ts create mode 100644 packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts create mode 100644 packages/workflow/tests/cloudflare/support/executor-object.ts diff --git a/deno.json b/deno.json index 32374cba5..70563ebed 100644 --- a/deno.json +++ b/deno.json @@ -14,7 +14,8 @@ "packages/workflow/src/cloudflare", "packages/workflow/tests/cloudflare", "vitest.config.ts", - "packages/workflow/tsconfig.cloudflare.json" + "packages/workflow/tsconfig.cloudflare.json", + "packages/workflow/cloudflare.ts" ], "nodeModulesDir": "auto", "lock": { diff --git a/packages/workflow/cloudflare.ts b/packages/workflow/cloudflare.ts new file mode 100644 index 000000000..270aef5c8 --- /dev/null +++ b/packages/workflow/cloudflare.ts @@ -0,0 +1,57 @@ +/** + * @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 }; + * } + * protected perform(socket, runId, command) { + * // … + * } + * } + * ``` + * + * 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 { + AcquisitionError, + acquisitionHolders, + EXECUTOR_TAG, +} from "./src/cloudflare/acquisition.ts"; +export type { AcquisitionAttachment, AcquisitionRefusal } from "./src/cloudflare/acquisition.ts"; + +export { CommandError } from "./src/cloudflare/commands.ts"; +export type { CommandRefusal, CommandResult, RunnerCommand } from "./src/cloudflare/commands.ts"; + +export { WorkflowObjectStorageError } from "./src/cloudflare/recognition.ts"; +export type { RecognitionFailure } from "./src/cloudflare/recognition.ts"; diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 5c2043bde..e7ffd74a5 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -6,6 +6,7 @@ "exports": { ".": "./mod.ts", "./deno": "./deno.ts", + "./cloudflare": "./cloudflare.ts", "./software-factory": "./software-factory.ts", "./credential-helper": "./src/deno/composition/credential-helper.ts" }, diff --git a/packages/workflow/src/cloudflare/acquisition.ts b/packages/workflow/src/cloudflare/acquisition.ts new file mode 100644 index 000000000..38601fd17 --- /dev/null +++ b/packages/workflow/src/cloudflare/acquisition.ts @@ -0,0 +1,163 @@ +/** + * 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 = value as Record; + if (members["kind"] !== "executor") { + return undefined; + } + const runId = members["runId"]; + const acquisitionId = members["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, +): AcquisitionAttachment { + if (acquisitionHolders(ctx).length > 0) { + throw new AcquisitionError("already-running"); + } + 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 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"); + } + if (mine.held.runId !== runId) { + throw new AcquisitionError("wrong-run"); + } + 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..17a247f33 --- /dev/null +++ b/packages/workflow/src/cloudflare/admission.ts @@ -0,0 +1,121 @@ +/** + * 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. + * + * 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. + */ + +/** 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. + * + * Takes claims a verifier already authenticated rather than a token, so this + * module owns *which* claims decide and nothing about how a signature is + * checked. A caller that has not verified a signature has not admitted + * anything, whatever this returns. + */ +export 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 payload nothing has inspected yet. */ +export function parseClaims(payload: unknown): ActionsClaims { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + throw new AdmissionError("token-malformed"); + } + const members = payload as Record; + return { + iss: members["iss"], + aud: members["aud"], + repository_id: members["repository_id"], + repository_owner_id: members["repository_owner_id"], + event_name: members["event_name"], + workflow_ref: members["workflow_ref"], + workflow_sha: members["workflow_sha"], + job_workflow_ref: members["job_workflow_ref"], + }; +} diff --git a/packages/workflow/src/cloudflare/commands.ts b/packages/workflow/src/cloudflare/commands.ts new file mode 100644 index 000000000..154e9acbd --- /dev/null +++ b/packages/workflow/src/cloudflare/commands.ts @@ -0,0 +1,215 @@ +/** + * What a runner asks its owner, and what comes back. + * + * These records are private to one software-factory release. They are not + * journaled, exported, authored, or supported across independently versioned + * builds — admission has already proved both sides are the same build, which is + * what a wire contract would otherwise be for. Their decomposition is + * implementation detail and may change with the release that contains it. + * + * What is not private is the parsing discipline. Everything arriving from the + * connection is parsed strictly before it reaches storage: an unknown command, + * an unknown member, a value of the wrong kind and a value past its bound are + * each refused whole, and nothing is partially adopted. A permissive read here + * would be a runner deciding what the owner does. + */ + +/** The commands a runner may send. */ +export type CommandName = "frontier" | "materialize" | "commit" | "settle"; + +/** Why a message was refused before it reached the run. */ +export type CommandRefusal = + | "not-an-object" + | "unknown-command" + | "unknown-member" + | "malformed-member" + | "duplicate-conflict" + | "too-large"; + +export class CommandError extends Error { + override name = "CommandError"; + + constructor(readonly refusal: CommandRefusal) { + // The message a runner sent is not repeated: it arrived from outside and a + // member name can carry as much as a member value. + super(`this owner refused a runner command (${refusal})`); + } +} + +/** The envelope every command shares. */ +export interface CommandEnvelope { + /** Distinguishes one request from a retry of the same request. */ + readonly id: string; + readonly command: CommandName; +} + +/** Read the committed run record and current Workspace root. */ +export interface FrontierCommand extends CommandEnvelope { + readonly command: "frontier"; +} + +/** Ask for the content-addressed bytes of one retained root. */ +export interface MaterializeCommand extends CommandEnvelope { + readonly command: "materialize"; + readonly workspaceRootId: string; +} + +/** One closed mutation intent, submitted once, applied atomically or not at all. */ +export interface CommitCommand extends CommandEnvelope { + readonly command: "commit"; + /** The root the runner started from; the owner refuses if it has moved. */ + readonly expectedWorkspaceRootId: string; + /** The journal frontier the runner read; the owner refuses if it has moved. */ + readonly expectedJournalEventId: string | null; + /** Content-addressed additions, each named by its own digest. */ + readonly content: readonly ContentChunk[]; + /** The canonical root the runner proposes, recomputed by the owner. */ + readonly proposedWorkspaceRootId: string; + /** Already-filtered journal events to append, in order. */ + readonly events: readonly string[]; +} + +/** Publish the run's resulting status. */ +export interface SettleCommand extends CommandEnvelope { + readonly command: "settle"; + readonly status: string; +} + +export interface ContentChunk { + readonly digest: string; + /** Base64, because a private transport still carries text. */ + readonly bytes: string; +} + +export type RunnerCommand = FrontierCommand | MaterializeCommand | CommitCommand | SettleCommand; + +/** The largest message this owner reads at all. */ +const MAX_MESSAGE = 8 * 1024 * 1024; + +/** The most chunks one commit may carry. */ +const MAX_CHUNKS = 4096; + +const ENVELOPE = ["id", "command"] as const; + +const MEMBERS: Record = { + frontier: [...ENVELOPE], + materialize: [...ENVELOPE, "workspaceRootId"], + commit: [ + ...ENVELOPE, + "expectedWorkspaceRootId", + "expectedJournalEventId", + "content", + "proposedWorkspaceRootId", + "events", + ], + settle: [...ENVELOPE, "status"], +}; + +function object(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new CommandError("not-an-object"); + } + return value as Record; +} + +function text(members: Record, key: string): string { + const value = members[key]; + if (typeof value !== "string" || value === "") { + throw new CommandError("malformed-member"); + } + return value; +} + +function closed(members: Record, allowed: readonly string[]): void { + for (const key of Object.keys(members)) { + if (!allowed.includes(key)) { + throw new CommandError("unknown-member"); + } + } +} + +function chunks(value: unknown): ContentChunk[] { + if (!Array.isArray(value)) { + throw new CommandError("malformed-member"); + } + if (value.length > MAX_CHUNKS) { + throw new CommandError("too-large"); + } + return value.map((entry) => { + const members = object(entry); + closed(members, ["digest", "bytes"]); + return { digest: text(members, "digest"), bytes: text(members, "bytes") }; + }); +} + +function events(value: unknown): string[] { + if (!Array.isArray(value)) { + throw new CommandError("malformed-member"); + } + return value.map((entry) => { + if (typeof entry !== "string" || entry === "") { + throw new CommandError("malformed-member"); + } + return entry; + }); +} + +/** Read one command out of a message nothing has inspected yet. */ +export function parseCommand(raw: string): RunnerCommand { + if (raw.length > MAX_MESSAGE) { + 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"); + const command = members["command"]; + if ( + command !== "frontier" && + command !== "materialize" && + command !== "commit" && + command !== "settle" + ) { + throw new CommandError("unknown-command"); + } + closed(members, MEMBERS[command]); + + if (command === "frontier") { + return { id, command }; + } + if (command === "materialize") { + return { id, command, workspaceRootId: text(members, "workspaceRootId") }; + } + if (command === "settle") { + return { id, command, status: text(members, "status") }; + } + const expectedJournalEventId = members["expectedJournalEventId"]; + if (expectedJournalEventId !== null && typeof expectedJournalEventId !== "string") { + throw new CommandError("malformed-member"); + } + return { + id, + command, + expectedWorkspaceRootId: text(members, "expectedWorkspaceRootId"), + expectedJournalEventId, + content: chunks(members["content"]), + proposedWorkspaceRootId: text(members, "proposedWorkspaceRootId"), + events: events(members["events"]), + }; +} + +/** + * What the owner answers with. + * + * A serialized record rather than an Effection `Result`: this crosses a + * connection, and an `Error` does not survive that. The discriminant is + * `outcome` for the same reason — there is no in-process result being modelled + * here, only what one side told the other. + */ +export type CommandResult = + | { readonly id: string; readonly outcome: "performed"; readonly value: unknown } + | { readonly id: string; readonly outcome: "refused"; readonly refusal: string }; diff --git a/packages/workflow/src/cloudflare/owner.ts b/packages/workflow/src/cloudflare/owner.ts new file mode 100644 index 000000000..e42787a93 --- /dev/null +++ b/packages/workflow/src/cloudflare/owner.ts @@ -0,0 +1,162 @@ +/** + * 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. + */ + +import { DurableObject } from "cloudflare:workers"; +import { + acquireExecutor, + type AcquisitionAttachment, + AcquisitionError, + releaseExecutor, + requireAcquisition, +} from "./acquisition.ts"; +import { admitClaims, type AdmissionPolicy, AdmissionError, parseClaims } from "./admission.ts"; +import { CommandError, type CommandResult, parseCommand, type RunnerCommand } from "./commands.ts"; +import { + declaredObjects, + 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. */ +export interface AdmissionRequest { + readonly runId: unknown; + readonly release: unknown; + /** Claims a verifier has already authenticated. */ + readonly claims: unknown; +} + +/** Everything a deployment must state before this object admits anybody. */ +export interface OwnerConfiguration { + readonly policy: AdmissionPolicy; +} + +/** 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 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) { + return `storage:${error.failure.kind}`; + } + 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 { + 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 the token is read, + * the token before the run is touched, and the acquisition is taken last. A + * refusal at any step leaves no acquisition and no object state — which is + * what makes "a mismatched build cannot reach run state" a fact about the + * code rather than a hope about it. + */ + admit( + request: AdmissionRequest, + socket: WebSocket, + acquisitionId: string, + ): AcquisitionAttachment { + const { policy } = this.configuration(); + requireSameRelease(policy.release, request.release); + admitClaims(policy, parseClaims(request.claims)); + const runId = admitRunId(request.runId); + return acquireExecutor(this.ctx, socket, runId, acquisitionId); + } + + /** + * 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 { id: command.id, outcome: "performed", value: this.perform(socket, runId, command) }; + } catch (error) { + return { id: command?.id ?? "", outcome: "refused", refusal: refusalOf(error) }; + } + } + + /** What each command does. Subclasses supply the behavior this owner has. */ + protected abstract perform(socket: WebSocket, runId: string, command: RunnerCommand): unknown; + + /** 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. + */ + open(runId: string, initializeRun: () => void): void { + admitRunId(runId); + if (isPristine(declaredObjects(this.owned))) { + initializeObject(this.owned, initializeRun); + return; + } + recognizeObject(this.owned); + } +} 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/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/tests/cloudflare/env.d.ts b/packages/workflow/tests/cloudflare/env.d.ts index 8697c38fd..e0dfafaa6 100644 --- a/packages/workflow/tests/cloudflare/env.d.ts +++ b/packages/workflow/tests/cloudflare/env.d.ts @@ -1,3 +1,4 @@ +import type { ExecutorObject } from "./support/executor-object.ts"; import type { OwnerObject } from "./support/owner-object.ts"; import type { StorageProbeObject } from "./support/probe-object.ts"; @@ -6,6 +7,7 @@ declare global { 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..ea8eb9150 --- /dev/null +++ b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts @@ -0,0 +1,206 @@ +/** + * 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 { describe, expect, it } from "vitest"; +import type { ExecutorObject } from "./support/executor-object.ts"; +import { POLICY, VALID_CLAIMS } from "./support/executor-object.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"; + +describe("admitting an executor", () => { + it("admits a matching build with authenticated claims", async () => { + const stub = executor(); + expect(await on(stub, (o) => o.admitConnection({}))).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 claims are deliberately unusable. If the release were checked after + // them, the refusal would name the token rather than the build. + expect(await on(stub, (o) => o.admitConnection({ release: "other-build", claims: null }))).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 claims = { ...VALID_CLAIMS, ...overrides }; + expect(await on(stub, (o) => o.admitConnection({ claims }))).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 claims = { ...VALID_CLAIMS, aud: ["https://other", POLICY.audience] }; + expect(await on(stub, (o) => o.admitConnection({ claims }))).toBe("admitted"); + }); + + it("refuses a token that is not a claim set at all", async () => { + const stub = executor(); + expect(await on(stub, (o) => o.admitConnection({ claims: "a string" }))).toBe( + "admission:token-malformed", + ); + }); + + it("refuses a run id that could not address an owner", async () => { + const stub = executor(); + expect(await on(stub, (o) => o.admitConnection({ runId: "" }))).toBe("run-id:run-id-empty"); + expect(await on(stub, (o) => o.admitConnection({ runId: 42 }))).toBe("run-id:run-id-absent"); + expect(await on(stub, (o) => o.holders())).toBe(0); + }); +}); + +describe("holding an acquisition", () => { + it("refuses a second healthy executor rather than following it", async () => { + const stub = executor(); + expect(await on(stub, (o) => o.admitConnection({}))).toBe("admitted"); + expect(await on(stub, (o) => o.admitConnection({}))).toBe("acquisition:already-running"); + expect(await on(stub, (o) => o.holders())).toBe(1); + }); + + it("lets the admitted connection send, and answers what it performed", async () => { + const stub = executor(); + await on(stub, (o) => o.admitConnection({})); + expect( + await on(stub, (o) => o.send(1, JSON.stringify({ id: "1", command: "frontier" }))), + ).toEqual({ id: "1", outcome: "performed", value: { performed: "frontier" } }); + }); + + it("refuses a socket it never admitted", async () => { + const stub = executor(); + await on(stub, (o) => o.admitConnection({})); + expect( + await on(stub, (o) => o.sendAsStranger(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 on(stub, (o) => o.admitConnection({})); + 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 on(stub, (o) => o.admitConnection({}))).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 on(stub, (o) => o.admitConnection({})); + 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: "materialize" }))).refusal).toBe( + "command:malformed-member", + ); + }); + + it("reads a commit intent whole", async () => { + const stub = executor(); + await on(stub, (o) => o.admitConnection({})); + const raw = JSON.stringify({ + id: "7", + command: "commit", + expectedWorkspaceRootId: "root-a", + expectedJournalEventId: null, + content: [{ digest: "d1", bytes: "AAAA" }], + proposedWorkspaceRootId: "root-b", + events: ["event-1"], + }); + expect(await on(stub, (o) => o.send(1, raw))).toEqual({ + id: "7", + outcome: "performed", + value: { performed: "commit" }, + }); + }); +}); + +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/support/executor-object.ts b/packages/workflow/tests/cloudflare/support/executor-object.ts new file mode 100644 index 000000000..a268019e8 --- /dev/null +++ b/packages/workflow/tests/cloudflare/support/executor-object.ts @@ -0,0 +1,109 @@ +/** + * 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 { 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 { RunnerCommand } from "../../../src/cloudflare/commands.ts"; +import { refusalOf } from "../../../src/cloudflare/owner.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", +}; + +/** Claims a verifier would have authenticated 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, +}; + +const RUN_ID = "5cktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; + +export class ExecutorObject extends WorkflowOwnerObject { + #acquisitions = 0; + + protected configuration(): OwnerConfiguration { + return { policy: POLICY }; + } + + protected perform(_socket: WebSocket, _runId: string, command: RunnerCommand): unknown { + return { performed: command.command }; + } + + /** + * Admit one connection, answering what happened rather than raising. + * + * The client half of the pair is kept so a later call can drive it; the + * server half is what the object admitted. + */ + admitConnection(request: Partial): string { + const pair = new WebSocketPair(); + const server = pair[1]; + this.#acquisitions += 1; + try { + this.admit( + { + runId: "runId" in request ? request.runId : RUN_ID, + release: "release" in request ? request.release : POLICY.release, + claims: "claims" in request ? request.claims : VALID_CLAIMS, + }, + server, + `acquisition-${this.#acquisitions}`, + ); + return "admitted"; + } catch (error) { + return refusalOf(error); + } + } + + /** 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 a socket this object never admitted. */ + sendAsStranger(raw: string): unknown { + const pair = new WebSocketPair(); + 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); + } + } +} diff --git a/packages/workflow/tests/cloudflare/worker.ts b/packages/workflow/tests/cloudflare/worker.ts index 4143c1ffd..1da99ee72 100644 --- a/packages/workflow/tests/cloudflare/worker.ts +++ b/packages/workflow/tests/cloudflare/worker.ts @@ -8,6 +8,7 @@ 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 { diff --git a/packages/workflow/tests/cloudflare/wrangler.jsonc b/packages/workflow/tests/cloudflare/wrangler.jsonc index 0315d0393..9e7f7877d 100644 --- a/packages/workflow/tests/cloudflare/wrangler.jsonc +++ b/packages/workflow/tests/cloudflare/wrangler.jsonc @@ -7,7 +7,10 @@ "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"] }], + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["StorageProbeObject", "OwnerObject", "ExecutorObject"] }, + ], } diff --git a/packages/workflow/tsconfig.cloudflare.json b/packages/workflow/tsconfig.cloudflare.json index 96d28350a..4e35f34ec 100644 --- a/packages/workflow/tsconfig.cloudflare.json +++ b/packages/workflow/tsconfig.cloudflare.json @@ -15,5 +15,5 @@ "skipLibCheck": true, "verbatimModuleSyntax": true }, - "include": ["src/cloudflare/**/*.ts", "tests/cloudflare/**/*.ts"] + "include": ["cloudflare.ts", "src/cloudflare/**/*.ts", "tests/cloudflare/**/*.ts"] } From fc4841add4ce61fc85396c7080f959b14cceed0c Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 20:08:54 -0400 Subject: [PATCH 12/42] =?UTF-8?q?=F0=9F=A7=BE=20Collect=20a=20remote=20tra?= =?UTF-8?q?nsaction=20and=20submit=20one=20closed=20intent=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `transact()` for a run whose storage is somewhere else, as `698-a-1.md` Q3 settles it. A Durable Object commits synchronously and cannot hold a transaction open across a network wait, so the obvious reading is unavailable. What makes it tractable is that the body does not need the transaction open while it runs: it needs the starting history, somewhere for its writes to go, and all of them landing together. So the callback runs in a runner-owned scope against a collector. The starting frontier is one bounded read that opens and closes its own owner-side read. Appends go into a local buffer that `readAll()` reads back after the starting prefix, so a body sees its own writes. Nothing is sent while the body runs; when the body and everything it started have torn down, one closed intent goes to the owner. The callback is never serialized, interpreted or executed on the owner. That is what makes arbitrary control flow safe — nothing tries to infer what the body did, and only what it *enlisted* travels. `src/remote/collector.ts` rather than `src/cloudflare/**`: this is the client half and it runs on the runner, not in the Worker. It names no host, so the ordinary Deno check and both boundary scans cover it. Nine assertions with a deterministic fake link, because Cloudflare mechanics are not the subject — what the owner does with an intent is proven on workerd, what the client sends is proven here. One intent carries what was enlisted and the frontier it was proposed against. `readAll()` gives read-your-writes. A body may cross a suspension point with no owner transaction open. A body that throws sends nothing and leaves the gate closed. An owner refusal is returned instead of the body's value, which never crossed the connection. A nested transaction and an ordinary same-handle operation inside a body each refuse. A handle used after its body closed refuses. And an event mutated after it was appended commits as it was handed over, because the collector cloned it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/remote/collector.ts | 171 ++++++++++++ .../workflow/tests/remote-transaction.test.ts | 247 ++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 packages/workflow/src/remote/collector.ts create mode 100644 packages/workflow/tests/remote-transaction.test.ts diff --git a/packages/workflow/src/remote/collector.ts b/packages/workflow/src/remote/collector.ts new file mode 100644 index 000000000..41f7f4484 --- /dev/null +++ b/packages/workflow/src/remote/collector.ts @@ -0,0 +1,171 @@ +/** + * `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 } from "effection"; +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" + | "transaction-closed" + | "operation-inside-body" + | "too-many-events"; + +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. */ +export interface CommitIntent { + readonly expectedWorkspaceRootId: string; + readonly expectedJournalEventId: string | null; + readonly events: readonly DurableEvent[]; +} + +/** 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; + +/** + * 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) => Operation, +): Operation> { + return call(function* (): Operation> { + if (gate.open) { + throw new RemoteTransactionError("nested-transaction"); + } + 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: the starting prefix, then this transaction's own + // appends, in order. A body that reads back what it just wrote sees it + // even though the owner has not been told yet. + return [...starting.events, ...appended]; + }, + *append(event: DurableEvent): Operation { + if (!live) { + throw new RemoteTransactionError("transaction-closed"); + } + if (appended.length >= MAX_EVENTS) { + throw new RemoteTransactionError("too-many-events"); + } + // Cloned on the way in, so a caller that keeps mutating the value it + // handed over cannot change what this transaction will commit. + appended.push(structuredClone(event)); + }, + }; + + gate.open = true; + let outcome: T; + try { + // Everything the body started tears down before the intent is built. A + // failure or cancellation leaves through here without a commit, which is + // what makes "no commit was sent" the same statement as "the body did not + // finish". + outcome = yield* call(() => body({ journal })); + } finally { + live = false; + gate.open = false; + } + + const committed = yield* link.commit({ + expectedWorkspaceRootId: starting.workspaceRootId, + expectedJournalEventId: starting.journalEventId, + events: appended, + }); + if (!committed.ok) { + return committed; + } + // Only now. `T` is the body's own value and never crossed the connection; + // returning it before the owner committed would be a caller holding a + // result for work that did not happen. + return Ok(outcome); + }); +} + +/** Discard a collector's work without sending it. */ +export function abandon(gate: TransactionGate): Operation { + return ensure(() => { + gate.open = false; + }); +} diff --git a/packages/workflow/tests/remote-transaction.test.ts b/packages/workflow/tests/remote-transaction.test.ts new file mode 100644 index 000000000..ef82a5b63 --- /dev/null +++ b/packages/workflow/tests/remote-transaction.test.ts @@ -0,0 +1,247 @@ +/** + * 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, type Operation, type Result } from "effection"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { + type CommitIntent, + createTransactionGate, + type OwnerLink, + RemoteTransactionError, + requireNoOpenTransaction, + type StartingFrontier, + transactRemotely, +} from "../src/remote/collector.ts"; + +/** 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 ""; + } + const name = (description as Record)["name"]; + return typeof name === "string" ? name : ""; +} + +/** 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") { + (description as Record)["name"] = name; + } +} + +function event(name: string): DurableEvent { + return { + type: "yield", + coroutineId: "root", + description: { type: "test", name }, + result: { status: "ok", value: name }, + } as DurableEvent; +} + +/** 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; + } = {}, +) { + const sent: CommitIntent[] = []; + const starting: StartingFrontier = options.frontier ?? { + workspaceRootId: "root-a", + journalEventId: "event-0", + events: [event("already-there")], + }; + const owner: OwnerLink = { + *frontier(): Operation { + return starting; + }, + *commit(intent: CommitIntent): Operation> { + sent.push(intent); + return options.commit === undefined ? Ok(undefined) : options.commit(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((raised as RemoteTransactionError).refusal).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((raised as RemoteTransactionError).refusal).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((raised as RemoteTransactionError).refusal).toBe("transaction-closed"); + }); + + 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"); + }); +}); From bf6358d45ec1cc760c449c6bedc377ca67903ce0 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 20:11:34 -0400 Subject: [PATCH 13/42] =?UTF-8?q?=F0=9F=93=A1=20Carry=20a=20runner's=20req?= =?UTF-8?q?uest=20to=20its=20owner=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner half of the connection. `src/remote/client.ts` holds one connection open for the calling scope and correlates answers by the id they name rather than by arrival order — 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. Teardown fails every request still waiting, because a caller blocked on an answer that can never arrive would outlive the connection it asked through. It decides nothing about the run. A refusal comes back as an answer rather than a transport failure: what a command means is the owner's, and a client that interpreted a refusal would be a second place deciding what a run may do. Seven assertions against a socket a test drives by hand. The command reaches the wire carrying its id; two answers returned in the opposite order to the asking still reach the right callers; a refusal is handed back as an answer; an answer that is not JSON and an answer naming nobody are both dropped without disturbing the caller that was waiting; a connection that ends fails the request in flight and refuses the next one; and a second request under an id already in flight is refused rather than silently replacing it. This is client code and it names no host, so it lives beside the collector in `src/remote/` where the ordinary Deno check and both boundary scans cover it. Not included, deliberately: `packages/cli/src/remote-workflow.ts`. The four- method assembly composes client operations — a remote `WorkflowRunDatabase`, lifecycle, delivery and inspection over this connection — and those do not exist yet. A host whose four methods all raise would be the placeholder the plan says not to add, so the assembly waits until there is something real to assemble. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/remote/client.ts | 137 +++++++++++++++ packages/workflow/tests/remote-client.test.ts | 165 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 packages/workflow/src/remote/client.ts create mode 100644 packages/workflow/tests/remote-client.test.ts diff --git a/packages/workflow/src/remote/client.ts b/packages/workflow/src/remote/client.ts new file mode 100644 index 000000000..669dbbada --- /dev/null +++ b/packages/workflow/src/remote/client.ts @@ -0,0 +1,137 @@ +/** + * 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. + */ + +import { createSignal, each, type Operation, resource, spawn, withResolvers } from "effection"; + +/** Why the connection itself could not carry a request. */ +export type LinkRefusal = "closed" | "malformed-answer" | "duplicate-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, as the client reads it. */ +export type OwnerAnswer = + | { readonly outcome: "performed"; readonly value: unknown } + | { readonly outcome: "refused"; readonly refusal: string }; + +/** The socket shape this client needs, so a test can supply one. */ +export interface OwnerSocket { + send(data: string): void; + close(): void; + addEventListener(type: "message", listener: (event: { data: unknown }) => void): void; + addEventListener(type: "close", listener: () => void): void; +} + +/** One live connection to a run's owner. */ +export interface OwnerConnection { + /** Send one command and wait for the answer that names it. */ + ask(id: string, command: Record): Operation; +} + +function readAnswer(raw: unknown): { id: string; answer: OwnerAnswer } { + if (typeof raw !== "string") { + throw new OwnerLinkError("malformed-answer"); + } + 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 = decoded as Record; + const id = members["id"]; + const outcome = members["outcome"]; + if (typeof id !== "string") { + throw new OwnerLinkError("malformed-answer"); + } + if (outcome === "performed") { + return { id, answer: { outcome, value: members["value"] } }; + } + if (outcome === "refused") { + const refusal = members["refusal"]; + if (typeof refusal !== "string") { + throw new OwnerLinkError("malformed-answer"); + } + return { id, answer: { outcome, refusal } }; + } + throw new OwnerLinkError("malformed-answer"); +} + +/** + * Hold one connection open for the calling scope. + * + * Teardown resolves every request still waiting with a closed refusal rather + * than leaving it pending: a caller blocked on an answer that can never arrive + * would outlive the connection it was asking through. + */ +export function useOwnerConnection(socket: OwnerSocket): Operation { + return resource(function* (provide) { + const waiting = new Map>>(); + const messages = createSignal(); + let closed = false; + + socket.addEventListener("message", (event) => messages.send(event.data)); + socket.addEventListener("close", () => { + closed = true; + messages.close(); + }); + + yield* spawn(function* () { + for (const raw of yield* each(messages)) { + // A malformed answer fails the request it names when it names one, and + // is otherwise dropped: it cannot be attributed to a caller. + try { + const { id, answer } = readAnswer(raw); + const pending = waiting.get(id); + if (pending !== undefined) { + waiting.delete(id); + pending.resolve(answer); + } + } catch { + // Nothing to attribute it to. + } + yield* each.next(); + } + for (const pending of waiting.values()) { + pending.reject(new OwnerLinkError("closed")); + } + waiting.clear(); + }); + + yield* provide({ + *ask(id: string, command: Record): Operation { + if (closed) { + throw new OwnerLinkError("closed"); + } + if (waiting.has(id)) { + throw new OwnerLinkError("duplicate-answer"); + } + const pending = withResolvers(); + waiting.set(id, pending); + socket.send(JSON.stringify({ ...command, id })); + return yield* pending.operation; + }, + }); + }); +} diff --git a/packages/workflow/tests/remote-client.test.ts b/packages/workflow/tests/remote-client.test.ts new file mode 100644 index 000000000..75e1d5bb8 --- /dev/null +++ b/packages/workflow/tests/remote-client.test.ts @@ -0,0 +1,165 @@ +/** + * 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, useOwnerConnection } from "../src/remote/client.ts"; + +/** A socket a test drives by hand. */ +function fakeSocket() { + const sent: Record[] = []; + let onMessage: ((event: { data: unknown }) => void) | undefined; + let onClose: (() => void) | undefined; + const socket: OwnerSocket = { + send(data: string): void { + sent.push(JSON.parse(data)); + }, + close(): void { + onClose?.(); + }, + addEventListener(type: "message" | "close", listener: never): void { + if (type === "message") { + onMessage = listener; + } else { + onClose = listener; + } + }, + }; + return { + socket, + sent, + answer(value: unknown): void { + onMessage?.({ data: typeof value === "string" ? value : JSON.stringify(value) }); + }, + end(): void { + onClose?.(); + }, + }; +} + +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" })); + 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" })); + const second = yield* spawn(() => owner.ask("a2", { command: "settle" })); + + // 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" })); + wire.answer({ id: "a1", outcome: "refused", refusal: "acquisition:already-running" }); + expect(yield* asking).toEqual({ + outcome: "refused", + refusal: "acquisition:already-running", + }); + }); + yield* sleep(0); + }); + + it("drops an answer it cannot attribute, and still answers the caller", 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" })); + wire.answer("not json at all"); + wire.answer({ id: "somebody-else", outcome: "performed", value: 1 }); + wire.answer({ id: "a1", outcome: "performed", value: "mine" }); + expect(yield* asking).toEqual({ outcome: "performed", value: "mine" }); + }); + 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" }); + } catch (error) { + raised = error; + } + }); + wire.end(); + yield* asking; + }); + yield* sleep(0); + expect(raised).toBeInstanceOf(OwnerLinkError); + expect((raised as OwnerLinkError).refusal).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" }); + } catch (error) { + raised = error; + } + }); + expect((raised as OwnerLinkError).refusal).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" })); + yield* sleep(0); + try { + yield* owner.ask("a1", { command: "settle" }); + } catch (error) { + raised = error; + } + wire.answer({ id: "a1", outcome: "performed", value: null }); + }); + expect((raised as OwnerLinkError).refusal).toBe("duplicate-answer"); + }); +}); From 871dbc85cd626fca641c09ad833ddbc3121a3ec7 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 21:39:47 -0400 Subject: [PATCH 14/42] =?UTF-8?q?=F0=9F=94=91=20Verify=20the=20token=20ins?= =?UTF-8?q?tead=20of=20trusting=20claims=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrections 1 and 2 from `.vscode/698/698-c-2.md`. The first is a real hole I opened, and the review is right about it: `admit()` took an `AdmissionRequest.claims` and compared its values to the policy, so a caller could assert every configured identity and obtain the acquisition without ever holding a signed token. A comment saying a verifier had authenticated them was not an authority boundary — `OwnerConfiguration` held no verifier, and the claims arrived through the same request surface the owner distrusts. `src/cloudflare/token.ts` verifies a compact JWS: one algorithm family by allowlist rather than by reading `alg` and believing it, keys the deployment configured, signature, then temporal validity — and only then is a payload member read as a claim. The raw token, the key material, the header and the claims the policy does not name stop there; none is retained, attached, journaled or returned. `admitClaims()` and `parseClaims()` are no longer exported. `admitToken()` is the only way into that module, because an exported "check these claims" is precisely the surface that made this forgeable. `AdmissionRequest` now carries the raw token and has 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. Verification material is closure state on the owner. `admit()` is an Effection operation, since verification suspends; the test owner drives it through one scope at the runtime callback boundary. Correction 2: the acquisition correlation is minted on the owner after both checks pass, from `crypto.getRandomValues`, and is no longer a caller argument. It partitions acquisition-private staging and duplicate handling and is not a bearer credential — the exact live socket is still what proves a message may act. Thirty-five assertions. Tokens are signed with an RSA key pair generated in the test process, so these are real signatures rather than a stub that agreed. A correctly signed token admits; an edited payload, a wrong key under a configured key id, an unknown key id, `alg: "none"`, an absent token, a non-JWS, an expired one and a not-yet-valid one each refuse before acquisition and before state. The wrong-release test now presents an unusable token, so it still proves the build is compared first. Two sequential acquisitions receive different correlations. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/admission.ts | 39 +++- packages/workflow/src/cloudflare/owner.ts | 61 ++++-- packages/workflow/src/cloudflare/token.ts | 180 ++++++++++++++++++ .../cloudflare/executor-acquisition.vitest.ts | 128 ++++++++++--- .../cloudflare/support/executor-object.ts | 57 ++++-- .../tests/cloudflare/support/tokens.ts | 68 +++++++ 6 files changed, 470 insertions(+), 63 deletions(-) create mode 100644 packages/workflow/src/cloudflare/token.ts create mode 100644 packages/workflow/tests/cloudflare/support/tokens.ts diff --git a/packages/workflow/src/cloudflare/admission.ts b/packages/workflow/src/cloudflare/admission.ts index 17a247f33..bd6f1cfdb 100644 --- a/packages/workflow/src/cloudflare/admission.ts +++ b/packages/workflow/src/cloudflare/admission.ts @@ -8,6 +8,11 @@ * 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 @@ -16,6 +21,9 @@ * 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; @@ -80,12 +88,13 @@ function requireClaim(claim: unknown, expected: string, refusal: AdmissionRefusa /** * Hold verified claims to the configured policy. * - * Takes claims a verifier already authenticated rather than a token, so this - * module owns *which* claims decide and nothing about how a signature is - * checked. A caller that has not verified a signature has not admitted - * anything, whatever this returns. + * 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. */ -export function admitClaims(policy: AdmissionPolicy, claims: ActionsClaims): void { +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. @@ -102,8 +111,8 @@ export function admitClaims(policy: AdmissionPolicy, claims: ActionsClaims): voi requireClaim(claims.job_workflow_ref, policy.jobWorkflowRef, "workflow-identity"); } -/** Read a claim set out of a payload nothing has inspected yet. */ -export function parseClaims(payload: unknown): ActionsClaims { +/** Read a claim set out of a verified payload. */ +function parseClaims(payload: unknown): ActionsClaims { if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { throw new AdmissionError("token-malformed"); } @@ -119,3 +128,19 @@ export function parseClaims(payload: unknown): ActionsClaims { job_workflow_ref: members["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/owner.ts b/packages/workflow/src/cloudflare/owner.ts index e42787a93..b8b31fdd1 100644 --- a/packages/workflow/src/cloudflare/owner.ts +++ b/packages/workflow/src/cloudflare/owner.ts @@ -21,6 +21,7 @@ */ import { DurableObject } from "cloudflare:workers"; +import type { Operation } from "effection"; import { acquireExecutor, type AcquisitionAttachment, @@ -28,7 +29,8 @@ import { releaseExecutor, requireAcquisition, } from "./acquisition.ts"; -import { admitClaims, type AdmissionPolicy, AdmissionError, parseClaims } from "./admission.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 { declaredObjects, @@ -41,17 +43,26 @@ import { ReleaseIdentityError, requireSameRelease } from "./release.ts"; import { admitRunId, RunIdError } from "./routing.ts"; import type { OwnerStorage } from "./storage.ts"; -/** What one admission presents. */ +/** + * 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; - /** Claims a verifier has already authenticated. */ - readonly claims: 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. */ @@ -62,6 +73,9 @@ export function refusalOf(error: unknown): string { if (error instanceof AdmissionError) { return `admission:${error.refusal}`; } + if (error instanceof TokenError) { + return `token:${error.refusal}`; + } if (error instanceof ReleaseIdentityError) { return `release:${error.refusal}`; } @@ -95,22 +109,21 @@ export abstract class WorkflowOwnerObject extends DurableObject { /** * Admit one executor connection. * - * The order is the contract: the build is compared before the token is read, - * the token before the run is touched, and the acquisition is taken last. A - * refusal at any step leaves no acquisition and no object state — which is - * what makes "a mismatched build cannot reach run state" a fact about the - * code rather than a hope about it. + * 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, - acquisitionId: string, - ): AcquisitionAttachment { - const { policy } = this.configuration(); + *admit(request: AdmissionRequest, socket: WebSocket): Operation { + const { policy, verification } = this.configuration(); requireSameRelease(policy.release, request.release); - admitClaims(policy, parseClaims(request.claims)); + yield* admitToken(policy, verification, request.token); const runId = admitRunId(request.runId); - return acquireExecutor(this.ctx, socket, runId, acquisitionId); + return acquireExecutor(this.ctx, socket, runId, mintAcquisitionId()); } /** @@ -160,3 +173,17 @@ export abstract class WorkflowOwnerObject extends DurableObject { recognizeObject(this.owned); } } + +/** + * 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(""); +} diff --git a/packages/workflow/src/cloudflare/token.ts b/packages/workflow/src/cloudflare/token.ts new file mode 100644 index 000000000..96a0d8fbe --- /dev/null +++ b/packages/workflow/src/cloudflare/token.ts @@ -0,0 +1,180 @@ +/** + * 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" + | "unsupported-algorithm" + | "unknown-key" + | "bad-signature" + | "expired" + | "not-yet-valid"; + +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"; + +/** 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. */ + 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): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TokenError("token-malformed"); + } + return value as Record; +} + +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> { + if (typeof token !== "string" || token === "") { + throw new TokenError("token-absent"); + } + const parts = token.split("."); + if (parts.length !== 3) { + 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["alg"] !== SUPPORTED) { + throw new TokenError("unsupported-algorithm"); + } + + const signed = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`); + const signature = signatureBytes(encodedSignature); + const keyId = header["kid"]; + // A `kid` narrows which key is tried; its absence means every configured key + // is a candidate. Either way only a configured key can verify anything. + const candidates = configured.keys.filter( + (key) => typeof keyId !== "string" || key.kid === undefined || key.kid === keyId, + ); + if (candidates.length === 0) { + 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(); + const expiry = payload["exp"]; + if (typeof expiry === "number" && now > expiry + configured.skewSeconds) { + throw new TokenError("expired"); + } + const notBefore = payload["nbf"]; + if (typeof notBefore === "number" && now + configured.skewSeconds < notBefore) { + throw new TokenError("not-yet-valid"); + } + return payload; +} diff --git a/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts index ea8eb9150..70ba65b7e 100644 --- a/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts +++ b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts @@ -9,9 +9,10 @@ */ import { env, runInDurableObject } from "cloudflare:test"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; 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; @@ -29,18 +30,46 @@ function on( 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 on(stub, (o) => o.admitConnection({}))).toBe("admitted"); + 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 claims are deliberately unusable. If the release were checked after - // them, the refusal would name the token rather than the build. - expect(await on(stub, (o) => o.admitConnection({ release: "other-build", claims: null }))).toBe( + // 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); @@ -85,29 +114,66 @@ describe("admitting an executor", () => { ]; for (const [expected, overrides] of cases) { const stub = executor(); - const claims = { ...VALID_CLAIMS, ...overrides }; - expect(await on(stub, (o) => o.admitConnection({ claims }))).toBe(expected); + 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 claims = { ...VALID_CLAIMS, aud: ["https://other", POLICY.audience] }; - expect(await on(stub, (o) => o.admitConnection({ claims }))).toBe("admitted"); + const token = await signToken(keys, claims({ aud: ["https://other", POLICY.audience] })); + expect(await admitted(stub, { token })).toBe("admitted"); }); - it("refuses a token that is not a claim set at all", async () => { + it("refuses a token whose payload was edited after signing", async () => { const stub = executor(); - expect(await on(stub, (o) => o.admitConnection({ claims: "a string" }))).toBe( - "admission:token-malformed", - ); + 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("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 on(stub, (o) => o.admitConnection({ runId: "" }))).toBe("run-id:run-id-empty"); - expect(await on(stub, (o) => o.admitConnection({ runId: 42 }))).toBe("run-id:run-id-absent"); + 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); }); }); @@ -115,14 +181,30 @@ describe("admitting an executor", () => { describe("holding an acquisition", () => { it("refuses a second healthy executor rather than following it", async () => { const stub = executor(); - expect(await on(stub, (o) => o.admitConnection({}))).toBe("admitted"); - expect(await on(stub, (o) => o.admitConnection({}))).toBe("acquisition:already-running"); + 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.admitConnection({})); + await admitted(stub); expect( await on(stub, (o) => o.send(1, JSON.stringify({ id: "1", command: "frontier" }))), ).toEqual({ id: "1", outcome: "performed", value: { performed: "frontier" } }); @@ -130,7 +212,7 @@ describe("holding an acquisition", () => { it("refuses a socket it never admitted", async () => { const stub = executor(); - await on(stub, (o) => o.admitConnection({})); + await admitted(stub); expect( await on(stub, (o) => o.sendAsStranger(JSON.stringify({ id: "1", command: "frontier" }))), ).toEqual({ id: "", outcome: "refused", refusal: "acquisition:foreign-connection" }); @@ -138,11 +220,11 @@ describe("holding an acquisition", () => { it("owns nothing once the connection ends, and rolls nothing back", async () => { const stub = executor(); - await on(stub, (o) => o.admitConnection({})); + 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 on(stub, (o) => o.admitConnection({}))).toBe("admitted"); + expect(await admitted(stub)).toBe("admitted"); }); it("proves the acquisition before it reads a command", async () => { @@ -158,7 +240,7 @@ describe("holding an acquisition", () => { describe("reading a runner command", () => { it("refuses what it cannot read as one", async () => { const stub = executor(); - await on(stub, (o) => o.admitConnection({})); + 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"); @@ -179,7 +261,7 @@ describe("reading a runner command", () => { it("reads a commit intent whole", async () => { const stub = executor(); - await on(stub, (o) => o.admitConnection({})); + await admitted(stub); const raw = JSON.stringify({ id: "7", command: "commit", diff --git a/packages/workflow/tests/cloudflare/support/executor-object.ts b/packages/workflow/tests/cloudflare/support/executor-object.ts index a268019e8..bf30a10aa 100644 --- a/packages/workflow/tests/cloudflare/support/executor-object.ts +++ b/packages/workflow/tests/cloudflare/support/executor-object.ts @@ -7,10 +7,12 @@ * allowed to send one, not what each one means. */ +import { run } from "effection"; 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 type { RunnerCommand } from "../../../src/cloudflare/commands.ts"; import { refusalOf } from "../../../src/cloudflare/owner.ts"; @@ -27,7 +29,7 @@ export const POLICY: AdmissionPolicy = { release: "factory-2026.09.02-abcdef", }; -/** Claims a verifier would have authenticated for the policy above. */ +/** The claims a correctly issued token carries for the policy above. */ export const VALID_CLAIMS: Record = { iss: POLICY.issuer, aud: POLICY.audience, @@ -42,10 +44,30 @@ export const VALID_CLAIMS: Record = { const RUN_ID = "5cktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; export class ExecutorObject extends WorkflowOwnerObject { - #acquisitions = 0; + /** + * 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[] = []; + #now = 1_800_000_000; + + configure(keys: VerificationKey[], now?: number): void { + this.#keys = keys; + if (now !== undefined) { + this.#now = now; + } + } protected configuration(): OwnerConfiguration { - return { policy: POLICY }; + const verification: TokenVerification = { + keys: this.#keys, + skewSeconds: 60, + now: () => this.#now, + }; + return { policy: POLICY, verification }; } protected perform(_socket: WebSocket, _runId: string, command: RunnerCommand): unknown { @@ -55,29 +77,32 @@ export class ExecutorObject extends WorkflowOwnerObject { /** * Admit one connection, answering what happened rather than raising. * - * The client half of the pair is kept so a later call can drive it; the - * server half is what the object admitted. + * 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. */ - admitConnection(request: Partial): string { + async admitConnection(request: Partial): Promise { const pair = new WebSocketPair(); const server = pair[1]; - this.#acquisitions += 1; + 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 { - this.admit( - { - runId: "runId" in request ? request.runId : RUN_ID, - release: "release" in request ? request.release : POLICY.release, - claims: "claims" in request ? request.claims : VALID_CLAIMS, - }, - server, - `acquisition-${this.#acquisitions}`, - ); + await run(() => this.admit(presented, server)); return "admitted"; } catch (error) { return refusalOf(error); } } + /** 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; 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]}`; +} From dd45ed88271eeb4a358887cc9d67e3305fe9d36a Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 21:43:06 -0400 Subject: [PATCH 15/42] =?UTF-8?q?=F0=9F=94=92=20Own=20the=20whole=20transa?= =?UTF-8?q?ction,=20detach=20the=20intent,=20fail=20the=20channel=20closed?= =?UTF-8?q?=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrections 3, 4 and 5 from `.vscode/698/698-c-2.md`. All three were real. Correction 3. `transactRemotely()` checked `gate.open`, then suspended in `frontier()`, and only took the gate afterwards — so two calls on one handle could both pass the check and act from the same starting frontier. It also released the gate when the body ended, before `commit()` returned, so later work could run while which state won was still undecided. The gate is now taken synchronously before the first suspension and released in one finalizer after the commit answer, on success, refusal, body failure, transport failure and cancellation alike. The transaction object still closes when the body does, so a retained handle refuses while the handle-level gate is held. Correction 4. `append()` cloned, but `readAll()` handed back references into the collector's own arrays: a body could read an event and mutate what it received, and the committed intent would differ from what `append()` admitted. Every crossing is now a fresh copy — in, out, and into the intent — and the collector's array never leaves. Events are admitted rather than assumed, with a count bound and an aggregate serialized-byte bound; an invalid or oversized one fails locally and sends nothing. Correction 5. The connection dropped malformed answers and answers for unknown ids, which meant a malformed reply to an in-flight commit left the caller waiting forever while the owner may already have committed. All three of those are evidence that the two sides disagree about which command completed, so the channel now fails closed: an unreadable answer, an answer naming a request nobody made, and a second answer to a request already settled each stop the channel, close the socket and reject every waiter. Owner refusals remain typed answers, and answers are bounded. Nine assertions on the channel and fifteen on the transaction. Two of the transaction ones are the concurrency cases the review asked for: a second transaction refuses while the first is suspended in `frontier()`, and the handle stays owned while a blocked `commit()` is undecided. Two more prove detachment from both directions — a reader mutating what `readAll()` returned, and a caller mutating what it appended. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/remote/client.ts | 61 +++++-- packages/workflow/src/remote/collector.ts | 139 ++++++++++----- packages/workflow/tests/remote-client.test.ts | 95 ++++++++-- .../workflow/tests/remote-transaction.test.ts | 166 +++++++++++++++++- 4 files changed, 389 insertions(+), 72 deletions(-) diff --git a/packages/workflow/src/remote/client.ts b/packages/workflow/src/remote/client.ts index 669dbbada..3f2a215d0 100644 --- a/packages/workflow/src/remote/client.ts +++ b/packages/workflow/src/remote/client.ts @@ -12,12 +12,25 @@ * 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 { createSignal, each, type Operation, resource, spawn, withResolvers } from "effection"; /** Why the connection itself could not carry a request. */ -export type LinkRefusal = "closed" | "malformed-answer" | "duplicate-answer"; +export type LinkRefusal = + | "closed" + | "malformed-answer" + | "unknown-answer" + | "duplicate-answer" + | "too-large"; export class OwnerLinkError extends Error { override name = "OwnerLinkError"; @@ -46,10 +59,16 @@ export interface OwnerConnection { ask(id: string, command: Record): Operation; } +/** The most bytes one answer may carry. */ +const MAX_ANSWER = 8 * 1024 * 1024; + function readAnswer(raw: unknown): { id: string; answer: OwnerAnswer } { 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); @@ -88,6 +107,8 @@ function readAnswer(raw: unknown): { id: string; answer: OwnerAnswer } { export function useOwnerConnection(socket: OwnerSocket): Operation { return resource(function* (provide) { const waiting = new Map>>(); + /** Requests already answered, so a second answer is recognized as one. */ + const settled = new Set(); const messages = createSignal(); let closed = false; @@ -97,22 +118,40 @@ export function useOwnerConnection(socket: OwnerSocket): Operation { + closed = true; + for (const pending of waiting.values()) { + pending.reject(new OwnerLinkError(refusal)); + } + waiting.clear(); + socket.close(); + }; + yield* spawn(function* () { for (const raw of yield* each(messages)) { - // A malformed answer fails the request it names when it names one, and - // is otherwise dropped: it cannot be attributed to a caller. + let read: { id: string; answer: OwnerAnswer } | undefined; try { - const { id, answer } = readAnswer(raw); - const pending = waiting.get(id); - if (pending !== undefined) { - waiting.delete(id); - pending.resolve(answer); - } + read = readAnswer(raw); } catch { - // Nothing to attribute it to. + // The owner said something this build cannot read. Whether it was + // meant for a waiter is exactly what cannot be established. + fail("malformed-answer"); + break; } + 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. + fail(settled.has(read.id) ? "duplicate-answer" : "unknown-answer"); + break; + } + waiting.delete(read.id); + settled.add(read.id); + pending.resolve(read.answer); yield* each.next(); } + closed = true; for (const pending of waiting.values()) { pending.reject(new OwnerLinkError("closed")); } @@ -124,7 +163,7 @@ export function useOwnerConnection(socket: OwnerSocket): Operation(); diff --git a/packages/workflow/src/remote/collector.ts b/packages/workflow/src/remote/collector.ts index 41f7f4484..d5f29d323 100644 --- a/packages/workflow/src/remote/collector.ts +++ b/packages/workflow/src/remote/collector.ts @@ -32,7 +32,9 @@ export type CollectorRefusal = | "nested-transaction" | "transaction-closed" | "operation-inside-body" - | "too-many-events"; + | "too-many-events" + | "events-too-large" + | "malformed-event"; export class RemoteTransactionError extends Error { override name = "RemoteTransactionError"; @@ -67,6 +69,37 @@ export interface OwnerLink { /** 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; + +/** + * 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. * @@ -105,61 +138,75 @@ export function transactRemotely( body: (transaction: WorkflowRunTransaction) => 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"); } - 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: the starting prefix, then this transaction's own - // appends, in order. A body that reads back what it just wrote sees it - // even though the owner has not been told yet. - return [...starting.events, ...appended]; - }, - *append(event: DurableEvent): Operation { - if (!live) { - throw new RemoteTransactionError("transaction-closed"); - } - if (appended.length >= MAX_EVENTS) { - throw new RemoteTransactionError("too-many-events"); - } - // Cloned on the way in, so a caller that keeps mutating the value it - // handed over cannot change what this transaction will commit. - appended.push(structuredClone(event)); - }, - }; - gate.open = true; - let outcome: T; + // 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 { - // Everything the body started tears down before the intent is built. A - // failure or cancellation leaves through here without a commit, which is - // what makes "no commit was sent" the same statement as "the body did not - // finish". - outcome = yield* call(() => body({ journal })); + return yield* run(); } finally { - live = false; gate.open = false; } - const committed = yield* link.commit({ - expectedWorkspaceRootId: starting.workspaceRootId, - expectedJournalEventId: starting.journalEventId, - events: appended, - }); - if (!committed.ok) { - return committed; + 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 outcome: T; + try { + // Everything the body started tears down before the intent is built, so + // "no commit was sent" and "the body did not finish" are one statement. + outcome = yield* call(() => body({ journal })); + } 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; + } + + 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)), + }); + if (!committed.ok) { + return committed; + } + // Only now. `T` is the body's own value and never crossed the connection. + return Ok(outcome); } - // Only now. `T` is the body's own value and never crossed the connection; - // returning it before the owner committed would be a caller holding a - // result for work that did not happen. - return Ok(outcome); }); } diff --git a/packages/workflow/tests/remote-client.test.ts b/packages/workflow/tests/remote-client.test.ts index 75e1d5bb8..6a59079b8 100644 --- a/packages/workflow/tests/remote-client.test.ts +++ b/packages/workflow/tests/remote-client.test.ts @@ -94,20 +94,6 @@ describe("a connection to a run's owner", () => { yield* sleep(0); }); - it("drops an answer it cannot attribute, and still answers the caller", 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" })); - wire.answer("not json at all"); - wire.answer({ id: "somebody-else", outcome: "performed", value: 1 }); - wire.answer({ id: "a1", outcome: "performed", value: "mine" }); - expect(yield* asking).toEqual({ outcome: "performed", value: "mine" }); - }); - yield* sleep(0); - }); - it("fails a request still waiting when the connection ends", function* () { const wire = fakeSocket(); let raised: unknown; @@ -162,4 +148,85 @@ describe("a connection to a run's owner", () => { }); expect((raised as OwnerLinkError).refusal).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" }); + } catch (error) { + raised.push(error); + } + }); + const second = yield* spawn(function* () { + try { + yield* owner.ask("a2", { command: "settle" }); + } 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((error as OwnerLinkError).refusal).toBe("malformed-answer"); + } + }); + + 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" }); + } catch (error) { + raised = error; + } + }); + yield* sleep(0); + wire.answer({ id: "somebody-else", outcome: "performed", value: 1 }); + yield* asking; + }); + expect((raised as OwnerLinkError).refusal).toBe("unknown-answer"); + }); + + 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" })); + 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" }); + } 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((refused as OwnerLinkError).refusal).toBe("duplicate-answer"); + }); }); diff --git a/packages/workflow/tests/remote-transaction.test.ts b/packages/workflow/tests/remote-transaction.test.ts index ef82a5b63..a86f97795 100644 --- a/packages/workflow/tests/remote-transaction.test.ts +++ b/packages/workflow/tests/remote-transaction.test.ts @@ -15,7 +15,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { Err, Ok, sleep, type Operation, type Result } from "effection"; +import { Err, Ok, sleep, spawn, withResolvers, type Operation, type Result } from "effection"; import type { DurableEvent } from "@executablemd/durable-streams"; import { type CommitIntent, @@ -65,6 +65,8 @@ function link( options: { frontier?: StartingFrontier; commit?: (intent: CommitIntent) => Result; + blockFrontier?: { operation: Operation }; + blockCommit?: { operation: Operation }; } = {}, ) { const sent: CommitIntent[] = []; @@ -75,10 +77,16 @@ function link( }; 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(undefined) : options.commit(intent); }, }; @@ -230,6 +238,162 @@ describe("a remote transaction", () => { expect((raised as RemoteTransactionError).refusal).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((raised as RemoteTransactionError).refusal).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((raised as RemoteTransactionError).refusal).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(undefined); + }, + }; + 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* transaction.journal.append({ nothing: true } as unknown as DurableEvent); + return undefined; + }); + } catch (error) { + raised = error; + } + expect((raised as RemoteTransactionError).refusal).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((raised as RemoteTransactionError).refusal).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(); From b6f58d002914cc4189cb9acd62b341f611c8c147 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 22:19:27 -0400 Subject: [PATCH 16/42] =?UTF-8?q?=F0=9F=A7=A9=20Publish=20the=20pure=20cor?= =?UTF-8?q?e=20predicates=20from=20node-free=20subpaths=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1 item 1, and Ruling 1 from `.vscode/698/698-c-4.md`. The owner has to parse a settle request with the shared `parseDocumentExecutionCompletion()`, and could not: `storage/record.ts` imports `canonicalize` from the `@executablemd/core` root, whose barrel reaches `node:crypto`, `node:process` and `node:readline/promises`. None resolves in a Workers typecheck, so a pure function was unreachable because of where it sat. `canonicalize()` moves to `packages/core/src/canonicalize.ts`, importing only a type. `canonicalFingerprint()` stays in the Node-capable module, importing the pure function and `node:crypto`. Both keep their package-root exports and their behavior, and `@executablemd/core/canonicalize` publishes the pure half. Two more predicates blocked the same graph for the same reason, which the ruling anticipates and authorizes handling the same way. `isComponentName` was co-located with the registration machinery and `isCanonicalTarget` with the document-target catalog and its Markdown parser; both are string arithmetic. They move to `src/component-name.ts` and `src/document-target-spelling.ts`, with `@executablemd/core/component-name` and `@executablemd/core/document-target` selecting them. `storage/definition.ts` imports through those. No copy of any of them exists — each original module re-exports the leaf, so there is one implementation and the root surface is unchanged. This is not a `portable` barrel: three narrowly named leaves, each holding what it is named after. Two adjustments fell out. `tsconfig.cloudflare.json` no longer sets `exactOptionalPropertyTypes` or `noUncheckedIndexedAccess`: it was stricter than the repository holds itself to, so it failed pre-existing shared code the Deno check accepts, and a check that invents rules proves the wrong thing. And the percent-decoder now states `ignoreBOM: false` — already its behavior everywhere, and Cloudflare's own type declares both `TextDecoder` options required. Evidence. `packages/core/tests/canonicalize.test.ts` proves root and subpath answer identically, including for `__proto__`, and that the fingerprint still composes over the same ordering. `settle-parser.vitest.ts` runs on real workerd, which is the assertion that matters: it loads the shared parser where a Node builtin is genuinely absent, so a test-only import could not have passed for it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/core/canonicalize.ts | 13 ++ packages/core/component-name.ts | 13 ++ packages/core/deno.json | 3 + packages/core/document-target.ts | 13 ++ packages/core/package.json | 3 + packages/core/src/canonical.ts | 32 ++--- packages/core/src/canonicalize.ts | 41 ++++++ packages/core/src/component-name.ts | 18 +++ packages/core/src/components/registration.ts | 17 +-- packages/core/src/document-target-spelling.ts | 123 ++++++++++++++++++ packages/core/src/document-targets.ts | 122 +++-------------- packages/core/tests/canonicalize.test.ts | 63 +++++++++ packages/workflow/src/cloudflare/commands.ts | 35 ++++- packages/workflow/src/storage/definition.ts | 6 +- packages/workflow/src/storage/record.ts | 5 +- .../tests/cloudflare/settle-parser.vitest.ts | 96 ++++++++++++++ packages/workflow/tsconfig.cloudflare.json | 2 - 17 files changed, 451 insertions(+), 154 deletions(-) create mode 100644 packages/core/canonicalize.ts create mode 100644 packages/core/component-name.ts create mode 100644 packages/core/document-target.ts create mode 100644 packages/core/src/canonicalize.ts create mode 100644 packages/core/src/component-name.ts create mode 100644 packages/core/src/document-target-spelling.ts create mode 100644 packages/core/tests/canonicalize.test.ts create mode 100644 packages/workflow/tests/cloudflare/settle-parser.vitest.ts 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..1b62eeae6 100644 --- a/packages/core/deno.json +++ b/packages/core/deno.json @@ -3,6 +3,9 @@ "version": "0.9.0", "exports": { ".": "./mod.ts", + "./canonicalize": "./canonicalize.ts", + "./component-name": "./component-name.ts", + "./document-target": "./document-target.ts", "./host": "./host.ts" }, "imports": { 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/package.json b/packages/core/package.json index 6f80ed609..f787a04cb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,6 +5,9 @@ "type": "module", "exports": { ".": "./mod.ts", + "./canonicalize": "./canonicalize.ts", + "./component-name": "./component-name.ts", + "./document-target": "./document-target.ts", "./host": "./host.ts" }, "dependencies": { 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/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/workflow/src/cloudflare/commands.ts b/packages/workflow/src/cloudflare/commands.ts index 154e9acbd..d36a80651 100644 --- a/packages/workflow/src/cloudflare/commands.ts +++ b/packages/workflow/src/cloudflare/commands.ts @@ -14,6 +14,11 @@ * would be a runner deciding what the owner does. */ +import { + type DocumentExecutionCompletion, + parseDocumentExecutionCompletion, +} from "../storage/record.ts"; + /** The commands a runner may send. */ export type CommandName = "frontier" | "materialize" | "commit" | "settle"; @@ -23,7 +28,6 @@ export type CommandRefusal = | "unknown-command" | "unknown-member" | "malformed-member" - | "duplicate-conflict" | "too-large"; export class CommandError extends Error { @@ -69,10 +73,19 @@ export interface CommitCommand extends CommandEnvelope { readonly events: readonly string[]; } -/** Publish the run's resulting status. */ +/** + * Publish how a document execution ended, and what the run becomes. + * + * The completion is the shared provider-neutral record, parsed with the shared + * parser rather than a private approximation — the owner and the local host + * have to agree about what a completion *is*, and two readers of one shape is + * how they stop agreeing. The expected root is carried so the owner can refuse + * a settlement proposed against a frontier that has moved. + */ export interface SettleCommand extends CommandEnvelope { readonly command: "settle"; - readonly status: string; + readonly completion: DocumentExecutionCompletion; + readonly expectedWorkspaceRootId: string; } export interface ContentChunk { @@ -102,7 +115,7 @@ const MEMBERS: Record = { "proposedWorkspaceRootId", "events", ], - settle: [...ENVELOPE, "status"], + settle: [...ENVELOPE, "completion", "expectedWorkspaceRootId"], }; function object(value: unknown): Record { @@ -185,7 +198,19 @@ export function parseCommand(raw: string): RunnerCommand { return { id, command, workspaceRootId: text(members, "workspaceRootId") }; } if (command === "settle") { - return { id, command, status: text(members, "status") }; + // The shared parser decides what a completion is. Its failure becomes this + // transport's own closed refusal: the parser's message names members and + // values a request supplied, and none of that belongs on the wire. + const completion = parseDocumentExecutionCompletion(members["completion"]); + if (!completion.ok) { + throw new CommandError("malformed-member"); + } + return { + id, + command, + completion: completion.value, + expectedWorkspaceRootId: text(members, "expectedWorkspaceRootId"), + }; } const expectedJournalEventId = members["expectedJournalEventId"]; if (expectedJournalEventId !== null && typeof expectedJournalEventId !== "string") { 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/tests/cloudflare/settle-parser.vitest.ts b/packages/workflow/tests/cloudflare/settle-parser.vitest.ts new file mode 100644 index 000000000..82ff195e9 --- /dev/null +++ b/packages/workflow/tests/cloudflare/settle-parser.vitest.ts @@ -0,0 +1,96 @@ +/** + * 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"); + }); +}); diff --git a/packages/workflow/tsconfig.cloudflare.json b/packages/workflow/tsconfig.cloudflare.json index 4e35f34ec..d78979841 100644 --- a/packages/workflow/tsconfig.cloudflare.json +++ b/packages/workflow/tsconfig.cloudflare.json @@ -6,8 +6,6 @@ "lib": ["ES2022"], "types": ["@cloudflare/workers-types", "@cloudflare/vitest-plugin/types"], "strict": true, - "exactOptionalPropertyTypes": true, - "noUncheckedIndexedAccess": true, "allowImportingTsExtensions": true, "allowJs": true, "checkJs": false, From 89272a60c6d2c06728a5e00dcba932f039855b2a Mon Sep 17 00:00:00 2001 From: Min Kim Date: Wed, 2 Sep 2026 22:27:50 -0400 Subject: [PATCH 17/42] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Harden=20the=20to?= =?UTF-8?q?ken,=20own=20the=20transaction,=20type=20the=20answers=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1 items 2 through 6 from `.vscode/698/698-c-4.md`. **Token.** `verifyToken()` accepted a token with no `exp`, and a non-numeric `exp` or `nbf`, because it checked those claims only when they happened to be numbers — so omitting one was treated as satisfying it. All three of `exp`, `iat` and `nbf` are now required finite integer NumericDates. The expiration boundary is expired, per RFC 7519 §4.1.4's "before". An `iat` in the future beyond tolerance is not-yet-valid rather than accepted. The header must say `typ` is a JWT, and `kid` must name exactly one configured key: the old filter fell back to an unkeyed candidate, so an unrecognized key id still got a signature check against whatever else was configured. The token and its segments are bounded before anything is decoded, and skew is bounded at both ends — negative would reject tokens for being on time, and unbounded is indistinguishable from not checking. **Transaction ownership.** The module-global `let open` is gone. It was shared by every Durable Object in an isolate, so one object's transaction would refuse another's. The ruling suggested a `WeakSet` keyed by storage; this repository's `local/no-module-scoped-registry` forbids that too, and for a related reason. So the claim belongs to the object: `OwnerTransactions` is created and held by the owner, its lifetime is the object's, and no other object can see it. Same guarantee, no process-lifetime table. **Settle.** `{ status: string }` becomes a closed request carrying `completion: DocumentExecutionCompletion` and `expectedWorkspaceRootId`, with the completion read by the shared `parseDocumentExecutionCompletion()` — a failed parse becomes this transport's own `malformed-member` without carrying the parser's message, which names members a request supplied. **Answers.** `ask()` now takes the parser for its own success value and returns `OwnerAnswer`. `unknown` exists only at the JSON boundary; a value the command's parser cannot read fails the channel closed like any other disagreement about what completed, and a refusal is delivered without consulting the parser at all. The generic stays inside the closure the request built, so the reader settles an answer without asserting what it is. **Removed claims.** `duplicate-conflict` is gone until the mechanism that produces it exists. `perform()` stays abstract and the echo stays test-only. Every checked-then-asserted `Record` became `Object.entries` narrowing, and the one double assertion in the tests became a helper that passes `unknown` through. One documented assertion remains, in `storage.ts`, bridging two type declarations of the same runtime rows. Forty-eight workerd assertions and thirty portable ones. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/cloudflare.ts | 6 + .../workflow/src/cloudflare/acquisition.ts | 8 +- packages/workflow/src/cloudflare/admission.ts | 22 ++-- packages/workflow/src/cloudflare/commands.ts | 23 ++-- .../src/cloudflare/owner-transaction.ts | 109 ++++++++++------- packages/workflow/src/cloudflare/owner.ts | 11 +- .../workflow/src/cloudflare/recognition.ts | 10 +- packages/workflow/src/cloudflare/token.ts | 101 +++++++++++++--- packages/workflow/src/remote/client.ts | 112 ++++++++++++++---- .../cloudflare/executor-acquisition.vitest.ts | 64 ++++++++++ .../tests/cloudflare/owner-storage.vitest.ts | 28 +++++ .../cloudflare/support/executor-object.ts | 8 +- .../tests/cloudflare/support/owner-object.ts | 71 ++++++++++- packages/workflow/tests/remote-client.test.ts | 91 ++++++++++++-- .../workflow/tests/remote-transaction.test.ts | 14 ++- 15 files changed, 549 insertions(+), 129 deletions(-) diff --git a/packages/workflow/cloudflare.ts b/packages/workflow/cloudflare.ts index 270aef5c8..946e6017d 100644 --- a/packages/workflow/cloudflare.ts +++ b/packages/workflow/cloudflare.ts @@ -53,5 +53,11 @@ export type { AcquisitionAttachment, AcquisitionRefusal } from "./src/cloudflare export { CommandError } from "./src/cloudflare/commands.ts"; export type { CommandRefusal, CommandResult, RunnerCommand } from "./src/cloudflare/commands.ts"; +export { + OwnerTransactionClosedError, + OwnerTransactionNestedError, + OwnerTransactions, +} from "./src/cloudflare/owner-transaction.ts"; + export { WorkflowObjectStorageError } from "./src/cloudflare/recognition.ts"; export type { RecognitionFailure } from "./src/cloudflare/recognition.ts"; diff --git a/packages/workflow/src/cloudflare/acquisition.ts b/packages/workflow/src/cloudflare/acquisition.ts index 38601fd17..a8dda2376 100644 --- a/packages/workflow/src/cloudflare/acquisition.ts +++ b/packages/workflow/src/cloudflare/acquisition.ts @@ -60,12 +60,12 @@ function attachmentOf(socket: WebSocket): AcquisitionAttachment | undefined { if (value === null || typeof value !== "object" || Array.isArray(value)) { return undefined; } - const members = value as Record; - if (members["kind"] !== "executor") { + const members: Map = new Map(Object.entries(value)); + if (members.get("kind") !== "executor") { return undefined; } - const runId = members["runId"]; - const acquisitionId = members["acquisitionId"]; + const runId = members.get("runId"); + const acquisitionId = members.get("acquisitionId"); if (typeof runId !== "string" || typeof acquisitionId !== "string") { return undefined; } diff --git a/packages/workflow/src/cloudflare/admission.ts b/packages/workflow/src/cloudflare/admission.ts index bd6f1cfdb..53a7ee687 100644 --- a/packages/workflow/src/cloudflare/admission.ts +++ b/packages/workflow/src/cloudflare/admission.ts @@ -112,20 +112,16 @@ function admitClaims(policy: AdmissionPolicy, claims: ActionsClaims): void { } /** Read a claim set out of a verified payload. */ -function parseClaims(payload: unknown): ActionsClaims { - if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { - throw new AdmissionError("token-malformed"); - } - const members = payload as Record; +function parseClaims(payload: Map): ActionsClaims { return { - iss: members["iss"], - aud: members["aud"], - repository_id: members["repository_id"], - repository_owner_id: members["repository_owner_id"], - event_name: members["event_name"], - workflow_ref: members["workflow_ref"], - workflow_sha: members["workflow_sha"], - job_workflow_ref: members["job_workflow_ref"], + 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"), }; } diff --git a/packages/workflow/src/cloudflare/commands.ts b/packages/workflow/src/cloudflare/commands.ts index d36a80651..228e48714 100644 --- a/packages/workflow/src/cloudflare/commands.ts +++ b/packages/workflow/src/cloudflare/commands.ts @@ -118,23 +118,24 @@ const MEMBERS: Record = { settle: [...ENVELOPE, "completion", "expectedWorkspaceRootId"], }; -function object(value: unknown): Record { +function object(value: unknown): Map { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new CommandError("not-an-object"); } - return value as Record; + const members: Map = new Map(Object.entries(value)); + return members; } -function text(members: Record, key: string): string { - const value = members[key]; +function text(members: Map, key: string): string { + const value = members.get(key); if (typeof value !== "string" || value === "") { throw new CommandError("malformed-member"); } return value; } -function closed(members: Record, allowed: readonly string[]): void { - for (const key of Object.keys(members)) { +function closed(members: Map, allowed: readonly string[]): void { + for (const key of members.keys()) { if (!allowed.includes(key)) { throw new CommandError("unknown-member"); } @@ -180,7 +181,7 @@ export function parseCommand(raw: string): RunnerCommand { } const members = object(decoded); const id = text(members, "id"); - const command = members["command"]; + const command = members.get("command"); if ( command !== "frontier" && command !== "materialize" && @@ -201,7 +202,7 @@ export function parseCommand(raw: string): RunnerCommand { // The shared parser decides what a completion is. Its failure becomes this // transport's own closed refusal: the parser's message names members and // values a request supplied, and none of that belongs on the wire. - const completion = parseDocumentExecutionCompletion(members["completion"]); + const completion = parseDocumentExecutionCompletion(members.get("completion")); if (!completion.ok) { throw new CommandError("malformed-member"); } @@ -212,7 +213,7 @@ export function parseCommand(raw: string): RunnerCommand { expectedWorkspaceRootId: text(members, "expectedWorkspaceRootId"), }; } - const expectedJournalEventId = members["expectedJournalEventId"]; + const expectedJournalEventId = members.get("expectedJournalEventId"); if (expectedJournalEventId !== null && typeof expectedJournalEventId !== "string") { throw new CommandError("malformed-member"); } @@ -221,9 +222,9 @@ export function parseCommand(raw: string): RunnerCommand { command, expectedWorkspaceRootId: text(members, "expectedWorkspaceRootId"), expectedJournalEventId, - content: chunks(members["content"]), + content: chunks(members.get("content")), proposedWorkspaceRootId: text(members, "proposedWorkspaceRootId"), - events: events(members["events"]), + events: events(members.get("events")), }; } diff --git a/packages/workflow/src/cloudflare/owner-transaction.ts b/packages/workflow/src/cloudflare/owner-transaction.ts index 12cd09bde..b91a3ec7c 100644 --- a/packages/workflow/src/cloudflare/owner-transaction.ts +++ b/packages/workflow/src/cloudflare/owner-transaction.ts @@ -62,8 +62,39 @@ export interface OwnerTransaction { readonly dofs: DofsDatabase; } -/** Whether an owner transaction is currently open on this object. */ -let open = false; +/** + * 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()`. @@ -73,45 +104,41 @@ let open = false; * requires the callback to finish before it can commit, and a value that * arrived later would be applied to a transaction nobody is holding. */ -export function ownerTransaction( - storage: OwnerStorage, - body: (transaction: OwnerTransaction) => T, -): T { - if (open) { - throw new OwnerTransactionNestedError(); - } - open = true; - try { - 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. +/** + * 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); - // 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); - } - }); - } finally { - open = false; - } + } + }); } diff --git a/packages/workflow/src/cloudflare/owner.ts b/packages/workflow/src/cloudflare/owner.ts index b8b31fdd1..3f68d0a5f 100644 --- a/packages/workflow/src/cloudflare/owner.ts +++ b/packages/workflow/src/cloudflare/owner.ts @@ -22,6 +22,7 @@ import { DurableObject } from "cloudflare:workers"; import type { Operation } from "effection"; +import { OwnerTransactions } from "./owner-transaction.ts"; import { acquireExecutor, type AcquisitionAttachment, @@ -99,6 +100,14 @@ export function refusalOf(error: unknown): string { * 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. */ @@ -167,7 +176,7 @@ export abstract class WorkflowOwnerObject extends DurableObject { open(runId: string, initializeRun: () => void): void { admitRunId(runId); if (isPristine(declaredObjects(this.owned))) { - initializeObject(this.owned, initializeRun); + initializeObject(this.owned, this.transactions, initializeRun); return; } recognizeObject(this.owned); diff --git a/packages/workflow/src/cloudflare/recognition.ts b/packages/workflow/src/cloudflare/recognition.ts index 13945e85f..7ee4bd6cd 100644 --- a/packages/workflow/src/cloudflare/recognition.ts +++ b/packages/workflow/src/cloudflare/recognition.ts @@ -25,7 +25,7 @@ import { type SchemaObject, } from "../sqlite/workflow-schema.ts"; import { isSchemaMarker, MARKER_SQL, MARKER_TABLE, readMarker } from "./marker.ts"; -import { ownerTransaction } from "./owner-transaction.ts"; +import type { OwnerTransactions } from "./owner-transaction.ts"; import type { OwnerStorage } from "./storage.ts"; /** Why storage could not be read as a version-1 workflow run. */ @@ -86,7 +86,11 @@ function markerRows(storage: OwnerStorage): Record[] { * 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, initializeRun: () => void): void { +export function initializeObject( + storage: OwnerStorage, + transactions: OwnerTransactions, + initializeRun: () => void, +): void { const objects = declaredObjects(storage); if (!isPristine(objects)) { throw new WorkflowObjectStorageError({ @@ -94,7 +98,7 @@ export function initializeObject(storage: OwnerStorage, initializeRun: () => voi detail: "it already holds objects and carries no workflow schema marker", }); } - ownerTransaction(storage, ({ dofs }) => { + transactions.run(storage, ({ dofs }) => { storage.sql.exec(SCHEMA_SQL); initializeDofsSchema(dofs, () => 0); initializeRun(); diff --git a/packages/workflow/src/cloudflare/token.ts b/packages/workflow/src/cloudflare/token.ts index 96a0d8fbe..1139ebf66 100644 --- a/packages/workflow/src/cloudflare/token.ts +++ b/packages/workflow/src/cloudflare/token.ts @@ -21,11 +21,15 @@ import { type Operation, until } from "effection"; export type TokenRefusal = | "token-absent" | "token-malformed" + | "token-too-large" | "unsupported-algorithm" + | "unsupported-type" | "unknown-key" | "bad-signature" + | "malformed-claims" | "expired" - | "not-yet-valid"; + | "not-yet-valid" + | "misconfigured-clock"; export class TokenError extends Error { override name = "TokenError"; @@ -44,6 +48,26 @@ export class TokenError extends Error { */ 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 { /** @@ -54,7 +78,13 @@ export interface TokenVerification { * a JWKS is for. */ readonly keys: readonly VerificationKey[]; - /** How much clock skew to tolerate, in seconds. */ + /** + * 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; @@ -84,11 +114,12 @@ function decodeSegment(segment: string): unknown { } } -function object(value: unknown): Record { +function object(value: unknown): Map { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new TokenError("token-malformed"); } - return value as Record; + const members: Map = new Map(Object.entries(value)); + return members; } function signatureBytes(segment: string): Uint8Array { @@ -111,14 +142,24 @@ function signatureBytes(segment: string): Uint8Array { export function* verifyToken( configured: TokenVerification, token: unknown, -): Operation> { +): 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 || @@ -129,19 +170,29 @@ export function* verifyToken( } const header = object(decodeSegment(encodedHeader)); - if (header["alg"] !== SUPPORTED) { + 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); - const keyId = header["kid"]; - // A `kid` narrows which key is tried; its absence means every configured key - // is a candidate. Either way only a configured key can verify anything. - const candidates = configured.keys.filter( - (key) => typeof keyId !== "string" || key.kid === undefined || key.kid === keyId, - ); - if (candidates.length === 0) { + // 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"); } @@ -168,12 +219,28 @@ export function* verifyToken( const payload = object(decodeSegment(encodedPayload)); const now = configured.now(); - const expiry = payload["exp"]; - if (typeof expiry === "number" && now > expiry + configured.skewSeconds) { + 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"); } - const notBefore = payload["nbf"]; - if (typeof notBefore === "number" && now + configured.skewSeconds < notBefore) { + 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/remote/client.ts b/packages/workflow/src/remote/client.ts index 3f2a215d0..8a988e1f9 100644 --- a/packages/workflow/src/remote/client.ts +++ b/packages/workflow/src/remote/client.ts @@ -40,11 +40,27 @@ export class OwnerLinkError extends Error { } } -/** What the owner answered, as the client reads it. */ -export type OwnerAnswer = - | { readonly outcome: "performed"; readonly value: unknown } +/** + * 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; + /** The socket shape this client needs, so a test can supply one. */ export interface OwnerSocket { send(data: string): void; @@ -55,14 +71,29 @@ export interface OwnerSocket { /** One live connection to a run's owner. */ export interface OwnerConnection { - /** Send one command and wait for the answer that names it. */ - ask(id: string, command: Record): Operation; + /** + * 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, + ): Operation>; } /** The most bytes one answer may carry. */ const MAX_ANSWER = 8 * 1024 * 1024; -function readAnswer(raw: unknown): { id: string; answer: OwnerAnswer } { +/** 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"); } @@ -78,17 +109,17 @@ function readAnswer(raw: unknown): { id: string; answer: OwnerAnswer } { if (decoded === null || typeof decoded !== "object" || Array.isArray(decoded)) { throw new OwnerLinkError("malformed-answer"); } - const members = decoded as Record; - const id = members["id"]; - const outcome = members["outcome"]; + const members: Map = new Map(Object.entries(decoded)); + const id = members.get("id"); + const outcome = members.get("outcome"); if (typeof id !== "string") { throw new OwnerLinkError("malformed-answer"); } if (outcome === "performed") { - return { id, answer: { outcome, value: members["value"] } }; + return { id, answer: { outcome, value: members.get("value") } }; } if (outcome === "refused") { - const refusal = members["refusal"]; + const refusal = members.get("refusal"); if (typeof refusal !== "string") { throw new OwnerLinkError("malformed-answer"); } @@ -106,7 +137,18 @@ function readAnswer(raw: unknown): { id: string; answer: OwnerAnswer } { */ export function useOwnerConnection(socket: OwnerSocket): Operation { return resource(function* (provide) { - const waiting = new Map>>(); + /** + * 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` answers whether the value could be read. + */ + interface Waiter { + deliver(answer: RawAnswer): boolean; + fail(error: OwnerLinkError): void; + } + const waiting = new Map(); /** Requests already answered, so a second answer is recognized as one. */ const settled = new Set(); const messages = createSignal(); @@ -122,7 +164,7 @@ export function useOwnerConnection(socket: OwnerSocket): Operation { closed = true; for (const pending of waiting.values()) { - pending.reject(new OwnerLinkError(refusal)); + pending.fail(new OwnerLinkError(refusal)); } waiting.clear(); socket.close(); @@ -130,7 +172,7 @@ export function useOwnerConnection(socket: OwnerSocket): Operation): Operation { + *ask( + id: string, + command: Record, + parse: AnswerParser, + ): Operation> { if (closed) { throw new OwnerLinkError("closed"); } if (waiting.has(id) || settled.has(id)) { throw new OwnerLinkError("duplicate-answer"); } - const pending = withResolvers(); - waiting.set(id, pending); + const settle = withResolvers>(); + waiting.set(id, { + deliver(answer: RawAnswer): boolean { + if (answer.outcome === "refused") { + // A refusal is an answer. Nothing is parsed and nothing fails. + settle.resolve(answer); + return true; + } + let value: T; + try { + value = parse(answer.value); + } catch { + return false; + } + settle.resolve({ outcome: "performed", value }); + return true; + }, + fail(error: OwnerLinkError): void { + settle.reject(error); + }, + }); socket.send(JSON.stringify({ ...command, id })); - return yield* pending.operation; + return yield* settle.operation; }, }); }); diff --git a/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts index 70ba65b7e..1ff81d189 100644 --- a/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts +++ b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts @@ -159,6 +159,70 @@ describe("admitting an executor", () => { 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( diff --git a/packages/workflow/tests/cloudflare/owner-storage.vitest.ts b/packages/workflow/tests/cloudflare/owner-storage.vitest.ts index 414b1801c..a075305ed 100644 --- a/packages/workflow/tests/cloudflare/owner-storage.vitest.ts +++ b/packages/workflow/tests/cloudflare/owner-storage.vitest.ts @@ -120,3 +120,31 @@ describe("an owner commit", () => { }); }); }); + +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/support/executor-object.ts b/packages/workflow/tests/cloudflare/support/executor-object.ts index bf30a10aa..5ba445b3f 100644 --- a/packages/workflow/tests/cloudflare/support/executor-object.ts +++ b/packages/workflow/tests/cloudflare/support/executor-object.ts @@ -53,18 +53,22 @@ export class ExecutorObject extends WorkflowOwnerObject { */ #keys: VerificationKey[] = []; #now = 1_800_000_000; + #skew = 0; - configure(keys: VerificationKey[], now?: number): void { + 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: 60, + skewSeconds: this.#skew, now: () => this.#now, }; return { policy: POLICY, verification }; diff --git a/packages/workflow/tests/cloudflare/support/owner-object.ts b/packages/workflow/tests/cloudflare/support/owner-object.ts index 79d5a1df1..b66641914 100644 --- a/packages/workflow/tests/cloudflare/support/owner-object.ts +++ b/packages/workflow/tests/cloudflare/support/owner-object.ts @@ -15,16 +15,22 @@ import { WorkflowObjectStorageError, } from "../../../src/cloudflare/recognition.ts"; import { MARKER_TABLE } from "../../../src/cloudflare/marker.ts"; -import { ownerTransaction } from "../../../src/cloudflare/owner-transaction.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, () => { + 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, @@ -86,7 +92,7 @@ export class OwnerObject extends DurableObject { */ commitMixedChange(fail: boolean): string { try { - ownerTransaction(this.ctx.storage, ({ dofs }) => { + this.#transactions.run(this.ctx.storage, ({ dofs }) => { mkdirPath(dofs, "/published", { recursive: true }, () => 0); // oxlint-disable-next-line local/no-sync-filesystem writeFileSync( @@ -112,6 +118,46 @@ export class OwnerObject extends DurableObject { } } + /** + * 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 @@ -135,3 +181,22 @@ function describe(error: unknown): string { } 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/remote-client.test.ts b/packages/workflow/tests/remote-client.test.ts index 6a59079b8..03d5a40e2 100644 --- a/packages/workflow/tests/remote-client.test.ts +++ b/packages/workflow/tests/remote-client.test.ts @@ -13,6 +13,19 @@ import { expect } from "@executablemd/test-support/expect"; import { scoped, sleep, spawn } from "effection"; import { type OwnerSocket, OwnerLinkError, 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; +} + /** A socket a test drives by hand. */ function fakeSocket() { const sent: Record[] = []; @@ -51,7 +64,7 @@ describe("a connection to a run's owner", () => { yield* scoped(function* () { const owner = yield* useOwnerConnection(wire.socket); yield* sleep(0); - const asking = yield* spawn(() => owner.ask("a1", { command: "frontier" })); + 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" }]); @@ -66,8 +79,8 @@ describe("a connection to a run's owner", () => { yield* scoped(function* () { const owner = yield* useOwnerConnection(wire.socket); yield* sleep(0); - const first = yield* spawn(() => owner.ask("a1", { command: "frontier" })); - const second = yield* spawn(() => owner.ask("a2", { command: "settle" })); + const first = yield* spawn(() => owner.ask("a1", { command: "frontier" }, readString)); + const second = yield* spawn(() => owner.ask("a2", { command: "settle" }, readString)); // Answered in the opposite order to the asking. wire.answer({ id: "a2", outcome: "performed", value: "second" }); @@ -84,7 +97,7 @@ describe("a connection to a run's owner", () => { yield* scoped(function* () { const owner = yield* useOwnerConnection(wire.socket); yield* sleep(0); - const asking = yield* spawn(() => owner.ask("a1", { command: "commit" })); + const asking = yield* spawn(() => owner.ask("a1", { command: "commit" }, readString)); wire.answer({ id: "a1", outcome: "refused", refusal: "acquisition:already-running" }); expect(yield* asking).toEqual({ outcome: "refused", @@ -102,7 +115,7 @@ describe("a connection to a run's owner", () => { yield* sleep(0); const asking = yield* spawn(function* () { try { - yield* owner.ask("a1", { command: "frontier" }); + yield* owner.ask("a1", { command: "frontier" }, readString); } catch (error) { raised = error; } @@ -123,7 +136,7 @@ describe("a connection to a run's owner", () => { yield* sleep(0); wire.end(); try { - yield* owner.ask("a1", { command: "frontier" }); + yield* owner.ask("a1", { command: "frontier" }, readString); } catch (error) { raised = error; } @@ -137,10 +150,10 @@ describe("a connection to a run's owner", () => { yield* scoped(function* () { const owner = yield* useOwnerConnection(wire.socket); yield* sleep(0); - yield* spawn(() => owner.ask("a1", { command: "frontier" })); + yield* spawn(() => owner.ask("a1", { command: "frontier" }, readString)); yield* sleep(0); try { - yield* owner.ask("a1", { command: "settle" }); + yield* owner.ask("a1", { command: "settle" }, readString); } catch (error) { raised = error; } @@ -157,14 +170,14 @@ describe("a connection to a run's owner", () => { yield* sleep(0); const first = yield* spawn(function* () { try { - yield* owner.ask("a1", { command: "frontier" }); + yield* owner.ask("a1", { command: "frontier" }, readString); } catch (error) { raised.push(error); } }); const second = yield* spawn(function* () { try { - yield* owner.ask("a2", { command: "settle" }); + yield* owner.ask("a2", { command: "settle" }, readString); } catch (error) { raised.push(error); } @@ -190,7 +203,7 @@ describe("a connection to a run's owner", () => { yield* sleep(0); const asking = yield* spawn(function* () { try { - yield* owner.ask("a1", { command: "frontier" }); + yield* owner.ask("a1", { command: "frontier" }, readString); } catch (error) { raised = error; } @@ -202,6 +215,58 @@ describe("a connection to a run's owner", () => { expect((raised as OwnerLinkError).refusal).toBe("unknown-answer"); }); + it("fails every waiter when a success value cannot be parsed", 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); + for (const error of raised) { + expect((error as OwnerLinkError).refusal).toBe("malformed-answer"); + } + }); + + 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("fails closed on a second answer to a request already settled", function* () { const wire = fakeSocket(); let answered: unknown; @@ -209,14 +274,14 @@ describe("a connection to a run's owner", () => { yield* scoped(function* () { const owner = yield* useOwnerConnection(wire.socket); yield* sleep(0); - const first = yield* spawn(() => owner.ask("a1", { command: "frontier" })); + 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" }); + yield* owner.ask("a2", { command: "settle" }, readString); } catch (error) { refused = error; } diff --git a/packages/workflow/tests/remote-transaction.test.ts b/packages/workflow/tests/remote-transaction.test.ts index a86f97795..cbc11ebd4 100644 --- a/packages/workflow/tests/remote-transaction.test.ts +++ b/packages/workflow/tests/remote-transaction.test.ts @@ -40,6 +40,18 @@ function nameOf(entry: DurableEvent | undefined): string { return typeof name === "string" ? name : ""; } +/** + * A value the collector should refuse, handed over as an event. + * + * Through `unknown` rather than a double assertion: the point of the test is + * that the collector parses what it is given, and manufacturing a value that + * claims to be an event would be asserting the thing under test. + */ +function malformed(value: unknown): DurableEvent { + const offered: unknown = value; + return offered as DurableEvent; +} + /** Rename a test event in place, to prove the collector cloned it. */ function rename(entry: DurableEvent, name: string): void { if (!("description" in entry)) { @@ -345,7 +357,7 @@ describe("a remote transaction", () => { let raised: unknown; try { yield* transactRemotely(owner, gate, function* (transaction) { - yield* transaction.journal.append({ nothing: true } as unknown as DurableEvent); + yield* transaction.journal.append(malformed({ nothing: true })); return undefined; }); } catch (error) { From 026ef1f96c4d895e1929adb02131e578452c75ed Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 07:36:48 -0400 Subject: [PATCH 18/42] =?UTF-8?q?=F0=9F=94=8E=20Bound=20the=20answer=20env?= =?UTF-8?q?elope=20and=20prove=20teardown=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing two gaps in D1 item 5 that I found auditing my own work against the list, rather than leaving them for review. The refusal was checked to be a string and nothing more, so an arbitrary remote sentence became this side's public failure identity — and a refusal is something a caller branches on. It is now held to the category shape the owner actually produces, and the correlation id is bounded. Anything else is an answer this build cannot read, which fails the channel closed like every other disagreement about what completed. Item 5 also asked for cancellation cleanup evidence and there was none. The test asserts what is observable rather than what I first assumed: Effection halts a cancelled task instead of raising into it, so the proof is that the scope completes at all with a request in flight — a waiter nothing settled would hang teardown — and that a late answer afterwards reaches nothing and raises nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/remote/client.ts | 18 ++++- packages/workflow/tests/remote-client.test.ts | 66 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/workflow/src/remote/client.ts b/packages/workflow/src/remote/client.ts index 8a988e1f9..c5557352d 100644 --- a/packages/workflow/src/remote/client.ts +++ b/packages/workflow/src/remote/client.ts @@ -88,6 +88,20 @@ export interface OwnerConnection { /** The most bytes one answer may carry. */ const MAX_ANSWER = 8 * 1024 * 1024; +/** The longest correlation id this reads back. */ +const MAX_ID = 128; + +/** + * The shape a refusal category has. + * + * The owner answers with a category and an optional detail, both drawn from + * closed sets it declares. Holding the answer to that shape is what stops an + * arbitrary remote string becoming this side's public failure identity: a + * refusal is something a caller may branch on, so it must not be whatever the + * other end felt like sending. + */ +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 } @@ -112,7 +126,7 @@ function readAnswer(raw: unknown): { id: string; answer: RawAnswer } { const members: Map = new Map(Object.entries(decoded)); const id = members.get("id"); const outcome = members.get("outcome"); - if (typeof id !== "string") { + if (typeof id !== "string" || id === "" || id.length > MAX_ID) { throw new OwnerLinkError("malformed-answer"); } if (outcome === "performed") { @@ -120,7 +134,7 @@ function readAnswer(raw: unknown): { id: string; answer: RawAnswer } { } if (outcome === "refused") { const refusal = members.get("refusal"); - if (typeof refusal !== "string") { + if (typeof refusal !== "string" || !REFUSAL.test(refusal)) { throw new OwnerLinkError("malformed-answer"); } return { id, answer: { outcome, refusal } }; diff --git a/packages/workflow/tests/remote-client.test.ts b/packages/workflow/tests/remote-client.test.ts index 03d5a40e2..fe3cdb8a5 100644 --- a/packages/workflow/tests/remote-client.test.ts +++ b/packages/workflow/tests/remote-client.test.ts @@ -267,6 +267,72 @@ describe("a connection to a run's owner", () => { }); }); + 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((raised as OwnerLinkError).refusal).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((raised as OwnerLinkError).refusal).toBe("malformed-answer"); + }); + + it("leaves no waiter or listener behind when its scope ends", function* () { + const wire = fakeSocket(); + let answered = false; + // The connection's scope ends with a request still in flight. Cancellation + // halts the asking task rather than raising into it, so what is observable + // is that the scope completes at all — a waiter nothing settled would hang + // teardown — and that nothing is left listening afterwards. + yield* scoped(function* () { + const owner = yield* useOwnerConnection(wire.socket); + yield* sleep(0); + yield* spawn(function* () { + yield* owner.ask("a1", { command: "frontier" }, readString); + answered = true; + }); + yield* sleep(0); + }); + + expect(answered).toBe(false); + // A late answer reaches nothing: the listener went with the scope, and + // delivering it must not raise out of the socket either. + wire.answer({ id: "a1", outcome: "performed", value: "too late" }); + expect(answered).toBe(false); + }); + it("fails closed on a second answer to a request already settled", function* () { const wire = fakeSocket(); let answered: unknown; From febef13f13bc57773c1ff3a71cda3f42d459bda0 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 08:15:12 -0400 Subject: [PATCH 19/42] =?UTF-8?q?=F0=9F=94=8C=20Make=20the=20connection=20?= =?UTF-8?q?release=20the=20acquisition=20it=20holds=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner's socket is the executor acquisition, and D1 left it behind. The resource installed listeners and never removed them, never closed the socket, and never observed `error`, so a scope that ended — normally, by failure, or by cancellation — left the owner looking at a healthy socket that no longer had anybody on the other end. With no lease and no heartbeat by design, that made the run unadvanceable by anybody, forever. Teardown is now one idempotent operation with one owner. Scope exit, cancellation, a remote close, a socket error, a protocol failure, a command-specific parse failure and a failed send all reach it; it runs once, removes the exact callbacks it installed, and closes the socket once. The finalizer is registered before anything can suspend. Answers are now read where they arrive rather than through a signal the reader drained later. That ordering is the point: a close arriving in the same turn as an unreadable answer used to reach teardown first and tell the caller `closed` for something that was actually `malformed-answer`. What went wrong is decided where it is observed. The envelope is closed for real: each outcome declares its whole key set, so a performed answer carrying a `refusal`, a refused one carrying a `value`, a missing member and an unknown member are all refused. An outgoing correlation id is held to the contract an incoming one is held to, and refusal text gets its own small bound. The regular expression proves spelling, and the comment now says so — narrowing to the declared union stays the adapter's job. A retained Workspace root is a content identity, so every command that names one parses it as 64 lowercase hexadecimal characters rather than as any non-empty text. The transaction machinery is no longer exported from the Cloudflare subpath. It stays private to `src/cloudflare/**`, owned by the object. The old teardown test asserted nothing: its fake retained the message callback, so the connection could leak both listeners and the socket and still pass. The fake now counts closes and live listeners, and the evidence is what those counters say. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/cloudflare.ts | 6 - packages/workflow/src/cloudflare/commands.ts | 26 +- packages/workflow/src/remote/client.ts | 201 ++++++++---- .../cloudflare/executor-acquisition.vitest.ts | 4 +- .../tests/cloudflare/settle-parser.vitest.ts | 62 +++- packages/workflow/tests/remote-client.test.ts | 295 ++++++++++++++++-- .../workflow/tests/remote-transaction.test.ts | 54 ++-- 7 files changed, 526 insertions(+), 122 deletions(-) diff --git a/packages/workflow/cloudflare.ts b/packages/workflow/cloudflare.ts index 946e6017d..270aef5c8 100644 --- a/packages/workflow/cloudflare.ts +++ b/packages/workflow/cloudflare.ts @@ -53,11 +53,5 @@ export type { AcquisitionAttachment, AcquisitionRefusal } from "./src/cloudflare export { CommandError } from "./src/cloudflare/commands.ts"; export type { CommandRefusal, CommandResult, RunnerCommand } from "./src/cloudflare/commands.ts"; -export { - OwnerTransactionClosedError, - OwnerTransactionNestedError, - OwnerTransactions, -} from "./src/cloudflare/owner-transaction.ts"; - export { WorkflowObjectStorageError } from "./src/cloudflare/recognition.ts"; export type { RecognitionFailure } from "./src/cloudflare/recognition.ts"; diff --git a/packages/workflow/src/cloudflare/commands.ts b/packages/workflow/src/cloudflare/commands.ts index 228e48714..9b50c8175 100644 --- a/packages/workflow/src/cloudflare/commands.ts +++ b/packages/workflow/src/cloudflare/commands.ts @@ -126,6 +126,24 @@ function object(value: unknown): Map { return members; } +/** + * A retained Workspace root, as the schema spells one. + * + * The lowercase SHA-256 of a canonical manifest: 64 hexadecimal characters and + * nothing else. Admitting any non-empty string would let three commands hold + * three different notions of what a root is, and the owner would be the first + * to find out. + */ +const ROOT_ID = /^[0-9a-f]{64}$/; + +function rootId(members: Map, key: string): string { + const value = text(members, key); + if (!ROOT_ID.test(value)) { + throw new CommandError("malformed-member"); + } + return value; +} + function text(members: Map, key: string): string { const value = members.get(key); if (typeof value !== "string" || value === "") { @@ -196,7 +214,7 @@ export function parseCommand(raw: string): RunnerCommand { return { id, command }; } if (command === "materialize") { - return { id, command, workspaceRootId: text(members, "workspaceRootId") }; + return { id, command, workspaceRootId: rootId(members, "workspaceRootId") }; } if (command === "settle") { // The shared parser decides what a completion is. Its failure becomes this @@ -210,7 +228,7 @@ export function parseCommand(raw: string): RunnerCommand { id, command, completion: completion.value, - expectedWorkspaceRootId: text(members, "expectedWorkspaceRootId"), + expectedWorkspaceRootId: rootId(members, "expectedWorkspaceRootId"), }; } const expectedJournalEventId = members.get("expectedJournalEventId"); @@ -220,10 +238,10 @@ export function parseCommand(raw: string): RunnerCommand { return { id, command, - expectedWorkspaceRootId: text(members, "expectedWorkspaceRootId"), + expectedWorkspaceRootId: rootId(members, "expectedWorkspaceRootId"), expectedJournalEventId, content: chunks(members.get("content")), - proposedWorkspaceRootId: text(members, "proposedWorkspaceRootId"), + proposedWorkspaceRootId: rootId(members, "proposedWorkspaceRootId"), events: events(members.get("events")), }; } diff --git a/packages/workflow/src/remote/client.ts b/packages/workflow/src/remote/client.ts index c5557352d..48879ae97 100644 --- a/packages/workflow/src/remote/client.ts +++ b/packages/workflow/src/remote/client.ts @@ -22,7 +22,7 @@ * dropping the answer and leaving somebody blocked forever. */ -import { createSignal, each, type Operation, resource, spawn, withResolvers } from "effection"; +import { ensure, type Operation, resource, withResolvers } from "effection"; /** Why the connection itself could not carry a request. */ export type LinkRefusal = @@ -30,7 +30,10 @@ export type LinkRefusal = | "malformed-answer" | "unknown-answer" | "duplicate-answer" - | "too-large"; + | "too-large" + | "malformed-request" + | "send-failed" + | "socket-error"; export class OwnerLinkError extends Error { override name = "OwnerLinkError"; @@ -61,12 +64,15 @@ export type OwnerAnswer = */ 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", listener: (event: { data: unknown }) => void): void; - addEventListener(type: "close", listener: () => void): 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. */ @@ -88,17 +94,31 @@ export interface OwnerConnection { /** The most bytes one answer may carry. */ const MAX_ANSWER = 8 * 1024 * 1024; -/** The longest correlation id this reads back. */ +/** 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, both drawn from - * closed sets it declares. Holding the answer to that shape is what stops an - * arbitrary remote string becoming this side's public failure identity: a - * refusal is something a caller may branch on, so it must not be whatever the - * other end felt like sending. + * 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-]*)?$/; @@ -126,15 +146,31 @@ function readAnswer(raw: unknown): { id: string; answer: RawAnswer } { const members: Map = new Map(Object.entries(decoded)); const id = members.get("id"); const outcome = members.get("outcome"); - if (typeof id !== "string" || id === "" || id.length > MAX_ID) { + 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.test(refusal)) { + if (typeof refusal !== "string" || refusal.length > MAX_REFUSAL || !REFUSAL.test(refusal)) { throw new OwnerLinkError("malformed-answer"); } return { id, answer: { outcome, refusal } }; @@ -145,9 +181,17 @@ function readAnswer(raw: unknown): { id: string; answer: RawAnswer } { /** * Hold one connection open for the calling scope. * - * Teardown resolves every request still waiting with a closed refusal rather - * than leaving it pending: a caller blocked on an answer that can never arrive - * would outlive the connection it was asking through. + * 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) { @@ -165,60 +209,85 @@ export function useOwnerConnection(socket: OwnerSocket): Operation(); /** Requests already answered, so a second answer is recognized as one. */ const settled = new Set(); - const messages = createSignal(); let closed = false; + let torn = false; - socket.addEventListener("message", (event) => messages.send(event.data)); - socket.addEventListener("close", () => { - closed = true; - messages.close(); - }); - - /** Stop the channel and tell everyone waiting why. */ - const fail = (refusal: LinkRefusal) => { - closed = true; - for (const pending of waiting.values()) { - pending.fail(new OwnerLinkError(refusal)); + /** + * 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)) { + // 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. + waiting.set(read.id, pending); + teardown("malformed-answer"); } - waiting.clear(); - socket.close(); }; + const onClose: SocketListener = () => teardown("closed"); + const onError: SocketListener = () => teardown("socket-error"); - yield* spawn(function* () { - for (const raw of yield* each(messages)) { - let read: { id: string; answer: RawAnswer } | undefined; - try { - read = readAnswer(raw); - } catch { - // The owner said something this build cannot read. Whether it was - // meant for a waiter is exactly what cannot be established. - fail("malformed-answer"); - break; - } - 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. - fail(settled.has(read.id) ? "duplicate-answer" : "unknown-answer"); - break; - } - waiting.delete(read.id); - settled.add(read.id); - if (!pending.deliver(read.answer)) { - // 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. - waiting.set(read.id, pending); - fail("malformed-answer"); - break; - } - yield* each.next(); + /** + * 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("closed")); + 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({ @@ -230,6 +299,11 @@ export function useOwnerConnection(socket: OwnerSocket): Operation { const raw = JSON.stringify({ id: "7", command: "commit", - expectedWorkspaceRootId: "root-a", + expectedWorkspaceRootId: `a${"0".repeat(63)}`, expectedJournalEventId: null, content: [{ digest: "d1", bytes: "AAAA" }], - proposedWorkspaceRootId: "root-b", + proposedWorkspaceRootId: `b${"1".repeat(63)}`, events: ["event-1"], }); expect(await on(stub, (o) => o.send(1, raw))).toEqual({ diff --git a/packages/workflow/tests/cloudflare/settle-parser.vitest.ts b/packages/workflow/tests/cloudflare/settle-parser.vitest.ts index 82ff195e9..2a26c5dac 100644 --- a/packages/workflow/tests/cloudflare/settle-parser.vitest.ts +++ b/packages/workflow/tests/cloudflare/settle-parser.vitest.ts @@ -88,9 +88,69 @@ describe("a settle command", () => { 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> = { + "materialize.workspaceRootId": (root) => + JSON.stringify({ id: "m1", command: "materialize", workspaceRootId: root }), + "commit.expectedWorkspaceRootId": (root) => commit({ expectedWorkspaceRootId: root }), + "commit.proposedWorkspaceRootId": (root) => commit({ proposedWorkspaceRootId: root }), + "settle.expectedWorkspaceRootId": (root) => settle({ expectedWorkspaceRootId: root }), + }; + + function commit(overrides: Record): string { + return JSON.stringify({ + id: "c1", + command: "commit", + expectedWorkspaceRootId: ROOT, + expectedJournalEventId: null, + content: [], + proposedWorkspaceRootId: ROOT, + events: [], + ...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/remote-client.test.ts b/packages/workflow/tests/remote-client.test.ts index fe3cdb8a5..b9711a6de 100644 --- a/packages/workflow/tests/remote-client.test.ts +++ b/packages/workflow/tests/remote-client.test.ts @@ -11,7 +11,12 @@ 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, useOwnerConnection } from "../src/remote/client.ts"; +import { + type OwnerSocket, + OwnerLinkError, + type SocketListener, + useOwnerConnection, +} from "../src/remote/client.ts"; /** These tests are about correlation, so most of them read any value. */ function readString(value: unknown): unknown { @@ -26,34 +31,79 @@ function requireString(value: unknown): string { return value; } -/** A socket a test drives by hand. */ -function fakeSocket() { +/** + * 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[] = []; - let onMessage: ((event: { data: unknown }) => void) | undefined; - let onClose: (() => void) | undefined; + 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 { - onClose?.(); + closes += 1; }, - addEventListener(type: "message" | "close", listener: never): void { - if (type === "message") { - onMessage = listener; - } else { - onClose = listener; - } + 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 { - onMessage?.({ data: typeof value === "string" ? value : JSON.stringify(value) }); + deliver("message", { + data: typeof value === "string" ? value : JSON.stringify(value), + }); }, end(): void { - onClose?.(); + deliver("close", {}); + }, + error(): void { + deliver("error", {}); }, }; } @@ -81,6 +131,9 @@ describe("a connection to a run's owner", () => { 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" }); @@ -98,6 +151,7 @@ describe("a connection to a run's owner", () => { 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", @@ -125,7 +179,7 @@ describe("a connection to a run's owner", () => { }); yield* sleep(0); expect(raised).toBeInstanceOf(OwnerLinkError); - expect((raised as OwnerLinkError).refusal).toBe("closed"); + expect(refusalOf(raised)).toBe("closed"); }); it("refuses to ask through a connection that already ended", function* () { @@ -141,7 +195,7 @@ describe("a connection to a run's owner", () => { raised = error; } }); - expect((raised as OwnerLinkError).refusal).toBe("closed"); + expect(refusalOf(raised)).toBe("closed"); }); it("refuses a second request under an id already in flight", function* () { @@ -159,7 +213,7 @@ describe("a connection to a run's owner", () => { } wire.answer({ id: "a1", outcome: "performed", value: null }); }); - expect((raised as OwnerLinkError).refusal).toBe("duplicate-answer"); + expect(refusalOf(raised)).toBe("duplicate-answer"); }); it("fails every waiter when it cannot read an answer", function* () { @@ -191,8 +245,10 @@ describe("a connection to a run's owner", () => { }); expect(raised).toHaveLength(2); for (const error of raised) { - expect((error as OwnerLinkError).refusal).toBe("malformed-answer"); + expect(refusalOf(error)).toBe("malformed-answer"); } + expect(wire.closes).toBe(1); + expect(wire.listening).toBe(0); }); it("fails closed on an answer naming a request nobody made", function* () { @@ -212,7 +268,7 @@ describe("a connection to a run's owner", () => { wire.answer({ id: "somebody-else", outcome: "performed", value: 1 }); yield* asking; }); - expect((raised as OwnerLinkError).refusal).toBe("unknown-answer"); + expect(refusalOf(raised)).toBe("unknown-answer"); }); it("fails every waiter when a success value cannot be parsed", function* () { @@ -244,8 +300,10 @@ describe("a connection to a run's owner", () => { }); expect(raised).toHaveLength(2); for (const error of raised) { - expect((error as OwnerLinkError).refusal).toBe("malformed-answer"); + expect(refusalOf(error)).toBe("malformed-answer"); } + expect(wire.closes).toBe(1); + expect(wire.listening).toBe(0); }); it("still delivers a refusal without consulting the success parser", function* () { @@ -286,7 +344,7 @@ describe("a connection to a run's owner", () => { wire.answer({ id: "a1", outcome: "refused", refusal: "something went wrong!" }); yield* asking; }); - expect((raised as OwnerLinkError).refusal).toBe("malformed-answer"); + expect(refusalOf(raised)).toBe("malformed-answer"); }); it("refuses an answer whose correlation id is not one", function* () { @@ -306,31 +364,206 @@ describe("a connection to a run's owner", () => { wire.answer({ id: "x".repeat(200), outcome: "performed", value: 1 }); yield* asking; }); - expect((raised as OwnerLinkError).refusal).toBe("malformed-answer"); + expect(refusalOf(raised)).toBe("malformed-answer"); }); - it("leaves no waiter or listener behind when its scope ends", function* () { + it("closes the socket once and stops listening when its scope ends", function* () { const wire = fakeSocket(); let answered = false; - // The connection's scope ends with a request still in flight. Cancellation - // halts the asking task rather than raising into it, so what is observable - // is that the scope completes at all — a waiter nothing settled would hang - // teardown — and that nothing is left listening afterwards. 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 answer reaches nothing: the listener went with the scope, and - // delivering it must not raise out of the socket either. + + // 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 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* () { @@ -358,6 +591,8 @@ describe("a connection to a run's owner", () => { yield* second; }); expect(answered).toEqual({ outcome: "performed", value: "once" }); - expect((refused as OwnerLinkError).refusal).toBe("duplicate-answer"); + expect(refusalOf(refused)).toBe("duplicate-answer"); + expect(wire.closes).toBe(1); + expect(wire.listening).toBe(0); }); }); diff --git a/packages/workflow/tests/remote-transaction.test.ts b/packages/workflow/tests/remote-transaction.test.ts index cbc11ebd4..5d5a2d794 100644 --- a/packages/workflow/tests/remote-transaction.test.ts +++ b/packages/workflow/tests/remote-transaction.test.ts @@ -16,7 +16,7 @@ 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 } from "@executablemd/durable-streams"; +import type { DurableEvent, DurableStream } from "@executablemd/durable-streams"; import { type CommitIntent, createTransactionGate, @@ -27,6 +27,19 @@ import { transactRemotely, } from "../src/remote/collector.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)) { @@ -36,20 +49,23 @@ function nameOf(entry: DurableEvent | undefined): string { if (description === null || typeof description !== "object") { return ""; } - const name = (description as Record)["name"]; + if (!("name" in description)) { + return ""; + } + const name: unknown = description["name"]; return typeof name === "string" ? name : ""; } /** - * A value the collector should refuse, handed over as an event. + * The journal as an untrusted caller reaches it. * - * Through `unknown` rather than a double assertion: the point of the test is - * that the collector parses what it is given, and manufacturing a value that - * claims to be an event would be asserting the thing under test. + * 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 malformed(value: unknown): DurableEvent { - const offered: unknown = value; - return offered as DurableEvent; +function offering(journal: DurableStream): { append(event: unknown): Operation } { + return journal; } /** Rename a test event in place, to prove the collector cloned it. */ @@ -59,7 +75,7 @@ function rename(entry: DurableEvent, name: string): void { } const description = entry.description; if (description !== null && typeof description === "object") { - (description as Record)["name"] = name; + Object.assign(description, { name }); } } @@ -69,7 +85,7 @@ function event(name: string): DurableEvent { coroutineId: "root", description: { type: "test", name }, result: { status: "ok", value: name }, - } as DurableEvent; + }; } /** A link that records what it was asked, and answers how a test tells it to. */ @@ -207,7 +223,7 @@ describe("a remote transaction", () => { } expect(raised).toBeInstanceOf(RemoteTransactionError); - expect((raised as RemoteTransactionError).refusal).toBe("nested-transaction"); + expect(refusalOf(raised)).toBe("nested-transaction"); expect(sent).toEqual([]); }); @@ -226,7 +242,7 @@ describe("a remote transaction", () => { }); expect(raised).toBeInstanceOf(RemoteTransactionError); - expect((raised as RemoteTransactionError).refusal).toBe("operation-inside-body"); + expect(refusalOf(raised)).toBe("operation-inside-body"); // And the gate is closed again afterwards, so the next operation is fine. requireNoOpenTransaction(gate); }); @@ -247,7 +263,7 @@ describe("a remote transaction", () => { } catch (error) { raised = error; } - expect((raised as RemoteTransactionError).refusal).toBe("transaction-closed"); + expect(refusalOf(raised)).toBe("transaction-closed"); }); it("owns the handle from before the first suspension until after the commit", function* () { @@ -272,7 +288,7 @@ describe("a remote transaction", () => { } catch (error) { raised = error; } - expect((raised as RemoteTransactionError).refusal).toBe("nested-transaction"); + expect(refusalOf(raised)).toBe("nested-transaction"); held.resolve(); yield* first; @@ -300,7 +316,7 @@ describe("a remote transaction", () => { } catch (error) { raised = error; } - expect((raised as RemoteTransactionError).refusal).toBe("operation-inside-body"); + expect(refusalOf(raised)).toBe("operation-inside-body"); held.resolve(); yield* first; @@ -357,13 +373,13 @@ describe("a remote transaction", () => { let raised: unknown; try { yield* transactRemotely(owner, gate, function* (transaction) { - yield* transaction.journal.append(malformed({ nothing: true })); + yield* offering(transaction.journal).append({ nothing: true }); return undefined; }); } catch (error) { raised = error; } - expect((raised as RemoteTransactionError).refusal).toBe("malformed-event"); + expect(refusalOf(raised)).toBe("malformed-event"); expect(sent).toEqual([]); }); @@ -383,7 +399,7 @@ describe("a remote transaction", () => { } catch (error) { raised = error; } - expect((raised as RemoteTransactionError).refusal).toBe("events-too-large"); + expect(refusalOf(raised)).toBe("events-too-large"); expect(sent).toEqual([]); }); From b69ab73e445480652c162587ae8a84d8bad6d4ff Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 15:00:03 -0400 Subject: [PATCH 20/42] =?UTF-8?q?=F0=9F=93=96=20Read=20a=20run=20from=20th?= =?UTF-8?q?e=20owner=20that=20holds=20it=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An admitted connection could prove who it was and ask nothing. This gives it the reads a runner actually needs — the committed frontier, one retained Workspace root, and the content that root names — and the private mechanics those reads depend on. Reads are bounded and coherent. One frontier request returns the parsed run record, the current canonical root and the last journal event that existed at that moment; the journal itself comes back in anchored pages, so a journal larger than one message is still one snapshot and later appends cannot enter an earlier one. Each page carries its own predecessor, and the runner reassembles them by checking rather than trusting: a page that skips, repeats, reorders or ends in the wrong place closes the connection before one event reaches a caller, because half a journal that looks whole is worse than none. A root comes back as its canonical manifest, then one referenced piece at a time. The owner proves the manifest is canonically encoded, that its identity is the digest of its own bytes, that the retained references are exactly the ones the entries name, and that every piece it sends is referenced by that root and hashes to the digest it is asked for. The runner proves it all again on arrival. The owner being honest is not evidence about the wire, and content that is not what it is named must never become a materialization. A runner that hears no answer cannot tell a lost question from a lost answer, so it asks again. That is only safe if asking twice is asking once. Every command is now decided once per acquisition and its decision retained beside it. Two requests are the same request when their parsed commands are equal, so member order does not make a retry into a new command and a changed value does: reusing an id for something else is refused as a conflict rather than answered. Reads whose answers are fixed by immutable state and an anchor the request already carries are remembered as a decision to read again; the frontier is kept whole, because it is the one read whose answer would otherwise move, and a retry that returned a later frontier would hand back a snapshot nobody asked for. The ledger never evicts while the acquisition lives — dropping an id would make a retry look new, which for a mutation is the difference between doing something once and twice — so a full ledger refuses and fails closed. Content a runner offers is staged, and staging is not publication. It is digest-checked, bounded per piece and in aggregate, stored detached, keyed by the acquisition that offered it, and visible to no retained read. Adopting it is a later checkpoint's transaction. Both private tables live in SQLite because an evicted object remembers nothing and the attachment is 16 KiB of identity, not somewhere to grow a ledger; a replacement acquisition discards its predecessor's scratch before it accepts, and touches no retained history doing it. The rules a root is held to now live in one place instead of two. Both hosts retain the same roots and must name them identically, so the manifest format, its canonical encoding and the stored-row parsers moved beside the schema they belong to, and the Deno host reads through the same implementation it always described. The digest is arithmetic in the language: a shared module cannot import a host's crypto, and the one place this is needed most is inside a synchronous transaction, where there is nothing to await into. `commit` and `settle` parse strictly and refuse. Applying them is D3 and D4, and a placeholder that reported success would be the one answer a runner cannot recover from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/cloudflare.ts | 13 - .../workflow/src/cloudflare/acquisition.ts | 16 +- packages/workflow/src/cloudflare/client.ts | 401 +++++++++++++ packages/workflow/src/cloudflare/commands.ts | 272 +++++---- .../workflow/src/cloudflare/dispatcher.ts | 269 +++++++++ packages/workflow/src/cloudflare/encoding.ts | 57 ++ .../workflow/src/cloudflare/owner-reads.ts | 356 ++++++++++++ packages/workflow/src/cloudflare/owner.ts | 63 +- .../workflow/src/cloudflare/private-schema.ts | 85 +++ .../workflow/src/cloudflare/recognition.ts | 23 +- .../workflow/src/deno/artifact-frontier.ts | 2 +- packages/workflow/src/deno/database.ts | 2 +- packages/workflow/src/deno/lifecycle.ts | 2 +- packages/workflow/src/deno/transitions.ts | 2 +- .../workflow/src/deno/workspace/manifest.ts | 221 ++----- packages/workflow/src/deno/workspace/root.ts | 53 +- packages/workflow/src/remote/client.ts | 11 +- packages/workflow/src/remote/read.ts | 57 ++ packages/workflow/src/remote/records.ts | 143 +++++ .../workflow/src/{deno => sqlite}/rows.ts | 6 + .../workflow/src/workspace/root-manifest.ts | 425 ++++++++++++++ packages/workflow/src/workspace/sha256.ts | 119 ++++ .../cloudflare/executor-acquisition.vitest.ts | 34 +- .../tests/cloudflare/owner-storage.vitest.ts | 7 + .../tests/cloudflare/remote-owner.vitest.ts | 540 ++++++++++++++++++ .../tests/cloudflare/settle-parser.vitest.ts | 14 +- .../cloudflare/support/executor-object.ts | 232 +++++++- .../workflow/tests/host-neutrality.test.ts | 9 + .../workflow/tests/public-entrypoint.test.ts | 17 + packages/workflow/tests/remote-read.test.ts | 429 ++++++++++++++ .../workflow/tests/workflow-export.test.ts | 2 +- 31 files changed, 3493 insertions(+), 389 deletions(-) create mode 100644 packages/workflow/src/cloudflare/client.ts create mode 100644 packages/workflow/src/cloudflare/dispatcher.ts create mode 100644 packages/workflow/src/cloudflare/encoding.ts create mode 100644 packages/workflow/src/cloudflare/owner-reads.ts create mode 100644 packages/workflow/src/cloudflare/private-schema.ts create mode 100644 packages/workflow/src/remote/read.ts create mode 100644 packages/workflow/src/remote/records.ts rename packages/workflow/src/{deno => sqlite}/rows.ts (96%) create mode 100644 packages/workflow/src/workspace/root-manifest.ts create mode 100644 packages/workflow/src/workspace/sha256.ts create mode 100644 packages/workflow/tests/cloudflare/remote-owner.vitest.ts create mode 100644 packages/workflow/tests/remote-read.test.ts diff --git a/packages/workflow/cloudflare.ts b/packages/workflow/cloudflare.ts index 270aef5c8..3b2f079c0 100644 --- a/packages/workflow/cloudflare.ts +++ b/packages/workflow/cloudflare.ts @@ -19,9 +19,6 @@ * protected configuration() { * return { policy: POLICY }; * } - * protected perform(socket, runId, command) { - * // … - * } * } * ``` * @@ -43,15 +40,5 @@ 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 { - AcquisitionError, - acquisitionHolders, - EXECUTOR_TAG, -} from "./src/cloudflare/acquisition.ts"; -export type { AcquisitionAttachment, AcquisitionRefusal } from "./src/cloudflare/acquisition.ts"; - -export { CommandError } from "./src/cloudflare/commands.ts"; -export type { CommandRefusal, CommandResult, RunnerCommand } from "./src/cloudflare/commands.ts"; - export { WorkflowObjectStorageError } from "./src/cloudflare/recognition.ts"; export type { RecognitionFailure } from "./src/cloudflare/recognition.ts"; diff --git a/packages/workflow/src/cloudflare/acquisition.ts b/packages/workflow/src/cloudflare/acquisition.ts index a8dda2376..c1cd862ec 100644 --- a/packages/workflow/src/cloudflare/acquisition.ts +++ b/packages/workflow/src/cloudflare/acquisition.ts @@ -105,10 +105,12 @@ export function acquireExecutor( 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 @@ -129,6 +131,17 @@ 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) { @@ -143,9 +156,6 @@ export function requireAcquisition( // Two live holders is a state this module refuses to choose between. throw new AcquisitionError("already-running"); } - if (mine.held.runId !== runId) { - throw new AcquisitionError("wrong-run"); - } return mine.held; } diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts new file mode 100644 index 000000000..2cf8ab4e0 --- /dev/null +++ b/packages/workflow/src/cloudflare/client.ts @@ -0,0 +1,401 @@ +/** + * 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, type Operation, type Result } from "effection"; +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 { OwnerAnswer, OwnerConnection } from "../remote/client.ts"; +import { + parseRemoteJournalEntry, + parseRemoteRetrieval, + parseRemoteRunRecord, + RemoteRecordError, +} from "../remote/records.ts"; +import { + type RemoteContent, + type RemoteContentRequest, + type RemoteFrontierSnapshot, + type RemoteReadLink, + startingFrontier, +} from "../remote/read.ts"; +import { + decodeDofsManifest, + parseWorkspaceRootManifest, + SHA256, + WORKSPACE_ROOT_DOMAIN, + type WorkspaceRootManifest, +} from "../workspace/root-manifest.ts"; +import { JOURNAL_PAGE_ENTRIES, MAX_CONTENT_BYTES } from "./commands.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" + | "storage:foreign" + | "storage:unsupported-version" + | "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; +} + +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 "storage:foreign": + case "storage:unsupported-version": + case "storage:corrupt": + return value; + default: + 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") { + decodeDofsManifest(bytes, fail); + } + return { kind, digest, bytes }; +} + +export function cloudflareReadLink( + connection: OwnerConnection, + nextId: () => string, + expectedRunId: string, +): RemoteReadLink { + return { + *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, + ), + ); + 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 }; + }, + *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; + }, + }; +} + +export function cloudflareOwnerLink(reads: RemoteReadLink): OwnerLink { + return { + *frontier(): Operation { + return startingFrontier(yield* reads.frontier()); + }, + *commit(_intent: CommitIntent): Operation> { + return Err(new CloudflareOwnerRefusalError("command:unavailable")); + }, + }; +} + +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, + ), + ); +} diff --git a/packages/workflow/src/cloudflare/commands.ts b/packages/workflow/src/cloudflare/commands.ts index 9b50c8175..a0ba67fb3 100644 --- a/packages/workflow/src/cloudflare/commands.ts +++ b/packages/workflow/src/cloudflare/commands.ts @@ -1,117 +1,121 @@ -/** - * What a runner asks its owner, and what comes back. - * - * These records are private to one software-factory release. They are not - * journaled, exported, authored, or supported across independently versioned - * builds — admission has already proved both sides are the same build, which is - * what a wire contract would otherwise be for. Their decomposition is - * implementation detail and may change with the release that contains it. - * - * What is not private is the parsing discipline. Everything arriving from the - * connection is parsed strictly before it reaches storage: an unknown command, - * an unknown member, a value of the wrong kind and a value past its bound are - * each refused whole, and nothing is partially adopted. A permissive read here - * would be a runner deciding what the owner does. - */ - import { type DocumentExecutionCompletion, parseDocumentExecutionCompletion, } from "../storage/record.ts"; +import { SHA256 } from "../workspace/root-manifest.ts"; + +export const MAX_MESSAGE_BYTES = 8 * 1024 * 1024; +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 commands a runner may send. */ -export type CommandName = "frontier" | "materialize" | "commit" | "settle"; +export type CommandName = + | "frontier" + | "journal" + | "root" + | "content" + | "stage" + | "commit" + | "settle"; -/** Why a message was refused before it reached the run. */ export type CommandRefusal = | "not-an-object" | "unknown-command" | "unknown-member" | "malformed-member" - | "too-large"; + | "too-large" + | "duplicate-conflict" + | "capacity" + | "unavailable"; export class CommandError extends Error { override name = "CommandError"; constructor(readonly refusal: CommandRefusal) { - // The message a runner sent is not repeated: it arrived from outside and a - // member name can carry as much as a member value. super(`this owner refused a runner command (${refusal})`); } } -/** The envelope every command shares. */ export interface CommandEnvelope { - /** Distinguishes one request from a retry of the same request. */ readonly id: string; readonly command: CommandName; } -/** Read the committed run record and current Workspace root. */ export interface FrontierCommand extends CommandEnvelope { readonly command: "frontier"; } -/** Ask for the content-addressed bytes of one retained root. */ -export interface MaterializeCommand extends CommandEnvelope { - readonly command: "materialize"; +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 mutation intent, submitted once, applied atomically or not at all. */ export interface CommitCommand extends CommandEnvelope { readonly command: "commit"; - /** The root the runner started from; the owner refuses if it has moved. */ readonly expectedWorkspaceRootId: string; - /** The journal frontier the runner read; the owner refuses if it has moved. */ readonly expectedJournalEventId: string | null; - /** Content-addressed additions, each named by its own digest. */ - readonly content: readonly ContentChunk[]; - /** The canonical root the runner proposes, recomputed by the owner. */ readonly proposedWorkspaceRootId: string; - /** Already-filtered journal events to append, in order. */ readonly events: readonly string[]; } -/** - * Publish how a document execution ended, and what the run becomes. - * - * The completion is the shared provider-neutral record, parsed with the shared - * parser rather than a private approximation — the owner and the local host - * have to agree about what a completion *is*, and two readers of one shape is - * how they stop agreeing. The expected root is carried so the owner can refuse - * a settlement proposed against a frontier that has moved. - */ export interface SettleCommand extends CommandEnvelope { readonly command: "settle"; readonly completion: DocumentExecutionCompletion; readonly expectedWorkspaceRootId: string; } -export interface ContentChunk { - readonly digest: string; - /** Base64, because a private transport still carries text. */ - readonly bytes: string; -} - -export type RunnerCommand = FrontierCommand | MaterializeCommand | CommitCommand | SettleCommand; - -/** The largest message this owner reads at all. */ -const MAX_MESSAGE = 8 * 1024 * 1024; - -/** The most chunks one commit may carry. */ -const MAX_CHUNKS = 4096; +export type RunnerCommand = + | FrontierCommand + | JournalCommand + | RootCommand + | ContentCommand + | StageCommand + | CommitCommand + | SettleCommand; -const ENVELOPE = ["id", "command"] as const; +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], - materialize: [...ENVELOPE, "workspaceRootId"], + frontier: ENVELOPE, + journal: [...ENVELOPE, "anchorEventId", "afterEventId"], + root: [...ENVELOPE, "workspaceRootId"], + content: [...ENVELOPE, "workspaceRootId", "kind", "digest", "sourceManifest"], + stage: [...ENVELOPE, "kind", "digest", "bytes"], commit: [ ...ENVELOPE, "expectedWorkspaceRootId", "expectedJournalEventId", - "content", "proposedWorkspaceRootId", "events", ], @@ -122,62 +126,68 @@ function object(value: unknown): Map { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new CommandError("not-an-object"); } - const members: Map = new Map(Object.entries(value)); - return members; + return new Map(Object.entries(value)); } -/** - * A retained Workspace root, as the schema spells one. - * - * The lowercase SHA-256 of a canonical manifest: 64 hexadecimal characters and - * nothing else. Admitting any non-empty string would let three commands hold - * three different notions of what a root is, and the owner would be the first - * to find out. - */ -const ROOT_ID = /^[0-9a-f]{64}$/; - -function rootId(members: Map, key: string): string { - const value = text(members, key); - if (!ROOT_ID.test(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 text(members: Map, key: string): string { +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 closed(members: Map, allowed: readonly string[]): void { - for (const key of members.keys()) { - if (!allowed.includes(key)) { - throw new CommandError("unknown-member"); - } +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 chunks(value: unknown): ContentChunk[] { - if (!Array.isArray(value)) { +function kind(members: Map): ContentKind { + const value = members.get("kind"); + if (value !== "manifest" && value !== "blob") { throw new CommandError("malformed-member"); } - if (value.length > MAX_CHUNKS) { - throw new CommandError("too-large"); - } - return value.map((entry) => { - const members = object(entry); - closed(members, ["digest", "bytes"]); - return { digest: text(members, "digest"), bytes: text(members, "bytes") }; - }); + return value; } -function events(value: unknown): string[] { +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"); @@ -186,9 +196,8 @@ function events(value: unknown): string[] { }); } -/** Read one command out of a message nothing has inspected yet. */ export function parseCommand(raw: string): RunnerCommand { - if (raw.length > MAX_MESSAGE) { + if (new TextEncoder().encode(raw).length > MAX_MESSAGE_BYTES) { throw new CommandError("too-large"); } let decoded: unknown; @@ -198,11 +207,14 @@ export function parseCommand(raw: string): RunnerCommand { throw new CommandError("not-an-object"); } const members = object(decoded); - const id = text(members, "id"); + const id = text(members, "id", MAX_ID); const command = members.get("command"); if ( command !== "frontier" && - command !== "materialize" && + command !== "journal" && + command !== "root" && + command !== "content" && + command !== "stage" && command !== "commit" && command !== "settle" ) { @@ -213,13 +225,42 @@ export function parseCommand(raw: string): RunnerCommand { if (command === "frontier") { return { id, command }; } - if (command === "materialize") { - return { id, command, workspaceRootId: rootId(members, "workspaceRootId") }; + 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 === "settle") { - // The shared parser decides what a completion is. Its failure becomes this - // transport's own closed refusal: the parser's message names members and - // values a request supplied, and none of that belongs on the wire. const completion = parseDocumentExecutionCompletion(members.get("completion")); if (!completion.ok) { throw new CommandError("malformed-member"); @@ -228,32 +269,15 @@ export function parseCommand(raw: string): RunnerCommand { id, command, completion: completion.value, - expectedWorkspaceRootId: rootId(members, "expectedWorkspaceRootId"), + expectedWorkspaceRootId: digest(members, "expectedWorkspaceRootId"), }; } - const expectedJournalEventId = members.get("expectedJournalEventId"); - if (expectedJournalEventId !== null && typeof expectedJournalEventId !== "string") { - throw new CommandError("malformed-member"); - } return { id, command, - expectedWorkspaceRootId: rootId(members, "expectedWorkspaceRootId"), - expectedJournalEventId, - content: chunks(members.get("content")), - proposedWorkspaceRootId: rootId(members, "proposedWorkspaceRootId"), - events: events(members.get("events")), + expectedWorkspaceRootId: digest(members, "expectedWorkspaceRootId"), + expectedJournalEventId: nullableText(members, "expectedJournalEventId"), + proposedWorkspaceRootId: digest(members, "proposedWorkspaceRootId"), + events: eventRecords(members.get("events")), }; } - -/** - * What the owner answers with. - * - * A serialized record rather than an Effection `Result`: this crosses a - * connection, and an `Error` does not survive that. The discriminant is - * `outcome` for the same reason — there is no in-process result being modelled - * here, only what one side told the other. - */ -export type CommandResult = - | { readonly id: string; readonly outcome: "performed"; readonly value: unknown } - | { readonly id: string; readonly outcome: "refused"; readonly refusal: string }; diff --git a/packages/workflow/src/cloudflare/dispatcher.ts b/packages/workflow/src/cloudflare/dispatcher.ts new file mode 100644 index 000000000..7216fd28f --- /dev/null +++ b/packages/workflow/src/cloudflare/dispatcher.ts @@ -0,0 +1,269 @@ +/** + * 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 { readContent, readFrontier, readJournalPage, readRoot } from "./owner-reads.ts"; +import type { OwnerTransactions } from "./owner-transaction.ts"; +import { COMMAND_TABLE, STAGING_TABLE } from "./private-schema.ts"; +import { recognizeObject } from "./recognition.ts"; + +function requestFingerprint(command: RunnerCommand): string { + return sha256Hex(JSON.stringify(command)); +} + +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; +} + +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"); +} + +function retainedDecision(command: RunnerCommand, result: CommandResult): string { + if ( + result.outcome === "performed" && + (command.command === "journal" || command.command === "root" || command.command === "content") + ) { + 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, +): 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) }; + } + 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); + const fingerprint = requestFingerprint(command); + return transactions.run(ctx.storage, () => { + const inside = requireAcquisition(ctx, socket, runId); + if (inside.acquisitionId !== held.acquisitionId) { + throw new CommandError("duplicate-conflict"); + } + recognizeObject(ctx.storage); + const previous = 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) + : decision; + } + const usage = ctx.storage.sql + .exec( + `SELECT count(*) AS commands, coalesce(sum(response_bytes), 0) AS bytes + FROM ${COMMAND_TABLE} WHERE acquisition_id = ?`, + held.acquisitionId, + ) + .toArray()[0]; + if ( + integer(usage?.["commands"]) >= MAX_COMMANDS || + integer(usage?.["bytes"]) >= MAX_LEDGER_BYTES + ) { + throw new CommandError("capacity"); + } + const result = perform(ctx, runId, held.acquisitionId, command); + const encoded = retainedDecision(command, result); + const responseBytes = new TextEncoder().encode(encoded).length; + if (integer(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, + ); + 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/owner-reads.ts b/packages/workflow/src/cloudflare/owner-reads.ts new file mode 100644 index 000000000..1598faa0e --- /dev/null +++ b/packages/workflow/src/cloudflare/owner-reads.ts @@ -0,0 +1,356 @@ +/** + * 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 piece of content is admitted only if the named root + * actually names it, directly or through a manifest it names, so this is a read + * of one retained root rather than of a content-addressed store. + * + * 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 { readRetrieval, readRunRecord, type Row } from "../sqlite/rows.ts"; +import { WorkflowRecordMalformedError } from "../storage/errors.ts"; +import { + decodeDofsManifest, + parseWorkspaceRootManifest, + SHA256, + WORKSPACE_ROOT_DOMAIN, + type WorkspaceRootManifest, +} from "../workspace/root-manifest.ts"; +import { + CommandError, + 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; +} + +interface StoredRoot { + readonly manifest: string; + readonly parsed: WorkspaceRootManifest; + readonly manifests: 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(); +} + +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 expectedManifests = new Set( + parsed.entries.flatMap((entry) => (entry.kind === "file" ? [entry.manifest] : [])), + ); + const manifestRows = byteRows( + storage, + `SELECT lower(hex(manifest_hash)) AS digest + FROM workspace_root_manifest_refs WHERE root_id = ? ORDER BY digest`, + rootId, + ); + if (manifestRows.length !== expectedManifests.size) { + return corrupt("a Workspace root's manifest references are incomplete"); + } + const manifests = new Set(); + for (const row of manifestRows) { + const digest = safeText(row, "digest"); + if (!expectedManifests.has(digest)) { + return corrupt("a Workspace root has an extra manifest reference"); + } + manifests.add(digest); + } + return { manifest, parsed, manifests }; +} + +function retainedManifest( + storage: OwnerStorage, + root: StoredRoot, + digest: string, +): { bytes: Uint8Array; chunks: ReturnType["chunks"] } { + if (!root.manifests.has(digest)) { + return corrupt("a DOFS manifest is not referenced by this Workspace root"); + } + 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 decoded = decodeDofsManifest(bytes, corrupt); + if (safeInteger(row["size"], "manifest size") !== decoded.size) { + return corrupt("a retained DOFS manifest disagrees with its recorded size"); + } + for (const entry of root.parsed.entries) { + if (entry.kind === "file" && entry.manifest === digest && entry.size !== decoded.size) { + return corrupt("a Workspace file size disagrees with its retained manifest"); + } + } + return { bytes, chunks: decoded.chunks }; +} + +function retainedBlob( + storage: OwnerStorage, + rootId: string, + root: StoredRoot, + sourceManifest: string, + digest: string, +): Uint8Array { + const manifest = retainedManifest(storage, root, sourceManifest); + const expected = manifest.chunks.find((chunk) => chunk.hash === digest); + if (expected === undefined) { + return corrupt("a blob is not referenced by the named DOFS manifest"); + } + 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 || + bytes.length !== expected.size || + safeInteger(row["size"], "blob size") !== expected.size || + sha256Hex(bytes) !== digest + ) { + return corrupt("a retained DOFS blob disagrees with its identity or size"); + } + return bytes; +} + +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"), + }; +} + +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 = + kind === "manifest" + ? retainedManifest(storage, root, digest).bytes + : retainedBlob(storage, workspaceRootId, root, sourceManifest ?? "", digest); + if (bytes.length === 0) { + return corrupt("a retained content piece is empty"); + } + return { kind, digest, size: bytes.length, bytes: encodeBase64(bytes) }; +} diff --git a/packages/workflow/src/cloudflare/owner.ts b/packages/workflow/src/cloudflare/owner.ts index 3f68d0a5f..cf22f1c6f 100644 --- a/packages/workflow/src/cloudflare/owner.ts +++ b/packages/workflow/src/cloudflare/owner.ts @@ -29,10 +29,14 @@ import { 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 { dispatchCommand } from "./dispatcher.ts"; +import { WorkflowRecordMalformedError } from "../storage/errors.ts"; +import { discardPriorAcquisitions, PRIVATE_OBJECT_NAMES } from "./private-schema.ts"; import { declaredObjects, initializeObject, @@ -89,6 +93,17 @@ export function refusalOf(error: unknown): string { if (error instanceof WorkflowObjectStorageError) { 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"; } @@ -132,7 +147,16 @@ export abstract class WorkflowOwnerObject extends DurableObject { requireSameRelease(policy.release, request.release); yield* admitToken(policy, verification, request.token); const runId = admitRunId(request.runId); - return acquireExecutor(this.ctx, socket, runId, mintAcquisitionId()); + 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))) { + recognizeObject(this.owned); + this.transactions.run(this.owned, () => { + discardPriorAcquisitions(this.owned, acquisitionId); + }); + } + }); } /** @@ -147,14 +171,36 @@ export abstract class WorkflowOwnerObject extends DurableObject { try { requireAcquisition(this.ctx, socket, runId); command = parseCommand(raw); - return { id: command.id, outcome: "performed", value: this.perform(socket, runId, command) }; + return dispatchCommand(this.ctx, this.transactions, socket, runId, command); } catch (error) { return { id: command?.id ?? "", outcome: "refused", refusal: refusalOf(error) }; } } - /** What each command does. Subclasses supply the behavior this owner has. */ - protected abstract perform(socket: WebSocket, runId: string, command: RunnerCommand): unknown; + 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 { @@ -196,3 +242,12 @@ function mintAcquisitionId(): string { crypto.getRandomValues(bytes); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } + +function fatal(answer: CommandResult): boolean { + if (answer.outcome === "performed") { + return false; + } + return ( + answer.refusal !== "command:duplicate-conflict" && answer.refusal !== "command:unavailable" + ); +} diff --git a/packages/workflow/src/cloudflare/private-schema.ts b/packages/workflow/src/cloudflare/private-schema.ts new file mode 100644 index 000000000..4353cf56e --- /dev/null +++ b/packages/workflow/src/cloudflare/private-schema.ts @@ -0,0 +1,85 @@ +/** + * 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"; +export const STAGING_TABLE = "_xmd_executor_staging"; + +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 PRIVATE_OBJECTS = new Map([ + [COMMAND_TABLE, { type: "table", sql: COMMAND_SQL }], + [STAGING_TABLE, { type: "table", sql: STAGING_SQL }], +]); + +export const PRIVATE_OBJECT_NAMES: readonly string[] = Object.freeze([...PRIVATE_OBJECTS.keys()]); + +export function initializePrivateSchema(storage: OwnerStorage): void { + storage.sql.exec(`${COMMAND_SQL};\n\n${STAGING_SQL};`); +} + +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; +} + +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); +} diff --git a/packages/workflow/src/cloudflare/recognition.ts b/packages/workflow/src/cloudflare/recognition.ts index 7ee4bd6cd..e283e5b4d 100644 --- a/packages/workflow/src/cloudflare/recognition.ts +++ b/packages/workflow/src/cloudflare/recognition.ts @@ -25,6 +25,11 @@ import { 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 { OwnerTransactions } from "./owner-transaction.ts"; import type { OwnerStorage } from "./storage.ts"; @@ -101,6 +106,7 @@ export function initializeObject( transactions.run(storage, ({ dofs }) => { storage.sql.exec(SCHEMA_SQL); initializeDofsSchema(dofs, () => 0); + initializePrivateSchema(storage); initializeRun(); storage.sql.exec(MARKER_SQL); storage.sql.exec( @@ -161,7 +167,22 @@ export function recognizeObject(storage: OwnerStorage): void { }); } - const declared = objects.filter((object) => object.name !== MARKER_TABLE); + 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; 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/database.ts b/packages/workflow/src/deno/database.ts index 2b439d65f..00e262223 100644 --- a/packages/workflow/src/deno/database.ts +++ b/packages/workflow/src/deno/database.ts @@ -61,7 +61,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"; 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/transitions.ts b/packages/workflow/src/deno/transitions.ts index 1c365834d..27808c7a7 100644 --- a/packages/workflow/src/deno/transitions.ts +++ b/packages/workflow/src/deno/transitions.ts @@ -60,7 +60,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, diff --git a/packages/workflow/src/deno/workspace/manifest.ts b/packages/workflow/src/deno/workspace/manifest.ts index 47c0b3893..e57194887 100644 --- a/packages/workflow/src/deno/workspace/manifest.ts +++ b/packages/workflow/src/deno/workspace/manifest.ts @@ -1,58 +1,43 @@ import { createHash } from "node:crypto"; -import { z } from "zod"; import { WorkflowDatabaseCorruptError } from "../../storage/errors.ts"; +import { + hasUnpairedSurrogate, + parseWorkspaceRootManifest, + SHA256, + validateCanonicalWorkspacePath, + validateWorkspaceRootEntries, + WORKSPACE_ROOT_DOMAIN, + WORKSPACE_ROOT_FORMAT, + type WorkspaceRejection, + type WorkspaceRootEntry, + type WorkspaceRootManifest, +} from "../../workspace/root-manifest.ts"; + +export { + compareUtf8, + hasUnpairedSurrogate, + parentFirst, + parentPath, + 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; @@ -96,40 +81,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 +94,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 +102,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 +118,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 +167,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/root.ts b/packages/workflow/src/deno/workspace/root.ts index 55db2b482..a6d211b9b 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 { + decodeDofsManifest as decodeSharedDofsManifest, + type DofsManifest, + SHA256, +} from "../../workspace/root-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 { DofsManifest } from "../../workspace/root-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 } }; } } } @@ -468,23 +455,7 @@ export function readDofsManifest( * 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) }); + return decodeSharedDofsManifest(encoded, reject); } function parseStoredRoot( diff --git a/packages/workflow/src/remote/client.ts b/packages/workflow/src/remote/client.ts index 48879ae97..93aa599b0 100644 --- a/packages/workflow/src/remote/client.ts +++ b/packages/workflow/src/remote/client.ts @@ -88,6 +88,7 @@ export interface OwnerConnection { id: string, command: Record, parse: AnswerParser, + parseRefusal?: (refusal: string) => string, ): Operation>; } @@ -295,6 +296,7 @@ export function useOwnerConnection(socket: OwnerSocket): Operation, parse: AnswerParser, + parseRefusal: (refusal: string) => string = (refusal) => refusal, ): Operation> { if (closed) { throw new OwnerLinkError("closed"); @@ -311,8 +313,13 @@ export function useOwnerConnection(socket: OwnerSocket): 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)), + }; +} diff --git a/packages/workflow/src/remote/records.ts b/packages/workflow/src/remote/records.ts new file mode 100644 index 000000000..1853f35a3 --- /dev/null +++ b/packages/workflow/src/remote/records.ts @@ -0,0 +1,143 @@ +/** + * 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, + parseRunId, + parseWorkflowRunStatus, + parseWorkflowStopReason, + type WorkflowRunRecord, +} from "../storage/record.ts"; +import { SHA256 } from "../workspace/root-manifest.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"), + }); +} 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/workspace/root-manifest.ts b/packages/workflow/src/workspace/root-manifest.ts new file mode 100644 index 000000000..a532ddc31 --- /dev/null +++ b/packages/workflow/src/workspace/root-manifest.ts @@ -0,0 +1,425 @@ +/** + * 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[]; +} + +/** One DOFS manifest: the ordered chunks one file's bytes are stored as. */ +export interface DofsChunkReference { + readonly hash: string; + readonly size: number; +} + +export interface DofsManifest { + readonly size: number; + readonly chunks: readonly DofsChunkReference[]; +} + +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 DOFS 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 DOFS 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 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 found = members(offered); + const chunks = found?.get("chunks"); + if ( + found === undefined || + !declares(found, ["version", "chunks"]) || + found.get("version") !== 1 || + !Array.isArray(chunks) + ) { + reject("a DOFS manifest is not canonically encoded"); + } + const references: DofsChunkReference[] = []; + 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 DOFS manifest is not canonically encoded"); + } + references.push({ hash, size }); + } + if (JSON.stringify({ version: 1, chunks: references }) !== text) { + reject("a DOFS manifest is not canonically encoded"); + } + const total = references.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(references) }); +} 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/tests/cloudflare/executor-acquisition.vitest.ts b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts index 3db842664..317e1dad6 100644 --- a/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts +++ b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts @@ -268,10 +268,12 @@ describe("holding an acquisition", () => { it("lets the admitted connection send, and answers what it performed", async () => { const stub = executor(); + await on(stub, (o) => o.initialize()); await admitted(stub); - expect( - await on(stub, (o) => o.send(1, JSON.stringify({ id: "1", command: "frontier" }))), - ).toEqual({ id: "1", outcome: "performed", value: { performed: "frontier" } }); + 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 () => { @@ -282,6 +284,16 @@ describe("holding an acquisition", () => { ).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); @@ -318,28 +330,34 @@ describe("reading a runner command", () => { expect((await refuse(JSON.stringify({ command: "frontier" }))).refusal).toBe( "command:malformed-member", ); - expect((await refuse(JSON.stringify({ id: "1", command: "materialize" }))).refusal).toBe( + expect((await refuse(JSON.stringify({ id: "1", command: "root" }))).refusal).toBe( "command:malformed-member", ); }); - it("reads a commit intent whole", async () => { + it("reads a commit intent whole, then refuses to act on it in this release", 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, - content: [{ digest: "d1", bytes: "AAAA" }], proposedWorkspaceRootId: `b${"1".repeat(63)}`, events: ["event-1"], }); + // The shape is read — an unknown member or a malformed root would refuse + // differently — and then declined, because applying one is a later + // checkpoint's work and a performed placeholder would be a lie. expect(await on(stub, (o) => o.send(1, raw))).toEqual({ id: "7", - outcome: "performed", - value: { performed: "commit" }, + outcome: "refused", + refusal: "command:unavailable", }); + 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" }); }); }); diff --git a/packages/workflow/tests/cloudflare/owner-storage.vitest.ts b/packages/workflow/tests/cloudflare/owner-storage.vitest.ts index a075305ed..82544a870 100644 --- a/packages/workflow/tests/cloudflare/owner-storage.vitest.ts +++ b/packages/workflow/tests/cloudflare/owner-storage.vitest.ts @@ -82,6 +82,13 @@ describe("recognizing an owner object", () => { 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", () => { 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..edc7076ac --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts @@ -0,0 +1,540 @@ +/** + * 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"; + +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(`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); + }); +} + +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", + ], + [ + "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}`)); + } + await admit(stub); + const frontier = record((await send(stub, "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 send(stub, "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 send(stub, "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("returns only content referenced by one validated root", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await admit(stub); + expect(await send(stub, "root", { command: "root", workspaceRootId: ROOT_ID })).toEqual({ + id: "root", + outcome: "performed", + value: { workspaceRootId: ROOT_ID, manifest: ROOT_MANIFEST }, + }); + expect( + await send(stub, "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 send(stub, "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 send(stub, "orphan", { + command: "content", + workspaceRootId: ROOT_ID, + kind: "blob", + digest: orphan, + sourceManifest: MANIFEST_ID, + }), + ).toEqual({ id: "orphan", outcome: "refused", refusal: "storage:corrupt" }); + }); + + 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")); + await admit(stub); + const first = await send(stub, "same", { command: "frontier" }); + await on(stub, (owner) => owner.appendJournal("after", "after")); + expect( + await on(stub, (owner) => + record(owner.send(1, JSON.stringify({ command: "frontier", id: "same" }))), + ), + ).toEqual(first); + expect(await send(stub, "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()); + await admit(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 send(stub, "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 send(stub, "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 stub = executor(); + await on(stub, (owner) => owner.initialize()); + await admit(stub); + const bytes = new TextEncoder().encode("piece"); + expect( + await send(stub, "bad-base64", { + command: "stage", + kind: "blob", + digest: sha256Hex(bytes), + bytes: "not base64", + }), + ).toMatchObject({ outcome: "refused", refusal: "command:malformed-member" }); + expect( + await send(stub, "bad-digest", { + command: "stage", + kind: "blob", + digest: "0".repeat(64), + bytes: encodeBase64(bytes), + }), + ).toMatchObject({ outcome: "refused", refusal: "command:malformed-member" }); + const oversized = new Uint8Array(MAX_CONTENT_BYTES + 1); + expect( + await send(stub, "oversized", { + command: "stage", + kind: "blob", + digest: sha256Hex(oversized), + bytes: encodeBase64(oversized), + }), + ).toMatchObject({ outcome: "refused", refusal: "command:too-large" }); + expect(await on(stub, (owner) => owner.scratch())).toEqual({ 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 D3 and D4 mutations rather than reporting placeholder success", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await admit(stub); + expect( + await send(stub, "commit", { + command: "commit", + expectedWorkspaceRootId: ROOT_ID, + expectedJournalEventId: null, + proposedWorkspaceRootId: ROOT_ID, + events: [], + }), + ).toEqual({ id: "commit", outcome: "refused", refusal: "command:unavailable" }); + expect( + await send(stub, "settle", { + command: "settle", + completion: { executionId: "execution", status: "completed" }, + expectedWorkspaceRootId: ROOT_ID, + }), + ).toEqual({ id: "settle", outcome: "refused", refusal: "command:unavailable" }); + }); +}); diff --git a/packages/workflow/tests/cloudflare/settle-parser.vitest.ts b/packages/workflow/tests/cloudflare/settle-parser.vitest.ts index 2a26c5dac..1f97cb13a 100644 --- a/packages/workflow/tests/cloudflare/settle-parser.vitest.ts +++ b/packages/workflow/tests/cloudflare/settle-parser.vitest.ts @@ -116,8 +116,17 @@ describe("a root identity in a command", () => { /** Every root field in the private command shapes, by the request it sits in. */ const fields: Record string> = { - "materialize.workspaceRootId": (root) => - JSON.stringify({ id: "m1", command: "materialize", workspaceRootId: root }), + "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.proposedWorkspaceRootId": (root) => commit({ proposedWorkspaceRootId: root }), "settle.expectedWorkspaceRootId": (root) => settle({ expectedWorkspaceRootId: root }), @@ -129,7 +138,6 @@ describe("a root identity in a command", () => { command: "commit", expectedWorkspaceRootId: ROOT, expectedJournalEventId: null, - content: [], proposedWorkspaceRootId: ROOT, events: [], ...overrides, diff --git a/packages/workflow/tests/cloudflare/support/executor-object.ts b/packages/workflow/tests/cloudflare/support/executor-object.ts index 5ba445b3f..88a22d258 100644 --- a/packages/workflow/tests/cloudflare/support/executor-object.ts +++ b/packages/workflow/tests/cloudflare/support/executor-object.ts @@ -8,13 +8,17 @@ */ import { run } from "effection"; +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 type { RunnerCommand } from "../../../src/cloudflare/commands.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, STAGING_TABLE } from "../../../src/cloudflare/private-schema.ts"; +import { MARKER_TABLE } from "../../../src/cloudflare/marker.ts"; /** The identities this owner is configured to admit. */ export const POLICY: AdmissionPolicy = { @@ -41,7 +45,31 @@ export const VALID_CLAIMS: Record = { job_workflow_ref: POLICY.jobWorkflowRef, }; -const RUN_ID = "5cktgrv2zyutngh7bbddr2tyg2b5a567cg725hu5e7u42orerxaa"; +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, + }, + ], +}); +export const ROOT_ID = sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${ROOT_MANIFEST}`); +const CREATED_AT = "2026-09-03T00:00:00.000Z"; export class ExecutorObject extends WorkflowOwnerObject { /** @@ -74,8 +102,165 @@ export class ExecutorObject extends WorkflowOwnerObject { return { policy: POLICY, verification }; } - protected perform(_socket: WebSocket, _runId: string, command: RunnerCommand): unknown { - return { performed: command.command }; + 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())), + ); + } + + damageRetainedBlob(): void { + this.ctx.storage.sql.exec( + "UPDATE vfs_blob_bytes SET bytes = ?", + new TextEncoder().encode("bad"), + ); + } + + 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; } /** @@ -101,6 +286,27 @@ export class ExecutorObject extends WorkflowOwnerObject { } } + async fetch(request: Request): Promise { + 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 }); + } + } + /** The correlation the live acquisition is partitioned by. */ acquisitionId(): string { const held = acquisitionHolders(this.ctx)[0]; @@ -127,6 +333,16 @@ export class ExecutorObject extends WorkflowOwnerObject { 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]; @@ -136,3 +352,11 @@ export class ExecutorObject extends WorkflowOwnerObject { } } } + +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/host-neutrality.test.ts b/packages/workflow/tests/host-neutrality.test.ts index b3a3cf2ec..bc10781ba 100644 --- a/packages/workflow/tests/host-neutrality.test.ts +++ b/packages/workflow/tests/host-neutrality.test.ts @@ -96,6 +96,15 @@ describe("the shared workflow package", () => { 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); }); 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-read.test.ts b/packages/workflow/tests/remote-read.test.ts new file mode 100644 index 000000000..cf4b1e658 --- /dev/null +++ b/packages/workflow/tests/remote-read.test.ts @@ -0,0 +1,429 @@ +/** + * 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, + stageCloudflareContent, +} from "../src/cloudflare/client.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 { + EMPTY_WORKSPACE_MANIFEST, + EMPTY_WORKSPACE_ROOT_ID, + workspaceRootId, +} from "../src/deno/workspace/manifest.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); + }, + }; +} + +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; +} + +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, failure(raised)]).toEqual([description, "malformed-answer"]); + 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(failure(raised)).toBe("malformed-answer"); + expect(transport.closes).toBe(1); + }); + + 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(failure(raised)).toBe("malformed-answer"); + 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 link = cloudflareOwnerLink(cloudflareReadLink(connection, ids(), RUN_ID)); + // 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 }); + }); +}); 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}`); From 827f1d4ecdf23b406fe79d49aac7d6fe9e7c96cf Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 16:19:28 -0400 Subject: [PATCH 21/42] =?UTF-8?q?=F0=9F=A7=B1=20Prove=20a=20whole=20retain?= =?UTF-8?q?ed=20root=20before=20answering=20with=20one=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A root the owner returns is where the run stands. The runner materializes it, works inside it, and proposes against it. So a root whose content cannot all be found is not a frontier that happens to be incomplete — it is not a frontier, and saying otherwise is the one answer that cannot be taken back. The reads checked the root manifest and the manifests its entries named, and stopped. Everything past that — whether those manifests were still there, what their bytes were, whether the blobs they named existed, whether the root retained references to exactly those blobs — was checked only when a runner later asked for that particular piece. A root missing half its content therefore answered `frontier` and `root` performed, and failed afterwards, one piece at a time, once the run had already been told where it stood. Now the graph is walked before either answers. 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 naming 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 naming it. Both directions of each reference set are checked: a missing row is content nothing is keeping alive, an extra row is content no manifest accounts for, and neither describes a root anybody should start from. The bytes are read and dropped. What survives the walk is the proof, and a content request still re-reads the single piece it sends — a validated root is not permission to answer with all of it at once. The schema already refuses to let content vanish from under a root that references it, so the states worth reproducing are the ones that restriction cannot prevent: a reference collected together with its content, bytes that no longer hash to the identity they are stored under, a recorded size that disagrees with what it describes, and a reference to content no manifest names. Each of those now refuses before a root is returned, and says only that storage is damaged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- .../workflow/src/cloudflare/owner-reads.ts | 254 ++++++++++++------ .../tests/cloudflare/remote-owner.vitest.ts | 60 +++++ .../cloudflare/support/executor-object.ts | 69 +++++ 3 files changed, 307 insertions(+), 76 deletions(-) diff --git a/packages/workflow/src/cloudflare/owner-reads.ts b/packages/workflow/src/cloudflare/owner-reads.ts index 1598faa0e..2b1467acf 100644 --- a/packages/workflow/src/cloudflare/owner-reads.ts +++ b/packages/workflow/src/cloudflare/owner-reads.ts @@ -12,9 +12,13 @@ * 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 piece of content is admitted only if the named root - * actually names it, directly or through a manifest it names, so this is a read - * of one retained root rather than of a content-addressed store. + * 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 @@ -26,6 +30,7 @@ import { readRetrieval, readRunRecord, type Row } from "../sqlite/rows.ts"; import { WorkflowRecordMalformedError } from "../storage/errors.ts"; import { decodeDofsManifest, + type DofsManifest, parseWorkspaceRootManifest, SHA256, WORKSPACE_ROOT_DOMAIN, @@ -71,10 +76,18 @@ export interface ContentValue { 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: ReadonlySet; + readonly manifests: ReadonlyMap; + readonly blobs: ReadonlySet; } function corrupt(reason: string): never { @@ -111,61 +124,45 @@ function byteRows(storage: OwnerStorage, sql: string, ...bindings: unknown[]): R return storage.sql.exec(sql, ...bindings).toArray(); } -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 expectedManifests = new Set( - parsed.entries.flatMap((entry) => (entry.kind === "file" ? [entry.manifest] : [])), - ); - const manifestRows = byteRows( +/** 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(manifest_hash)) AS digest - FROM workspace_root_manifest_refs WHERE root_id = ? ORDER BY digest`, + `SELECT lower(hex(${column})) AS digest FROM ${table} WHERE root_id = ? ORDER BY digest`, rootId, - ); - if (manifestRows.length !== expectedManifests.size) { - return corrupt("a Workspace root's manifest references are incomplete"); - } - const manifests = new Set(); - for (const row of manifestRows) { - const digest = safeText(row, "digest"); - if (!expectedManifests.has(digest)) { - return corrupt("a Workspace root has an extra manifest reference"); - } - manifests.add(digest); + ).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`); } - return { manifest, parsed, manifests }; } -function retainedManifest( +/** One retained DOFS manifest, proved against its identity, size and entries. */ +function validatedManifest( storage: OwnerStorage, - root: StoredRoot, + parsed: WorkspaceRootManifest, digest: string, -): { bytes: Uint8Array; chunks: ReturnType["chunks"] } { - if (!root.manifests.has(digest)) { - return corrupt("a DOFS manifest is not referenced by this Workspace root"); - } +): { bytes: Uint8Array; manifest: DofsManifest } { const row = exactlyOne( byteRows(storage, "SELECT size, encoded FROM vfs_manifests WHERE lower(hex(hash)) = ?", digest), "DOFS manifest", @@ -174,30 +171,25 @@ function retainedManifest( if (bytes.length > MAX_CONTENT_BYTES || sha256Hex(bytes) !== digest) { return corrupt("a retained DOFS manifest disagrees with its identity"); } - const decoded = decodeDofsManifest(bytes, corrupt); - if (safeInteger(row["size"], "manifest size") !== decoded.size) { + const manifest = decodeDofsManifest(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 root.parsed.entries) { - if (entry.kind === "file" && entry.manifest === digest && entry.size !== decoded.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, chunks: decoded.chunks }; + return { bytes, manifest }; } -function retainedBlob( +/** One retained blob, proved against its identity and every chunk naming it. */ +function validatedBlob( storage: OwnerStorage, rootId: string, - root: StoredRoot, - sourceManifest: string, + manifests: ReadonlyMap, digest: string, ): Uint8Array { - const manifest = retainedManifest(storage, root, sourceManifest); - const expected = manifest.chunks.find((chunk) => chunk.hash === digest); - if (expected === undefined) { - return corrupt("a blob is not referenced by the named DOFS manifest"); - } const row = exactlyOne( byteRows( storage, @@ -211,17 +203,100 @@ function retainedBlob( "DOFS blob", ); const bytes = bytesOf(row["bytes"]); - if ( - bytes.length > MAX_CONTENT_BYTES || - bytes.length !== expected.size || - safeInteger(row["size"], "blob size") !== expected.size || - sha256Hex(bytes) !== digest - ) { - return corrupt("a retained DOFS blob disagrees with its identity or size"); + 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. + */ +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( @@ -345,12 +420,39 @@ export function readContent( sourceManifest: string | null, ): ContentValue { const root = referencedRoot(storage, workspaceRootId); - const bytes = - kind === "manifest" - ? retainedManifest(storage, root, digest).bytes - : retainedBlob(storage, workspaceRootId, root, sourceManifest ?? "", digest); + 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); +} diff --git a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts index edc7076ac..afa58cc0f 100644 --- a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts @@ -317,6 +317,66 @@ describe("the remote owner protocol", () => { ).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()); diff --git a/packages/workflow/tests/cloudflare/support/executor-object.ts b/packages/workflow/tests/cloudflare/support/executor-object.ts index 88a22d258..bcf675972 100644 --- a/packages/workflow/tests/cloudflare/support/executor-object.ts +++ b/packages/workflow/tests/cloudflare/support/executor-object.ts @@ -223,6 +223,75 @@ export class ExecutorObject extends WorkflowOwnerObject { ); } + /** + * 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)"); } From d97615f6509b981b44fac195e1675ce55f1add8b Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 17:59:29 -0400 Subject: [PATCH 22/42] =?UTF-8?q?=F0=9F=8C=B3=20Put=20a=20retained=20root?= =?UTF-8?q?=20on=20a=20runner=20and=20read=20it=20back=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner holds the run and can run nothing. Git, an Agent and an evidence command need real files, so a root has to become a directory somewhere the runner owns, and whatever happens there has to come back as a root again. The equality that matters is that those two operations compose to nothing: a materialization nobody touched must capture to the exact root it came from. If it did not, every Workspace operation that changed nothing would still propose a new root, and the owner could not tell a real change from an artefact of how the runner unpacked the tree. Directories, an empty file, a symbolic link, a hardlink group, modes and modification times all have to survive for that to be true, which is why the evidence is a real temporary filesystem rather than a map that would only prove it kept what it was given. Ordering, hardlink numbering, manifest encoding and chunk size now live beside the format instead of inside whoever happened to walk the tree. The local host walks SQLite rows and the runner walks a directory; those walks cannot be shared and their meaning must not diverge, because a root identity is a digest of the encoding and two encodings would be two names for one Workspace. The materialization knows no runtime and no path. Native operations arrive injected, adapted from the runtime's asynchronous primitives with `until` where `@effectionx/fs` has no equivalent; the logical root stays `/`, and the temporary directory the invocation happens to use reaches no manifest, event, proposal or error. A run that recorded where it was unpacked could not be resumed anywhere else. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/deno/remote-files.ts | 116 ++++++++ packages/workflow/src/remote/materialize.ts | 274 ++++++++++++++++++ packages/workflow/src/workspace/capture.ts | 218 ++++++++++++++ .../tests/remote-materialization.test.ts | 194 +++++++++++++ 4 files changed, 802 insertions(+) create mode 100644 packages/workflow/src/deno/remote-files.ts create mode 100644 packages/workflow/src/remote/materialize.ts create mode 100644 packages/workflow/src/workspace/capture.ts create mode 100644 packages/workflow/tests/remote-materialization.test.ts diff --git a/packages/workflow/src/deno/remote-files.ts b/packages/workflow/src/deno/remote-files.ts new file mode 100644 index 000000000..6e18ec6c3 --- /dev/null +++ b/packages/workflow/src/deno/remote-files.ts @@ -0,0 +1,116 @@ +/** + * 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 { + link, + lstat, + mkdir, + readdir, + readFile, + readlink, + symlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { type Operation, until } from "effection"; +import type { RunnerFiles, RunnerNode } from "../remote/materialize.ts"; + +/** Whole seconds, which is what a retained entry records. */ +function seconds(milliseconds: number): number { + return Math.floor(milliseconds / 1000); +} + +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: seconds(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, + }; +} + +/** 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 })); + }, + + *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)); + }, + + *setModifiedAt(path: string, mtime: number): Operation { + yield* until(utimes(path, mtime, mtime)); + }, + + *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); + }, + }; +} diff --git a/packages/workflow/src/remote/materialize.ts b/packages/workflow/src/remote/materialize.ts new file mode 100644 index 000000000..8968b6042 --- /dev/null +++ b/packages/workflow/src/remote/materialize.ts @@ -0,0 +1,274 @@ +/** + * 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, + decodeDofsManifest, + type WorkspaceRejection, + type WorkspaceRootManifest, +} from "../workspace/root-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; + /** Applied last, because writing into a directory moves its own time. */ + setModifiedAt(path: string, mtime: number): Operation; + readFile(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); + const written = new Map(); + const times: { path: string; mtime: number }[] = []; + + for (const entry of manifest.entries) { + const path = at(entry.path); + if (entry.kind === "directory") { + if (entry.path !== "/") { + yield* files.makeDirectory(path, entry.mode); + } + times.push({ path, mtime: entry.mtime }); + 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); + continue; + } + + const first = written.get(entry.manifest); + if (entry.hardlink !== null && first !== undefined) { + 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) { + written.set(entry.manifest, path); + } + times.push({ path, mtime: entry.mtime }); + } + + // Deepest first, so filling a directory cannot move a time already set. + for (const entry of times.toReversed()) { + yield* files.setModifiedAt(entry.path, entry.mtime); + } + return manifest; +} + +/** 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 = decodeDofsManifest(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/workspace/capture.ts b/packages/workflow/src/workspace/capture.ts new file mode 100644 index 000000000..a3e81b11a --- /dev/null +++ b/packages/workflow/src/workspace/capture.ts @@ -0,0 +1,218 @@ +/** + * 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 DofsChunkReference, + type WorkspaceRejection, + type WorkspaceRootEntry, + validateWorkspaceRootEntries, + WORKSPACE_ROOT_DOMAIN, + WORKSPACE_ROOT_FORMAT, +} from "./root-manifest.ts"; +import { sha256Hex } from "./sha256.ts"; + +/** + * The size a file's bytes are split at. + * + * Pinned to what the vendored DOFS layer uses. A runner that chunked + * differently would compute different manifest identities for identical bytes, + * and the owner would then hold two names for one file. + */ +export const CHUNK_SIZE = 512 * 1024; + +/** 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 DOFS 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 DofsChunkReference[]; +} + +/** 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 DOFS 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(); + +/** The bytes a DOFS manifest is stored and identified as. */ +export function encodeDofsManifest(chunks: readonly DofsChunkReference[]): Uint8Array { + return encoder.encode( + JSON.stringify({ + version: 1, + chunks: chunks.map((chunk) => ({ hash: chunk.hash, size: chunk.size })), + }), + ); +} + +/** + * 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: DofsChunkReference[] = []; + 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 = encodeDofsManifest(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/tests/remote-materialization.test.ts b/packages/workflow/tests/remote-materialization.test.ts new file mode 100644 index 000000000..882595f84 --- /dev/null +++ b/packages/workflow/tests/remote-materialization.test.ts @@ -0,0 +1,194 @@ +/** + * 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 { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, type Operation, resource, scoped, until } from "effection"; +import { mkdir, mkdtemp, rm, symlink, link, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runnerFiles } 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 { encodeDofsManifest } from "../src/workspace/capture.ts"; + +function reject(reason: string): never { + throw new Error(reason); +} + +/** + * A temporary directory owned by the scope that asked for it. + * + * A resource rather than a scoped operation: the tree has to outlive the call + * that created it and end with the invocation that owns it, which is the whole + * lifetime claim materialization makes. + */ +function useTemporaryDirectory(): Operation { + return resource(function* (provide) { + const path = yield* until(mkdtemp(join(tmpdir(), "xmd-materialize-"))); + yield* ensure(() => until(rm(path, { recursive: true, force: true }))); + yield* provide(path); + }); +} + +/** 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 { + // 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 names for one file: a hardlink group the capture must number. + yield* until(writeFile(join(root, "shared-a"), "shared bytes\n", { mode: 0o644 })); + yield* until(link(join(root, "shared-a"), join(root, "shared-b"))); + + 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, "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)); + } +} + +describe("materializing a retained Workspace root", () => { + it("captures an untouched materialization back to the exact root it came from", function* () { + const files: RunnerFiles = runnerFiles(); + const source = yield* useTemporaryDirectory(); + 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(3); + expect(entries.filter((entry) => entry.kind === "symlink")).toHaveLength(1); + expect( + entries.filter((entry) => entry.kind === "file" && entry.hardlink !== null), + ).toHaveLength(2); + 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* useTemporaryDirectory(); + yield* materializeWorkspaceRoot( + files, + servedBy(captured), + at(destination), + captured.root.rootId, + reject, + ); + + const again = yield* captureWorkspace(files, at(destination), reject); + 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 DOFS manifest the way the content 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(encodeDofsManifest([{ hash: "a".repeat(64), size: 3 }]))).toBe( + `{"version":1,"chunks":[{"hash":"${"a".repeat(64)}","size":3}]}`, + ); + expect(new TextDecoder().decode(encodeDofsManifest([]))).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* () { + path = yield* useTemporaryDirectory(); + 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); + }); +}); From 6e4596d446b42849030f837ea09b06174abf03ba Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 18:19:32 -0400 Subject: [PATCH 23/42] =?UTF-8?q?=F0=9F=94=97=20Keep=20two=20hardlink=20gr?= =?UTF-8?q?oups=20two=20files=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materialization indexed a hardlink group's first path by the content digest of its bytes. Two groups holding identical bytes legally share one DOFS manifest and are still two inodes, so the second group linked to the first and recapture saw one group of four names where the root said two of two. The Workspace that came back was a different Workspace, arriving under the identity of the one that was asked for. Group membership comes from the root's own `hardlink` value now, which is the only thing that ever said what a group was. Modes and times were left to whatever creation happened to produce. A creation mode is narrowed by the process umask, so a root retaining a group-writable file materialized without that bit; a symbolic link's own mode and time were never restored at all, and `utimes` could not have done it without following the link — which may point outside the tree deliberately. Permissions are now set explicitly after creation, deepest-first so a mode that forbids writing is not applied while children are still arriving, and a link's own metadata is set through the operations that do not follow it, where the platform has them. Where a platform has none, materialization refuses. Every entry is read back and compared with what the root declared before anything executes against the tree: a host that cannot represent a legal retained mode or time says so, once, before native work begins. Quietly normalizing it would hand the run a Workspace whose durable identity differs from the history it accepted, and the run would have no way to notice. The round trip now carries two hardlink groups with identical bytes, two independent files with identical bytes that must stay independent, modes the test's own umask would narrow, and a symbolic link with an old time of its own. Reverting the grouping key alone fails it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/deno/remote-files.ts | 47 ++++++++ packages/workflow/src/remote/materialize.ts | 104 ++++++++++++++++-- .../tests/remote-materialization.test.ts | 69 +++++++++++- 3 files changed, 205 insertions(+), 15 deletions(-) diff --git a/packages/workflow/src/deno/remote-files.ts b/packages/workflow/src/deno/remote-files.ts index 6e18ec6c3..fa482d45b 100644 --- a/packages/workflow/src/deno/remote-files.ts +++ b/packages/workflow/src/deno/remote-files.ts @@ -16,8 +16,11 @@ */ import { + chmod, link, lstat, + lchmod, + lutimes, mkdir, readdir, readFile, @@ -65,6 +68,22 @@ function describeStats( }; } +/** + * `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 { @@ -84,10 +103,38 @@ export function runnerFiles(): RunnerFiles { 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 { yield* until(utimes(path, mtime, mtime)); }, + /** + * 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, mtime)); + }, + + /** + * 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))); }, diff --git a/packages/workflow/src/remote/materialize.ts b/packages/workflow/src/remote/materialize.ts index 8968b6042..513971e3d 100644 --- a/packages/workflow/src/remote/materialize.ts +++ b/packages/workflow/src/remote/materialize.ts @@ -70,8 +70,27 @@ export interface RunnerFiles { 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; /** One directory's entries, described without following a link. */ list(path: string): Operation; @@ -104,8 +123,17 @@ export function* materializeWorkspaceRoot( reject: WorkspaceRejection, ): Operation { const manifest = yield* reads.root(workspaceRootId); - const written = new Map(); - const times: { path: string; mtime: number }[] = []; + /** + * 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); @@ -113,7 +141,8 @@ export function* materializeWorkspaceRoot( if (entry.path !== "/") { yield* files.makeDirectory(path, entry.mode); } - times.push({ path, mtime: entry.mtime }); + modes.push({ path, mode: entry.mode, link: false }); + times.push({ path, mtime: entry.mtime, link: false }); continue; } if (entry.kind === "symlink") { @@ -121,29 +150,86 @@ export function* materializeWorkspaceRoot( // 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 = written.get(entry.manifest); - if (entry.hardlink !== null && first !== undefined) { + 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) { - written.set(entry.manifest, path); + groups.set(entry.hardlink, path); } - times.push({ path, mtime: entry.mtime }); + modes.push({ path, mode: entry.mode, link: false }); + times.push({ path, mtime: entry.mtime, link: false }); } - // Deepest first, so filling a directory cannot move a time already set. + // 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()) { - yield* files.setModifiedAt(entry.path, entry.mtime); + 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, diff --git a/packages/workflow/tests/remote-materialization.test.ts b/packages/workflow/tests/remote-materialization.test.ts index 882595f84..b9e9bf865 100644 --- a/packages/workflow/tests/remote-materialization.test.ts +++ b/packages/workflow/tests/remote-materialization.test.ts @@ -17,9 +17,20 @@ 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 { mkdir, mkdtemp, rm, symlink, link, utimes, writeFile } from "node:fs/promises"; +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 } from "../src/deno/remote-files.ts"; import { captureWorkspace, @@ -108,9 +119,27 @@ function* buildTree(root: string): Operation { }), ); yield* until(symlink("../README.md", join(root, "docs", "link"))); - // Two names for one file: a hardlink group the capture must number. + + // 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], @@ -118,16 +147,26 @@ function* buildTree(root: string): Operation { [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 source = yield* useTemporaryDirectory(); yield* buildTree(source); @@ -135,11 +174,28 @@ describe("materializing a retained Workspace root", () => { 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(3); + expect(entries.filter((entry) => entry.kind === "directory")).toHaveLength(4); expect(entries.filter((entry) => entry.kind === "symlink")).toHaveLength(1); - expect( - entries.filter((entry) => entry.kind === "file" && entry.hardlink !== null), - ).toHaveLength(2); + // 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); 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. @@ -159,6 +215,7 @@ describe("materializing a retained Workspace root", () => { ); 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]); From eb4fb815480881c925999df5edb3fa190652f85b Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 18:27:26 -0400 Subject: [PATCH 24/42] =?UTF-8?q?=F0=9F=A7=AD=20Keep=20the=20content-store?= =?UTF-8?q?=20format=20out=20of=20the=20Workspace=20surface=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared coordination boundary is scanned for host vocabulary, and the root manifest module had been failing that scan since D2: its refusals named DOFS in string literals, which the scan reads as code because they are. D2's evidence list did not include the scan, so nothing said so until this checkpoint's wider verification ran it. The names were a symptom of the wrong seam. A Workspace root names a file's content by one identity and says nothing about how those bytes are kept; how they are kept is a separate format, and it was sitting inside the module that describes roots. It now has its own, which decides only whether a sequence of bytes is a canonically encoded manifest and produces the bytes one ought to be. Its refusals describe the format rather than the store implementing it. That is not a rename to satisfy a scan: every host keeps content this way, the vendored layer is one implementation, and a neutral module naming that implementation would be the Workspace surface learning where it happened to be stored. No message this reworded is asserted anywhere, so nothing observable moved. The obvious home — beside the schema that declares the content tables — is closed. A neutral module importing from there names the storage engine in its own import specifier, which is the same crossing by a different route. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/client.ts | 4 +- .../workflow/src/cloudflare/owner-reads.ts | 13 +- .../workflow/src/deno/artifact/records.ts | 14 +- .../workflow/src/deno/workspace/restore.ts | 4 +- packages/workflow/src/deno/workspace/root.ts | 31 +++-- packages/workflow/src/remote/materialize.ts | 4 +- packages/workflow/src/workspace/capture.ts | 35 ++--- .../src/workspace/content-manifest.ts | 129 ++++++++++++++++++ .../workflow/src/workspace/root-manifest.ts | 69 ---------- .../tests/remote-materialization.test.ts | 12 +- 10 files changed, 181 insertions(+), 134 deletions(-) create mode 100644 packages/workflow/src/workspace/content-manifest.ts diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts index 2cf8ab4e0..f443147ef 100644 --- a/packages/workflow/src/cloudflare/client.ts +++ b/packages/workflow/src/cloudflare/client.ts @@ -46,12 +46,12 @@ import { startingFrontier, } from "../remote/read.ts"; import { - decodeDofsManifest, parseWorkspaceRootManifest, SHA256, WORKSPACE_ROOT_DOMAIN, type WorkspaceRootManifest, } from "../workspace/root-manifest.ts"; +import { decodeContentManifest } from "../workspace/content-manifest.ts"; import { JOURNAL_PAGE_ENTRIES, MAX_CONTENT_BYTES } from "./commands.ts"; import { decodeBase64, encodeBase64, sha256Hex } from "./encoding.ts"; @@ -266,7 +266,7 @@ function parseContent(value: unknown): RemoteContent { return fail("the content disagreed with its identity or size"); } if (kind === "manifest") { - decodeDofsManifest(bytes, fail); + decodeContentManifest(bytes, fail); } return { kind, digest, bytes }; } diff --git a/packages/workflow/src/cloudflare/owner-reads.ts b/packages/workflow/src/cloudflare/owner-reads.ts index 2b1467acf..1b18d5eab 100644 --- a/packages/workflow/src/cloudflare/owner-reads.ts +++ b/packages/workflow/src/cloudflare/owner-reads.ts @@ -29,13 +29,12 @@ import { parseDurableEvent } from "@executablemd/durable-streams"; import { readRetrieval, readRunRecord, type Row } from "../sqlite/rows.ts"; import { WorkflowRecordMalformedError } from "../storage/errors.ts"; import { - decodeDofsManifest, - type DofsManifest, parseWorkspaceRootManifest, SHA256, WORKSPACE_ROOT_DOMAIN, type WorkspaceRootManifest, } from "../workspace/root-manifest.ts"; +import { type ContentManifest, decodeContentManifest } from "../workspace/content-manifest.ts"; import { CommandError, JOURNAL_PAGE_BYTES, @@ -86,7 +85,7 @@ export interface ContentValue { interface StoredRoot { readonly manifest: string; readonly parsed: WorkspaceRootManifest; - readonly manifests: ReadonlyMap; + readonly manifests: ReadonlyMap; readonly blobs: ReadonlySet; } @@ -162,7 +161,7 @@ function validatedManifest( storage: OwnerStorage, parsed: WorkspaceRootManifest, digest: string, -): { bytes: Uint8Array; manifest: DofsManifest } { +): { bytes: Uint8Array; manifest: ContentManifest } { const row = exactlyOne( byteRows(storage, "SELECT size, encoded FROM vfs_manifests WHERE lower(hex(hash)) = ?", digest), "DOFS manifest", @@ -171,7 +170,7 @@ function validatedManifest( if (bytes.length > MAX_CONTENT_BYTES || sha256Hex(bytes) !== digest) { return corrupt("a retained DOFS manifest disagrees with its identity"); } - const manifest = decodeDofsManifest(bytes, corrupt); + const manifest = decodeContentManifest(bytes, corrupt); if (safeInteger(row["size"], "manifest size") !== manifest.size) { return corrupt("a retained DOFS manifest disagrees with its recorded size"); } @@ -187,7 +186,7 @@ function validatedManifest( function validatedBlob( storage: OwnerStorage, rootId: string, - manifests: ReadonlyMap, + manifests: ReadonlyMap, digest: string, ): Uint8Array { const row = exactlyOne( @@ -274,7 +273,7 @@ function referencedRoot(storage: OwnerStorage, rootId: string): StoredRoot { "manifest", ); - const manifests = new Map(); + const manifests = new Map(); for (const digest of named) { manifests.set(digest, validatedManifest(storage, parsed, digest).manifest); } diff --git a/packages/workflow/src/deno/artifact/records.ts b/packages/workflow/src/deno/artifact/records.ts index 08cf5b18c..4752e1620 100644 --- a/packages/workflow/src/deno/artifact/records.ts +++ b/packages/workflow/src/deno/artifact/records.ts @@ -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; 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 a6d211b9b..de87da60c 100644 --- a/packages/workflow/src/deno/workspace/root.ts +++ b/packages/workflow/src/deno/workspace/root.ts @@ -24,18 +24,18 @@ import { workspaceRoot, WORKSPACE_ROOT_FORMAT, } from "./manifest.ts"; +import { SHA256 } from "../../workspace/root-manifest.ts"; import { - decodeDofsManifest as decodeSharedDofsManifest, - type DofsManifest, - SHA256, -} from "../../workspace/root-manifest.ts"; + type ContentManifest, + decodeContentManifest as decodeSharedContentManifest, +} from "../../workspace/content-manifest.ts"; export interface DofsChunk { readonly hash: Uint8Array; readonly size: number; } -export type { DofsManifest } from "../../workspace/root-manifest.ts"; +export type { ContentManifest } from "../../workspace/content-manifest.ts"; interface NodeRow { readonly inode: number; @@ -412,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, @@ -434,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"); } @@ -454,8 +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 { - return decodeSharedDofsManifest(encoded, reject); +export function decodeContentManifest( + encoded: Uint8Array, + reject: WorkspaceRejection, +): ContentManifest { + return decodeSharedContentManifest(encoded, reject); } function parseStoredRoot( @@ -488,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) { @@ -547,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( @@ -587,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/remote/materialize.ts b/packages/workflow/src/remote/materialize.ts index 513971e3d..114bb00ed 100644 --- a/packages/workflow/src/remote/materialize.ts +++ b/packages/workflow/src/remote/materialize.ts @@ -33,10 +33,10 @@ import { } from "../workspace/capture.ts"; import { compareUtf8, - decodeDofsManifest, 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"; @@ -242,7 +242,7 @@ function* fetchFile( kind: "manifest", digest: manifestDigest, }); - const manifest = decodeDofsManifest(encoded.bytes, reject); + const manifest = decodeContentManifest(encoded.bytes, reject); if (manifest.size !== size) { reject("a retained Workspace file size disagrees with the manifest it names"); } diff --git a/packages/workflow/src/workspace/capture.ts b/packages/workflow/src/workspace/capture.ts index a3e81b11a..a49b0b21f 100644 --- a/packages/workflow/src/workspace/capture.ts +++ b/packages/workflow/src/workspace/capture.ts @@ -21,24 +21,19 @@ import { compareUtf8, - type DofsChunkReference, 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"; -/** - * The size a file's bytes are split at. - * - * Pinned to what the vendored DOFS layer uses. A runner that chunked - * differently would compute different manifest identities for identical bytes, - * and the owner would then hold two names for one file. - */ -export const CHUNK_SIZE = 512 * 1024; - /** One node a walk found, before anything is ordered or numbered. */ export type CapturedNode = | { @@ -60,7 +55,7 @@ export type CapturedNode = readonly mode: number; readonly mtime: number; readonly size: number; - /** The DOFS manifest identity of this file's bytes. */ + /** The content manifest identity of this file's bytes. */ readonly manifest: string; /** * What makes two paths the same file rather than two copies. @@ -78,7 +73,7 @@ export type CapturedNode = export interface CapturedContent { readonly manifest: string; readonly manifestBytes: Uint8Array; - readonly chunks: readonly DofsChunkReference[]; + readonly chunks: readonly ContentChunkReference[]; } /** The root one capture describes, and the content it closes over. */ @@ -86,7 +81,7 @@ export interface CapturedRoot { readonly rootId: string; readonly manifest: string; readonly entries: readonly WorkspaceRootEntry[]; - /** Every DOFS manifest identity this root names, in canonical order. */ + /** 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[]; @@ -94,16 +89,6 @@ export interface CapturedRoot { const encoder = new TextEncoder(); -/** The bytes a DOFS manifest is stored and identified as. */ -export function encodeDofsManifest(chunks: readonly DofsChunkReference[]): Uint8Array { - return encoder.encode( - JSON.stringify({ - version: 1, - chunks: chunks.map((chunk) => ({ hash: chunk.hash, size: chunk.size })), - }), - ); -} - /** * Split one file's bytes the way the content store splits them. * @@ -112,12 +97,12 @@ export function encodeDofsManifest(chunks: readonly DofsChunkReference[]): Uint8 * empty file in a Workspace shares it. */ export function captureContent(bytes: Uint8Array): CapturedContent { - const chunks: DofsChunkReference[] = []; + 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 = encodeDofsManifest(chunks); + const manifestBytes = encodeContentManifest(chunks); return { manifest: sha256Hex(manifestBytes), manifestBytes, chunks }; } 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/root-manifest.ts b/packages/workflow/src/workspace/root-manifest.ts index a532ddc31..90090d74f 100644 --- a/packages/workflow/src/workspace/root-manifest.ts +++ b/packages/workflow/src/workspace/root-manifest.ts @@ -79,17 +79,6 @@ export interface WorkspaceRootManifest { readonly entries: readonly WorkspaceRootEntry[]; } -/** One DOFS manifest: the ordered chunks one file's bytes are stored as. */ -export interface DofsChunkReference { - readonly hash: string; - readonly size: number; -} - -export interface DofsManifest { - readonly size: number; - readonly chunks: readonly DofsChunkReference[]; -} - const encoder = new TextEncoder(); const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }); @@ -365,61 +354,3 @@ export function validateCanonicalWorkspacePath(value: string, reject: WorkspaceR } } } - -/** - * The DOFS 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 DOFS 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 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 found = members(offered); - const chunks = found?.get("chunks"); - if ( - found === undefined || - !declares(found, ["version", "chunks"]) || - found.get("version") !== 1 || - !Array.isArray(chunks) - ) { - reject("a DOFS manifest is not canonically encoded"); - } - const references: DofsChunkReference[] = []; - 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 DOFS manifest is not canonically encoded"); - } - references.push({ hash, size }); - } - if (JSON.stringify({ version: 1, chunks: references }) !== text) { - reject("a DOFS manifest is not canonically encoded"); - } - const total = references.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(references) }); -} diff --git a/packages/workflow/tests/remote-materialization.test.ts b/packages/workflow/tests/remote-materialization.test.ts index b9e9bf865..b5d09d5dd 100644 --- a/packages/workflow/tests/remote-materialization.test.ts +++ b/packages/workflow/tests/remote-materialization.test.ts @@ -40,7 +40,7 @@ import { 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 { encodeDofsManifest } from "../src/workspace/capture.ts"; +import { encodeContentManifest } from "../src/workspace/content-manifest.ts"; function reject(reason: string): never { throw new Error(reason); @@ -222,13 +222,13 @@ describe("materializing a retained Workspace root", () => { expect([...again.root.blobs]).toEqual([...captured.root.blobs]); }); - it("encodes a DOFS manifest the way the content store stores one", function* () { + 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(encodeDofsManifest([{ hash: "a".repeat(64), size: 3 }]))).toBe( - `{"version":1,"chunks":[{"hash":"${"a".repeat(64)}","size":3}]}`, - ); - expect(new TextDecoder().decode(encodeDofsManifest([]))).toBe('{"version":1,"chunks":[]}'); + 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* () { From 99b21a18329fec33e42ee18f45bc6f804ced6695 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 18:52:33 -0400 Subject: [PATCH 25/42] =?UTF-8?q?=E2=9C=8D=EF=B8=8F=20Let=20the=20owner=20?= =?UTF-8?q?decide=20one=20whole=20proposal=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `commit` carried a proposed root identity and nothing that could justify it. An identity with no manifest and no content closure is a name, and an owner adopting one would be taking the runner's word for what a root contains. The command now 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 — and the owner recomputes all of it before anything is written. The frontier is re-read inside the transaction rather than before it, and compared with what the runner said it started from, root and terminal event both, `null` included exactly. A frontier read outside the transaction is a frontier that can move before the write. The inventory has to be exactly the closure of the proposed 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. Each piece resolves from content already authoritative or from bytes this exact acquisition staged — staging supplies bytes and grants nothing, so another acquisition's scratch is unreachable and a digest already retained under different bytes is a disagreement rather than an overwrite. Everything lands together or not at all: content, the immutable root and its exact references, the mappings, the current pointer moved by compare-and-set from the expected root, the journal rows, and the retry decision. Journal rows name the root this commit selected — the proposed one when there is a publication, the unchanged one when there is not — which is what makes history readable against the Workspace it happened in. A journal-only transaction and an empty one are both ordinary, and neither invents a Workspace change to look uniform. Retained mappings go through the parsers the local host holds its own rows to, and creation identity is immutable: a second proposal naming the same Repository must describe the same Repository. An Agent-session mapping carries the canonical assertion and the derived key and nothing of the conversation; the owner never contacts a provider. The evidence runs on real workerd because none of it is provable otherwise. A forced failure after every category has been written rolls all of them back, the retry decision with them, leaving the id free. A lost response retried across eviction publishes once. A moved root, a moved anchor, an identity that is not its manifest's digest, an inventory missing or inventing a piece, unstaged content, a mapping that would rewrite an established identity, and a foreign socket each change nothing at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/commands.ts | 182 ++++++- .../workflow/src/cloudflare/dispatcher.ts | 22 + packages/workflow/src/cloudflare/publish.ts | 449 ++++++++++++++++++ .../src/deno/workspace/agent-sessions.ts | 65 +-- packages/workflow/src/remote/publication.ts | 63 +++ .../workflow/src/storage/agent-session.ts | 125 +++++ .../cloudflare/executor-acquisition.vitest.ts | 15 +- .../tests/cloudflare/remote-owner.vitest.ts | 11 +- .../tests/cloudflare/remote-publish.vitest.ts | 410 ++++++++++++++++ .../tests/cloudflare/settle-parser.vitest.ts | 8 +- .../cloudflare/support/executor-object.ts | 116 +++++ 11 files changed, 1398 insertions(+), 68 deletions(-) create mode 100644 packages/workflow/src/cloudflare/publish.ts create mode 100644 packages/workflow/src/remote/publication.ts create mode 100644 packages/workflow/src/storage/agent-session.ts create mode 100644 packages/workflow/tests/cloudflare/remote-publish.vitest.ts diff --git a/packages/workflow/src/cloudflare/commands.ts b/packages/workflow/src/cloudflare/commands.ts index a0ba67fb3..aeb7a0bb0 100644 --- a/packages/workflow/src/cloudflare/commands.ts +++ b/packages/workflow/src/cloudflare/commands.ts @@ -3,6 +3,13 @@ import { parseDocumentExecutionCompletion, } from "../storage/record.ts"; import { SHA256 } from "../workspace/root-manifest.ts"; +import { + parseRepositoryRecord, + parseWorktreeRecord, + type RepositoryRecord, + type WorktreeRecord, +} from "../composition/records.ts"; +import { type AgentSessionRecord, parseAgentSessionRecord } from "../storage/agent-session.ts"; export const MAX_MESSAGE_BYTES = 8 * 1024 * 1024; export const MAX_CONTENT_BYTES = 1024 * 1024; @@ -11,6 +18,12 @@ 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 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" @@ -29,7 +42,14 @@ export type CommandRefusal = | "too-large" | "duplicate-conflict" | "capacity" - | "unavailable"; + | "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"; export class CommandError extends Error { override name = "CommandError"; @@ -76,14 +96,51 @@ export interface StageCommand extends CommandEnvelope { 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 proposedWorkspaceRootId: string; + readonly publication: ProposedPublication | null; + readonly mappings: readonly ProposedMapping[]; + /** Exactly what `serializeDurableEvent` produced, terminating newline included. */ readonly events: readonly 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 kind: "worktree"; readonly record: WorktreeRecord } + | { readonly kind: "agent-session"; readonly record: AgentSessionRecord }; + export interface SettleCommand extends CommandEnvelope { readonly command: "settle"; readonly completion: DocumentExecutionCompletion; @@ -116,7 +173,8 @@ const MEMBERS: Record = { ...ENVELOPE, "expectedWorkspaceRootId", "expectedJournalEventId", - "proposedWorkspaceRootId", + "publication", + "mappings", "events", ], settle: [...ENVELOPE, "completion", "expectedWorkspaceRootId"], @@ -277,7 +335,123 @@ export function parseCommand(raw: string): RunnerCommand { command, expectedWorkspaceRootId: digest(members, "expectedWorkspaceRootId"), expectedJournalEventId: nullableText(members, "expectedJournalEventId"), - proposedWorkspaceRootId: digest(members, "proposedWorkspaceRootId"), + publication: publication(members.get("publication")), + mappings: mappings(members.get("mappings")), events: eventRecords(members.get("events")), }; } + +/** + * 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); + closed(members, ["kind", "record"]); + const which = members.get("kind"); + const offered = members.get("record"); + if (which === "repository") { + const record = parseRepositoryRecord(offered); + if (record === undefined) { + throw new CommandError("malformed-member"); + } + return { kind: which, record }; + } + 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/dispatcher.ts b/packages/workflow/src/cloudflare/dispatcher.ts index 7216fd28f..26df1b59c 100644 --- a/packages/workflow/src/cloudflare/dispatcher.ts +++ b/packages/workflow/src/cloudflare/dispatcher.ts @@ -47,6 +47,7 @@ import { bytesOf, decodeBase64, sha256Hex } from "./encoding.ts"; import { readContent, readFrontier, readJournalPage, readRoot } from "./owner-reads.ts"; import type { OwnerTransactions } from "./owner-transaction.ts"; import { COMMAND_TABLE, STAGING_TABLE } from "./private-schema.ts"; +import { applyCommit } from "./publish.ts"; import { recognizeObject } from "./recognition.ts"; function requestFingerprint(command: RunnerCommand): string { @@ -91,6 +92,17 @@ function storedDecision(value: unknown, id: string): CommandResult | "reconstruc 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(); +} + function retainedDecision(command: RunnerCommand, result: CommandResult): string { if ( result.outcome === "performed" && @@ -200,6 +212,16 @@ function perform( 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), + }; + } + // `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" }; } diff --git a/packages/workflow/src/cloudflare/publish.ts b/packages/workflow/src/cloudflare/publish.ts new file mode 100644 index 000000000..4a967ef32 --- /dev/null +++ b/packages/workflow/src/cloudflare/publish.ts @@ -0,0 +1,449 @@ +/** + * 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 { sha256Hex } from "../workspace/sha256.ts"; +import { CommandError, type CommitCommand, type ProposedMapping } from "./commands.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. */ +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"); + } + 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 held = rows( + storage, + `SELECT ${column} AS content FROM ${table} WHERE lower(hex(hash)) = ?`, + digest, + )[0]; + if (held !== undefined) { + const bytes = bytesOf(held["content"]); + if (sha256Hex(bytes) !== digest) { + return corrupt("retained content disagrees with the identity it is stored under"); + } + return { bytes, authoritative: true }; + } + 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 }; +} + +/** + * 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, +): 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); + + for (const mapping of command.mappings) { + applyMapping(storage, mapping); + } + + 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"); + } + + // 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; +} + +/** + * One retained mapping, inserted or confirmed. + * + * Creation identity is immutable: a second proposal naming the same Repository + * must describe the same Repository, and one that does not is refused rather + * than allowed to rewrite what an earlier execution established. + */ +function applyMapping(storage: OwnerStorage, mapping: ProposedMapping): void { + if (mapping.kind === "repository") { + const record = mapping.record; + const held = rows( + storage, + `SELECT name, 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, + record.locatorFingerprint, + record.locatorFingerprint, + record.requestedBase, + record.creationCommit, + record.primaryBranch, + record.objectFormat, + record.checkoutPath, + ); + return; + } + if ( + held["locator_fingerprint"] !== record.locatorFingerprint || + 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, 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 || + 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, assertion_kind, assertion_value + 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["assertion_kind"] !== record.assertion.kind || + held["assertion_value"] !== record.assertion.value + ) { + throw new CommandError("mapping-conflict"); + } +} diff --git a/packages/workflow/src/deno/workspace/agent-sessions.ts b/packages/workflow/src/deno/workspace/agent-sessions.ts index ff55877b3..1142cc92b 100644 --- a/packages/workflow/src/deno/workspace/agent-sessions.ts +++ b/packages/workflow/src/deno/workspace/agent-sessions.ts @@ -38,7 +38,6 @@ */ 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 { @@ -46,54 +45,26 @@ export class WorkflowAgentSessionError extends Error { } /** - * One durable identity a provider asserted, and what kind of thing it is. + * The shape and the key derivation are the shared rule, not this adapter's. * - * 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. + * 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 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 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", digest(identity.sessionIdentity)].join(":"); -} +import { + agentSessionKey, + type AgentSessionIdentity, + type AgentSessionRecord, + type ProviderAssertion, +} from "../../storage/agent-session.ts"; + +export { agentSessionKey, parseAgentSessionRecord } 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`; diff --git a/packages/workflow/src/remote/publication.ts b/packages/workflow/src/remote/publication.ts new file mode 100644 index 000000000..d8d95d557 --- /dev/null +++ b/packages/workflow/src/remote/publication.ts @@ -0,0 +1,63 @@ +/** + * 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 } + | { readonly kind: "worktree"; readonly record: WorktreeRecord } + | { readonly kind: "agent-session"; readonly record: AgentSessionRecord }; diff --git a/packages/workflow/src/storage/agent-session.ts b/packages/workflow/src/storage/agent-session.ts new file mode 100644 index 000000000..2bba915aa --- /dev/null +++ b/packages/workflow/src/storage/agent-session.ts @@ -0,0 +1,125 @@ +/** + * 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, + }); +} diff --git a/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts index 317e1dad6..6dbb1c84c 100644 --- a/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts +++ b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts @@ -335,7 +335,7 @@ describe("reading a runner command", () => { ); }); - it("reads a commit intent whole, then refuses to act on it in this release", async () => { + 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); @@ -344,16 +344,21 @@ describe("reading a runner command", () => { command: "commit", expectedWorkspaceRootId: `a${"0".repeat(63)}`, expectedJournalEventId: null, - proposedWorkspaceRootId: `b${"1".repeat(63)}`, + publication: { + proposedWorkspaceRootId: `b${"1".repeat(63)}`, + proposedManifest: "{}", + content: [], + }, + mappings: [], events: ["event-1"], }); // The shape is read — an unknown member or a malformed root would refuse - // differently — and then declined, because applying one is a later - // checkpoint's work and a performed placeholder would be a lie. + // 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:unavailable", + refusal: "command:stale-root", }); expect( await on(stub, (o) => o.send(1, JSON.stringify({ ...JSON.parse(raw), id: "8", extra: 1 }))), diff --git a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts index afa58cc0f..1557fc251 100644 --- a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts @@ -576,19 +576,10 @@ describe("the remote owner protocol", () => { expect(first).toMatchObject({ outcome: "performed" }); }); - it("refuses D3 and D4 mutations rather than reporting placeholder success", async () => { + it("refuses a settlement rather than reporting placeholder success", async () => { const stub = executor(); await on(stub, (owner) => owner.initialize()); await admit(stub); - expect( - await send(stub, "commit", { - command: "commit", - expectedWorkspaceRootId: ROOT_ID, - expectedJournalEventId: null, - proposedWorkspaceRootId: ROOT_ID, - events: [], - }), - ).toEqual({ id: "commit", outcome: "refused", refusal: "command:unavailable" }); expect( await send(stub, "settle", { command: "settle", 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..69adf37f2 --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts @@ -0,0 +1,410 @@ +/** + * 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 { + 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 { 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", + ); +} + +function send( + stub: ReturnType, + id: string, + command: Record, +): Promise> { + return on(stub, (owner) => record(owner.send(1, JSON.stringify({ id, ...command })))); +} + +/** + * 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 REPOSITORY = { + kind: "repository", + record: { + name: "app", + locatorFingerprint: "c".repeat(64), + requestedBase: null, + creationCommit: "9".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", + checkoutPath: "/app", + }, +}; + +/** Stage the one piece the owner does not already hold. */ +async function stageNewContent(stub: ReturnType): Promise { + expect( + await send(stub, "stage-blob", { + command: "stage", + kind: "blob", + digest: NEXT_BLOB_ID, + bytes: encodeBase64(NEXT_BYTES), + }), + ).toMatchObject({ outcome: "performed" }); + const manifest = new TextEncoder().encode( + JSON.stringify({ version: 1, chunks: [{ hash: NEXT_BLOB_ID, size: NEXT_BYTES.length }] }), + ); + expect( + await send(stub, "stage-manifest", { + command: "stage", + kind: "manifest", + digest: sha256Hex(manifest), + bytes: encodeBase64(manifest), + }), + ).toMatchObject({ outcome: "performed" }); +} + +function commit(overrides: Record = {}): Record { + return { + command: "commit", + expectedWorkspaceRootId: ROOT_ID, + expectedJournalEventId: null, + publication: nextPublication(), + mappings: [REPOSITORY], + events: [event("published")], + ...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()); + await admit(stub); + await stageNewContent(stub); + + const before = await on(stub, (owner) => owner.published()); + expect(before).toMatchObject({ currentRootId: ROOT_ID, roots: 1, events: [] }); + + const answer = await send(stub, "publish", commit()); + expect(answer).toMatchObject({ 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 send(stub, "read", { command: "frontier" }))["value"]); + expect(frontier["workspaceRootId"]).toBe(NEXT_ROOT_ID); + expect( + record(await send(stub, "root", { command: "root", workspaceRootId: NEXT_ROOT_ID })), + ).toMatchObject({ outcome: "performed" }); + }); + + it("keeps the expected root current for a journal-only transaction", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await admit(stub); + const answer = await send( + stub, + "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()); + await admit(stub); + expect( + await send(stub, "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()); + await admit(stub); + await stageNewContent(stub); + 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 send(stub, "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()); + await admit(stub); + await stageNewContent(stub); + const before = await on(stub, (owner) => owner.published()); + const answer = await send(stub, "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()); + await admit(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 send(stub, "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()); + await admit(stub); + await stageNewContent(stub); + expect(await send(stub, "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 send( + stub, + "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("grants a closed or foreign socket no publication", async () => { + const stub = executor(); + await on(stub, (owner) => owner.initialize()); + await admit(stub); + await stageNewContent(stub); + 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); + }); +}); diff --git a/packages/workflow/tests/cloudflare/settle-parser.vitest.ts b/packages/workflow/tests/cloudflare/settle-parser.vitest.ts index 1f97cb13a..ba37f3a56 100644 --- a/packages/workflow/tests/cloudflare/settle-parser.vitest.ts +++ b/packages/workflow/tests/cloudflare/settle-parser.vitest.ts @@ -128,7 +128,10 @@ describe("a root identity in a command", () => { sourceManifest: ROOT, }), "commit.expectedWorkspaceRootId": (root) => commit({ expectedWorkspaceRootId: root }), - "commit.proposedWorkspaceRootId": (root) => commit({ proposedWorkspaceRootId: root }), + "commit.publication.proposedWorkspaceRootId": (root) => + commit({ + publication: { proposedWorkspaceRootId: root, proposedManifest: "{}", content: [] }, + }), "settle.expectedWorkspaceRootId": (root) => settle({ expectedWorkspaceRootId: root }), }; @@ -138,7 +141,8 @@ describe("a root identity in a command", () => { command: "commit", expectedWorkspaceRootId: ROOT, expectedJournalEventId: null, - proposedWorkspaceRootId: ROOT, + publication: { proposedWorkspaceRootId: ROOT, proposedManifest: "{}", content: [] }, + mappings: [], events: [], ...overrides, }); diff --git a/packages/workflow/tests/cloudflare/support/executor-object.ts b/packages/workflow/tests/cloudflare/support/executor-object.ts index bcf675972..98568056d 100644 --- a/packages/workflow/tests/cloudflare/support/executor-object.ts +++ b/packages/workflow/tests/cloudflare/support/executor-object.ts @@ -69,6 +69,65 @@ export const ROOT_MANIFEST = JSON.stringify({ ], }); 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, + }, + ], +}); +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 { @@ -216,6 +275,63 @@ export class ExecutorObject extends WorkflowOwnerObject { ); } + /** + * 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. */ + 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), + }; + } + damageRetainedBlob(): void { this.ctx.storage.sql.exec( "UPDATE vfs_blob_bytes SET bytes = ?", From 4a0e9407ccfe0ef60d547bdc12860ba596f7656b Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 19:18:25 -0400 Subject: [PATCH 26/42] =?UTF-8?q?=F0=9F=A7=B7=20Hold=20a=20proposal=20to?= =?UTF-8?q?=20what=20it=20claims=20to=20be=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ways the owner could publish something nobody proposed. A journal record was admitted for being a non-empty string, and the database proved only that it was JSON. `{}` could therefore be retained atomically and reported performed, and a later read — which parses events properly — would find history it cannot understand. The run would become unreplayable at the moment it was told it had committed. Every record is now parsed with the authoritative parser and serialized again, and must be the same bytes that arrived, terminating newline included. A nearly-right record is refused rather than normalized, because retaining a corrected one would retain something the runner never proposed. A repository mapping carried only its journal-safe record, so the fingerprint was written where the locator belongs and the run could not reattach to the repository it claimed to have retained. The mapping now carries the admitted locator beside the record, and the fingerprint must follow from it. Existing rows are compared on every field that establishes creation identity rather than a convenient subset — a partial comparison reported performed for proposals that disagreed with what an earlier execution established. A Repository or Worktree row names a checkout, and the row is only true if the Workspace this commit selects contains it. A mapping-only commit could retain a claim about a directory nothing put there, and the next execution would find the claim and not the files. New checkout mappings must now accompany the publication that creates them, a Worktree must name a Repository that exists or arrives with it, and one proposal naming one mapping twice is refused. And the starting root was taken from the pointer without being proved. A journal-only commit could append history against a current root whose content graph cannot be materialized — accepting a frontier nothing can restore. The same validator the reads use now proves it inside the commit transaction, and authoritative content is confirmed against its companion metadata rather than trusted and then preserved by an `ON CONFLICT DO NOTHING`. A proposal is not a licence to repair damage. Two things surfaced while proving it. Refusals that are answers about the request — a moved frontier, a duplicate id, a mapping conflict, a command this release does not implement — no longer close the connection; only a broken channel or damaged store does, because closing on an ordinary disagreement made the runner reconnect to be told the same thing. And the workerd tests that walked several object round trips were using sockets made inside the object, which do not survive it being reset; they now use real accepted connections, which is what the claims were always about. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/commands.ts | 35 ++- .../workflow/src/cloudflare/owner-reads.ts | 11 + packages/workflow/src/cloudflare/owner.ts | 27 +- packages/workflow/src/cloudflare/publish.ts | 183 ++++++++++- packages/workflow/src/composition/records.ts | 12 + .../workflow/src/deno/composition/locator.ts | 11 +- packages/workflow/src/remote/publication.ts | 14 +- .../cloudflare/executor-acquisition.vitest.ts | 13 +- .../tests/cloudflare/remote-owner.vitest.ts | 23 +- .../tests/cloudflare/remote-publish.vitest.ts | 286 ++++++++++++++---- .../cloudflare/support/executor-object.ts | 4 + 11 files changed, 528 insertions(+), 91 deletions(-) diff --git a/packages/workflow/src/cloudflare/commands.ts b/packages/workflow/src/cloudflare/commands.ts index aeb7a0bb0..5e38fd1dd 100644 --- a/packages/workflow/src/cloudflare/commands.ts +++ b/packages/workflow/src/cloudflare/commands.ts @@ -2,8 +2,10 @@ import { type DocumentExecutionCompletion, parseDocumentExecutionCompletion, } from "../storage/record.ts"; +import { parseDurableEvent, serializeDurableEvent } from "@executablemd/durable-streams"; import { SHA256 } from "../workspace/root-manifest.ts"; import { + locatorFingerprintOf, parseRepositoryRecord, parseWorktreeRecord, type RepositoryRecord, @@ -137,7 +139,7 @@ export interface ProposedPiece { /** One retained mapping the proposal carries, already parsed. */ export type ProposedMapping = - | { readonly kind: "repository"; readonly record: RepositoryRecord } + | { readonly kind: "repository"; readonly record: RepositoryRecord; readonly locator: string } | { readonly kind: "worktree"; readonly record: WorktreeRecord } | { readonly kind: "agent-session"; readonly record: AgentSessionRecord }; @@ -239,6 +241,20 @@ function kind(members: Map): ContentKind { 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. + */ function eventRecords(value: unknown): string[] { if (!Array.isArray(value)) { throw new CommandError("malformed-member"); @@ -250,6 +266,10 @@ function eventRecords(value: unknown): string[] { 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; }); } @@ -428,15 +448,22 @@ function mappings(value: unknown): ProposedMapping[] { } return value.map((entry) => { const members = object(entry); - closed(members, ["kind", "record"]); 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); - if (record === undefined) { + const locator = members.get("locator"); + if (record === undefined || typeof locator !== "string" || locator === "") { throw new CommandError("malformed-member"); } - return { kind: which, record }; + // The record is journal-safe and names no locator; storage needs the + // admitted one. Requiring the fingerprint to follow from it is what stops + // a proposal retaining a locator that is not the one it was admitted for. + if (locatorFingerprintOf(locator) !== record.locatorFingerprint) { + throw new CommandError("malformed-member"); + } + return { kind: which, record, locator }; } if (which === "worktree") { const record = parseWorktreeRecord(offered); diff --git a/packages/workflow/src/cloudflare/owner-reads.ts b/packages/workflow/src/cloudflare/owner-reads.ts index 1b18d5eab..1f43833d3 100644 --- a/packages/workflow/src/cloudflare/owner-reads.ts +++ b/packages/workflow/src/cloudflare/owner-reads.ts @@ -240,6 +240,17 @@ function validatedBlob( * 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"); diff --git a/packages/workflow/src/cloudflare/owner.ts b/packages/workflow/src/cloudflare/owner.ts index cf22f1c6f..c1dbf96d4 100644 --- a/packages/workflow/src/cloudflare/owner.ts +++ b/packages/workflow/src/cloudflare/owner.ts @@ -243,11 +243,32 @@ function mintAcquisitionId(): string { 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", +]; + function fatal(answer: CommandResult): boolean { if (answer.outcome === "performed") { return false; } - return ( - answer.refusal !== "command:duplicate-conflict" && answer.refusal !== "command:unavailable" - ); + return !ANSWERED.includes(answer.refusal); } diff --git a/packages/workflow/src/cloudflare/publish.ts b/packages/workflow/src/cloudflare/publish.ts index 4a967ef32..2f3730fe9 100644 --- a/packages/workflow/src/cloudflare/publish.ts +++ b/packages/workflow/src/cloudflare/publish.ts @@ -47,8 +47,10 @@ import { 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 { bytesOf } from "./encoding.ts"; import { STAGING_TABLE } from "./private-schema.ts"; import type { OwnerStorage } from "./storage.ts"; @@ -86,13 +88,23 @@ function hexBytes(digest: string): Uint8Array { return bytes; } -/** The frontier as it is right now, read where the write will happen. */ +/** + * 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", @@ -113,16 +125,24 @@ function resolve( ): { bytes: Uint8Array; authoritative: boolean } { const table = kind === "manifest" ? "vfs_manifests" : "vfs_blob_bytes"; const column = kind === "manifest" ? "encoded" : "bytes"; - const held = rows( + const authoritative = rows( storage, `SELECT ${column} AS content FROM ${table} WHERE lower(hex(hash)) = ?`, digest, - )[0]; + ); + 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 (sha256Hex(bytes) !== digest) { + 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 }; } const staged = rows( @@ -145,6 +165,39 @@ function resolve( 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. * @@ -170,8 +223,24 @@ export function applyCommit( ? command.expectedWorkspaceRootId : publish(storage, acquisitionId, command); + const named = new Set(); + for (const mapping of command.mappings) { + const identity = + mapping.kind === "worktree" + ? `worktree:${mapping.record.repositoryName}/${mapping.record.name}` + : `${mapping.kind}:${mapping.kind === "repository" ? mapping.record.name : mapping.record.sessionKey}`; + 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); + } + const selectedEntries = directoriesOf( + command.publication === null ? undefined : command.publication.proposedManifest, + ); for (const mapping of command.mappings) { - applyMapping(storage, mapping); + applyMapping(storage, mapping, selectedEntries); } const journalEventIds: string[] = []; @@ -319,6 +388,12 @@ function publish(storage: OwnerStorage, acquisitionId: string, command: CommitCo 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 = ?", @@ -336,29 +411,85 @@ function publish(storage: OwnerStorage, acquisitionId: string, command: CommitCo } /** - * One retained mapping, inserted or confirmed. + * Where a mapping's checkout has to exist, for the mapping to be true. + * + * A Repository or Worktree row names a checkout path, and the row is only + * meaningful if the Workspace this commit selects actually contains it. A + * mapping-only commit that invented a checkout would retain a claim about a + * directory nothing put there, and the next execution would find the claim and + * not the files. + */ +function requirePlacement( + mapping: ProposedMapping, + selectedEntries: ReadonlySet | undefined, +): void { + if (mapping.kind === "agent-session") { + return; + } + if (selectedEntries === undefined) { + // No publication accompanies this commit, so nothing can have created the + // checkout. An exact confirmation of an already-retained mapping is still + // admissible — that is decided below, once the existing row is read. + return; + } + if (!selectedEntries.has(mapping.record.checkoutPath)) { + throw new CommandError("mapping-conflict"); + } +} + +/** 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: a second proposal naming the same Repository - * must describe the same Repository, and one that does not is refused rather - * than allowed to rewrite what an earlier execution established. + * 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 { +function applyMapping( + storage: OwnerStorage, + mapping: ProposedMapping, + selectedEntries: ReadonlySet | undefined, +): void { if (mapping.kind === "repository") { const record = mapping.record; const held = rows( storage, - `SELECT name, locator_fingerprint, requested_base, creation_commit, primary_branch, + `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) { + requirePlacement(mapping, selectedEntries); + if (selectedEntries === undefined) { + // A new checkout mapping with no Workspace publication to create it. + throw new CommandError("mapping-conflict"); + } storage.sql.exec( `INSERT INTO workspace_repositories (name, locator, locator_fingerprint, requested_base, creation_commit, primary_branch, object_format, checkout_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, record.name, - record.locatorFingerprint, + mapping.locator, record.locatorFingerprint, record.requestedBase, record.creationCommit, @@ -369,7 +500,9 @@ function applyMapping(storage: OwnerStorage, mapping: ProposedMapping): void { 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 || @@ -384,12 +517,26 @@ function applyMapping(storage: OwnerStorage, mapping: ProposedMapping): void { const record = mapping.record; const held = rows( storage, - `SELECT requested_branch, creation_commit, checkout_path FROM workspace_worktrees - WHERE repository_name = ? AND name = ?`, + `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) { + // A Worktree exists inside a Repository. One that named none would be a + // checkout belonging to nothing. + const repository = rows( + storage, + "SELECT name FROM workspace_repositories WHERE name = ?", + record.repositoryName, + )[0]; + if (repository === undefined) { + throw new CommandError("mapping-conflict"); + } + requirePlacement(mapping, selectedEntries); + if (selectedEntries === undefined) { + throw new CommandError("mapping-conflict"); + } storage.sql.exec( `INSERT INTO workspace_worktrees (repository_name, name, requested_branch, requested_base, creation_commit, checkout_path) @@ -405,6 +552,7 @@ function applyMapping(storage: OwnerStorage, mapping: ProposedMapping): void { } if ( held["requested_branch"] !== record.requestedBranch || + !sameText(held, "requested_base", record.requestedBase) || held["creation_commit"] !== record.creationCommit || held["checkout_path"] !== record.checkoutPath ) { @@ -416,7 +564,8 @@ function applyMapping(storage: OwnerStorage, mapping: ProposedMapping): void { const record = mapping.record; const held = rows( storage, - `SELECT provider, agent_command, session_identity, assertion_kind, assertion_value + `SELECT provider, agent_command, session_identity, policy, + assertion_kind, assertion_value, created_at FROM agent_sessions WHERE session_key = ?`, record.sessionKey, )[0]; @@ -441,8 +590,10 @@ function applyMapping(storage: OwnerStorage, mapping: ProposedMapping): void { 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["assertion_value"] !== record.assertion.value || + held["created_at"] !== record.createdAt ) { throw new CommandError("mapping-conflict"); } diff --git a/packages/workflow/src/composition/records.ts b/packages/workflow/src/composition/records.ts index b7ebb87c0..866129ea5 100644 --- a/packages/workflow/src/composition/records.ts +++ b/packages/workflow/src/composition/records.ts @@ -20,6 +20,7 @@ * a record at all. */ +import { sha256Hex } from "../workspace/sha256.ts"; import type { Json } from "@executablemd/durable-streams"; import { members, optionalText, text } from "./parse.ts"; @@ -101,6 +102,17 @@ const WORKTREE_MEMBERS = [ "checkoutPath", ] as const; +/** + * 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); +} + export function parseObjectFormat(value: unknown): GitObjectFormat | undefined { return value === "sha1" || value === "sha256" ? value : undefined; } diff --git a/packages/workflow/src/deno/composition/locator.ts b/packages/workflow/src/deno/composition/locator.ts index 7b9198d60..a92faaae9 100644 --- a/packages/workflow/src/deno/composition/locator.ts +++ b/packages/workflow/src/deno/composition/locator.ts @@ -91,7 +91,10 @@ export function admitLocator(locator: string): string | undefined { 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"); -} +/** + * The stable name an admitted locator is known by everywhere but its own column. + * + * The derivation is the shared rule: both hosts retain this fingerprint and a + * second derivation would be two names for one repository. + */ +export { locatorFingerprintOf as locatorFingerprint } from "../../composition/records.ts"; diff --git a/packages/workflow/src/remote/publication.ts b/packages/workflow/src/remote/publication.ts index d8d95d557..73f157645 100644 --- a/packages/workflow/src/remote/publication.ts +++ b/packages/workflow/src/remote/publication.ts @@ -58,6 +58,18 @@ export interface WorkspacePublication { * the run established; it never contacts or impersonates an Agent provider. */ export type RetainedMapping = - | { readonly kind: "repository"; readonly record: RepositoryRecord } + | { + 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 }; diff --git a/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts index 6dbb1c84c..86c14bfe2 100644 --- a/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts +++ b/packages/workflow/tests/cloudflare/executor-acquisition.vitest.ts @@ -10,6 +10,7 @@ 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"; @@ -242,6 +243,16 @@ describe("admitting an executor", () => { }); }); +/** 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(); @@ -350,7 +361,7 @@ describe("reading a runner command", () => { content: [], }, mappings: [], - events: ["event-1"], + events: [serializedEvent("read whole")], }); // 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 diff --git a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts index 1557fc251..5c50cf934 100644 --- a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts @@ -233,8 +233,10 @@ describe("the remote owner protocol", () => { for (let index = 0; index < 129; index += 1) { await on(stub, (owner) => owner.appendJournal(`event-${index}`, `event ${index}`)); } - await admit(stub); - const frontier = record((await send(stub, "frontier", { command: "frontier" }))["value"]); + // 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); @@ -242,7 +244,7 @@ describe("the remote owner protocol", () => { await on(stub, (owner) => owner.appendJournal("event-later", "later")); const first = record( ( - await send(stub, "journal-1", { + await ask(socket, "journal-1", { command: "journal", anchorEventId: "event-128", afterEventId: null, @@ -253,7 +255,7 @@ describe("the remote owner protocol", () => { expect(first["done"]).toBe(false); const second = record( ( - await send(stub, "journal-2", { + await ask(socket, "journal-2", { command: "journal", anchorEventId: "event-128", afterEventId: "event-127", @@ -269,14 +271,17 @@ describe("the remote owner protocol", () => { it("returns only content referenced by one validated root", async () => { const stub = executor(); await on(stub, (owner) => owner.initialize()); - await admit(stub); - expect(await send(stub, "root", { command: "root", workspaceRootId: ROOT_ID })).toEqual({ + // 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 send(stub, "manifest", { + await ask(socket, "manifest", { command: "content", workspaceRootId: ROOT_ID, kind: "manifest", @@ -294,7 +299,7 @@ describe("the remote owner protocol", () => { }, }); expect( - await send(stub, "blob", { + await ask(socket, "blob", { command: "content", workspaceRootId: ROOT_ID, kind: "blob", @@ -307,7 +312,7 @@ describe("the remote owner protocol", () => { owner.addUnreferencedBlob(new TextEncoder().encode("orphan")), ); expect( - await send(stub, "orphan", { + await ask(socket, "orphan", { command: "content", workspaceRootId: ROOT_ID, kind: "blob", diff --git a/packages/workflow/tests/cloudflare/remote-publish.vitest.ts b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts index 69adf37f2..deead8242 100644 --- a/packages/workflow/tests/cloudflare/remote-publish.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts @@ -18,6 +18,7 @@ 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, @@ -30,6 +31,7 @@ import { } from "./support/executor-object.ts"; import { encodeBase64 } from "../../src/cloudflare/encoding.ts"; import { sha256Hex } from "../../src/workspace/sha256.ts"; +import { locatorFingerprintOf } from "../../src/composition/records.ts"; import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; let unique = 0; @@ -69,14 +71,6 @@ async function admit(stub: ReturnType): Promise { ); } -function send( - stub: ReturnType, - id: string, - command: Record, -): Promise> { - return on(stub, (owner) => record(owner.send(1, JSON.stringify({ id, ...command })))); -} - /** * A real accepted connection, which is what survives eviction. * @@ -137,11 +131,14 @@ function event(name: string): string { } /** 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: "c".repeat(64), + locatorFingerprint: locatorFingerprintOf(LOCATOR), requestedBase: null, creationCommit: "9".repeat(40), primaryBranch: "main", @@ -150,27 +147,23 @@ const REPOSITORY = { }, }; -/** Stage the one piece the owner does not already hold. */ -async function stageNewContent(stub: ReturnType): Promise { - expect( - await send(stub, "stage-blob", { - command: "stage", - kind: "blob", - digest: NEXT_BLOB_ID, - bytes: encodeBase64(NEXT_BYTES), - }), - ).toMatchObject({ outcome: "performed" }); +/** 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 }] }), ); - expect( - await send(stub, "stage-manifest", { - command: "stage", - kind: "manifest", - digest: sha256Hex(manifest), - bytes: encodeBase64(manifest), - }), - ).toMatchObject({ outcome: "performed" }); + await ask(socket, "stage-manifest", { + command: "stage", + kind: "manifest", + digest: sha256Hex(manifest), + bytes: encodeBase64(manifest), + }); } function commit(overrides: Record = {}): Record { @@ -189,14 +182,14 @@ describe("publishing one proposal", () => { it("adopts content, root, references, mapping, pointer and journal together", async () => { const stub = executor(); await on(stub, (owner) => owner.initialize()); - await admit(stub); - await stageNewContent(stub); + 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 send(stub, "publish", commit()); - expect(answer).toMatchObject({ outcome: "performed" }); + 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); @@ -215,19 +208,19 @@ describe("publishing one proposal", () => { expect(after["blobRefs"]).toBe(3); // And the new frontier reads back whole. - const frontier = record(record(await send(stub, "read", { command: "frontier" }))["value"]); + const frontier = record(record(await ask(socket, "read", { command: "frontier" }))["value"]); expect(frontier["workspaceRootId"]).toBe(NEXT_ROOT_ID); expect( - record(await send(stub, "root", { command: "root", workspaceRootId: NEXT_ROOT_ID })), + record(await ask(socket, "root", { command: "root", workspaceRootId: NEXT_ROOT_ID })), ).toMatchObject({ outcome: "performed" }); }); it("keeps the expected root current for a journal-only transaction", async () => { const stub = executor(); await on(stub, (owner) => owner.initialize()); - await admit(stub); - const answer = await send( - stub, + const socket = await connect(stub); + const answer = await ask( + socket, "journal-only", commit({ publication: null, mappings: [], events: [event("noted")] }), ); @@ -241,9 +234,9 @@ describe("publishing one proposal", () => { it("commits an empty transaction without inventing a Workspace change", async () => { const stub = executor(); await on(stub, (owner) => owner.initialize()); - await admit(stub); + const socket = await connect(stub); expect( - await send(stub, "empty", commit({ publication: null, mappings: [], events: [] })), + 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, @@ -255,8 +248,8 @@ describe("publishing one proposal", () => { it("rolls every category back when the transaction fails after applying", async () => { const stub = executor(); await on(stub, (owner) => owner.initialize()); - await admit(stub); - await stageNewContent(stub); + const socket = await connect(stub); + await stageThrough(socket); const before = await on(stub, (owner) => owner.published()); expect( @@ -269,7 +262,7 @@ describe("publishing one proposal", () => { // 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 send(stub, "doomed", commit())).toMatchObject({ outcome: "performed" }); + expect(await ask(socket, "doomed", commit())).toMatchObject({ outcome: "performed" }); }); it("applies a lost-response retry exactly once, across eviction", async () => { @@ -341,10 +334,10 @@ describe("publishing one proposal", () => { for (const [description, request] of Object.entries(cases)) { const stub = executor(); await on(stub, (owner) => owner.initialize()); - await admit(stub); - await stageNewContent(stub); + const socket = await connect(stub); + await stageThrough(socket); const before = await on(stub, (owner) => owner.published()); - const answer = await send(stub, "refused", request); + const answer = await ask(socket, "refused", request); expect([description, answer["outcome"]]).toEqual([description, "refused"]); expect([description, await on(stub, (owner) => owner.published())]).toEqual([ description, @@ -356,27 +349,27 @@ describe("publishing one proposal", () => { it("refuses content this acquisition did not stage", async () => { const stub = executor(); await on(stub, (owner) => owner.initialize()); - await admit(stub); + 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 send(stub, "unstaged", commit())).toMatchObject({ outcome: "refused" }); + 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()); - await admit(stub); - await stageNewContent(stub); - expect(await send(stub, "first", commit())).toMatchObject({ outcome: "performed" }); + 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 send( - stub, + await ask( + socket, "second", commit({ expectedWorkspaceRootId: NEXT_ROOT_ID, @@ -394,11 +387,198 @@ describe("publishing one proposal", () => { 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("grants a closed or foreign socket no publication", async () => { const stub = executor(); await on(stub, (owner) => owner.initialize()); - await admit(stub); - await stageNewContent(stub); + const socket = await connect(stub); + await stageThrough(socket); const before = await on(stub, (owner) => owner.published()); expect( await on(stub, (owner) => diff --git a/packages/workflow/tests/cloudflare/support/executor-object.ts b/packages/workflow/tests/cloudflare/support/executor-object.ts index 98568056d..4788737b5 100644 --- a/packages/workflow/tests/cloudflare/support/executor-object.ts +++ b/packages/workflow/tests/cloudflare/support/executor-object.ts @@ -100,6 +100,10 @@ export const NEXT_ROOT_MANIFEST = JSON.stringify({ 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}`); From f14349390cd02cba56c987fc5ddf8e7a9fff3fdd Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 19:33:47 -0400 Subject: [PATCH 27/42] =?UTF-8?q?=F0=9F=97=9D=EF=B8=8F=20Admit=20a=20locat?= =?UTF-8?q?or,=20and=20decide=20the=20mappings=20before=20writing=20any=20?= =?UTF-8?q?(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ways the owner could retain something it had not actually agreed to. A repository locator was admitted for having a fingerprint that matched itself. That says the two values agree with each other and nothing about whether the locator is one this system would ever hand to Git — so an authenticated proposal could retain a credential-bearing URL, an executable transport form, a query carrying a token, or a relative path, each with a perfectly consistent fingerprint. The closed allowlist the local host applies before Git sees a locator is now shared and applied by the owner before the fingerprint is compared at all. It is an allowlist rather than a search for bad shapes because Git's locator grammar reaches well past URLs, and a second copy of an allowlist is the copy that ends up longer. A Worktree's parent was looked for while its own row was being written, so `[repository, worktree]` committed and `[worktree, repository]` did not — the same transaction, refused for the order its mappings happened to arrive in. Identity, duplication, parent relationships and checkout placement are properties of the proposal rather than of one mapping, so they are all settled against the whole collection before anything is written, and application then runs parents before children. Which one a proposal lists first is the owner's problem, not something a runner should have to arrange to suit a schema. And a blob's metadata row with no bytes beside it was treated as absent rather than as damaged, so staging would supply the missing bytes and a proposal would quietly complete a half-written identity. Which durable state won then depended on the write path instead of on what the store actually holds. A partial identity is now storage corruption, refused before staging is consulted. The evidence is on real workerd: five locator forms that must never be retained, a positive round trip proving the row holds the repository rather than a digest of it, a Repository and its Worktree accepted in both orders with the same result, and a metadata row without its bytes refusing without repairing anything or recording a decision. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/commands.ts | 17 +- packages/workflow/src/cloudflare/publish.ts | 192 +++++++++++++----- packages/workflow/src/composition/locator.ts | 111 ++++++++++ packages/workflow/src/composition/records.ts | 12 -- .../workflow/src/deno/composition/locator.ts | 84 +------- .../tests/cloudflare/remote-publish.vitest.ts | 97 ++++++++- .../cloudflare/support/executor-object.ts | 17 ++ 7 files changed, 374 insertions(+), 156 deletions(-) create mode 100644 packages/workflow/src/composition/locator.ts diff --git a/packages/workflow/src/cloudflare/commands.ts b/packages/workflow/src/cloudflare/commands.ts index 5e38fd1dd..f7d84786c 100644 --- a/packages/workflow/src/cloudflare/commands.ts +++ b/packages/workflow/src/cloudflare/commands.ts @@ -4,8 +4,8 @@ import { } from "../storage/record.ts"; import { parseDurableEvent, serializeDurableEvent } from "@executablemd/durable-streams"; import { SHA256 } from "../workspace/root-manifest.ts"; +import { admitLocator, locatorFingerprintOf } from "../composition/locator.ts"; import { - locatorFingerprintOf, parseRepositoryRecord, parseWorktreeRecord, type RepositoryRecord, @@ -453,14 +453,17 @@ function mappings(value: unknown): ProposedMapping[] { const offered = members.get("record"); if (which === "repository") { const record = parseRepositoryRecord(offered); - const locator = members.get("locator"); - if (record === undefined || typeof locator !== "string" || locator === "") { + const offeredLocator = members.get("locator"); + if (record === undefined || typeof offeredLocator !== "string") { throw new CommandError("malformed-member"); } - // The record is journal-safe and names no locator; storage needs the - // admitted one. Requiring the fingerprint to follow from it is what stops - // a proposal retaining a locator that is not the one it was admitted for. - if (locatorFingerprintOf(locator) !== record.locatorFingerprint) { + // 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 }; diff --git a/packages/workflow/src/cloudflare/publish.ts b/packages/workflow/src/cloudflare/publish.ts index 2f3730fe9..a1344465d 100644 --- a/packages/workflow/src/cloudflare/publish.ts +++ b/packages/workflow/src/cloudflare/publish.ts @@ -145,6 +145,16 @@ function resolve( 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 = ?`, @@ -223,24 +233,22 @@ export function applyCommit( ? command.expectedWorkspaceRootId : publish(storage, acquisitionId, command); - const named = new Set(); - for (const mapping of command.mappings) { - const identity = - mapping.kind === "worktree" - ? `worktree:${mapping.record.repositoryName}/${mapping.record.name}` - : `${mapping.kind}:${mapping.kind === "repository" ? mapping.record.name : mapping.record.sessionKey}`; - 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); - } const selectedEntries = directoriesOf( command.publication === null ? undefined : command.publication.proposedManifest, ); - for (const mapping of command.mappings) { - applyMapping(storage, mapping, selectedEntries); + // 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); } const journalEventIds: string[] = []; @@ -410,31 +418,126 @@ function publish(storage: OwnerStorage, acquisitionId: string, command: CommitCo 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}`; +} + /** - * Where a mapping's checkout has to exist, for the mapping to be true. + * Decide the whole mapping collection, before any of it is written. * - * A Repository or Worktree row names a checkout path, and the row is only - * meaningful if the Workspace this commit selects actually contains it. A - * mapping-only commit that invented a checkout would retain a claim about a - * directory nothing put there, and the next execution would find the claim and - * not the files. + * 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 requirePlacement( - mapping: ProposedMapping, +function validateMappings( + storage: OwnerStorage, + mappings: readonly ProposedMapping[], selectedEntries: ReadonlySet | undefined, ): void { - if (mapping.kind === "agent-session") { - return; + 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); } - if (selectedEntries === undefined) { - // No publication accompanies this commit, so nothing can have created the - // checkout. An exact confirmation of an already-retained mapping is still - // admissible — that is decided below, once the existing row is read. - return; + + 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"); + } } - if (!selectedEntries.has(mapping.record.checkoutPath)) { - 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. */ @@ -464,11 +567,7 @@ function sameText(row: Record, column: string, expected: string * established, and the disagreement would only surface later, as a checkout * that is not what its record says. */ -function applyMapping( - storage: OwnerStorage, - mapping: ProposedMapping, - selectedEntries: ReadonlySet | undefined, -): void { +function applyMapping(storage: OwnerStorage, mapping: ProposedMapping): void { if (mapping.kind === "repository") { const record = mapping.record; const held = rows( @@ -478,11 +577,6 @@ function applyMapping( record.name, )[0]; if (held === undefined) { - requirePlacement(mapping, selectedEntries); - if (selectedEntries === undefined) { - // A new checkout mapping with no Workspace publication to create it. - throw new CommandError("mapping-conflict"); - } storage.sql.exec( `INSERT INTO workspace_repositories (name, locator, locator_fingerprint, requested_base, creation_commit, @@ -523,20 +617,6 @@ function applyMapping( record.name, )[0]; if (held === undefined) { - // A Worktree exists inside a Repository. One that named none would be a - // checkout belonging to nothing. - const repository = rows( - storage, - "SELECT name FROM workspace_repositories WHERE name = ?", - record.repositoryName, - )[0]; - if (repository === undefined) { - throw new CommandError("mapping-conflict"); - } - requirePlacement(mapping, selectedEntries); - if (selectedEntries === undefined) { - throw new CommandError("mapping-conflict"); - } storage.sql.exec( `INSERT INTO workspace_worktrees (repository_name, name, requested_branch, requested_base, creation_commit, checkout_path) 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/composition/records.ts b/packages/workflow/src/composition/records.ts index 866129ea5..b7ebb87c0 100644 --- a/packages/workflow/src/composition/records.ts +++ b/packages/workflow/src/composition/records.ts @@ -20,7 +20,6 @@ * a record at all. */ -import { sha256Hex } from "../workspace/sha256.ts"; import type { Json } from "@executablemd/durable-streams"; import { members, optionalText, text } from "./parse.ts"; @@ -102,17 +101,6 @@ const WORKTREE_MEMBERS = [ "checkoutPath", ] as const; -/** - * 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); -} - export function parseObjectFormat(value: unknown): GitObjectFormat | undefined { return value === "sha1" || value === "sha256" ? value : undefined; } diff --git a/packages/workflow/src/deno/composition/locator.ts b/packages/workflow/src/deno/composition/locator.ts index a92faaae9..5d0f1dbe9 100644 --- a/packages/workflow/src/deno/composition/locator.ts +++ b/packages/workflow/src/deno/composition/locator.ts @@ -18,83 +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. - * - * The derivation is the shared rule: both hosts retain this fingerprint and a - * second derivation would be two names for one repository. - */ -export { locatorFingerprintOf as locatorFingerprint } from "../../composition/records.ts"; +export { + admitLocator, + locatorFingerprintOf as locatorFingerprint, +} from "../../composition/locator.ts"; diff --git a/packages/workflow/tests/cloudflare/remote-publish.vitest.ts b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts index deead8242..5bf7b210c 100644 --- a/packages/workflow/tests/cloudflare/remote-publish.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts @@ -31,7 +31,7 @@ import { } from "./support/executor-object.ts"; import { encodeBase64 } from "../../src/cloudflare/encoding.ts"; import { sha256Hex } from "../../src/workspace/sha256.ts"; -import { locatorFingerprintOf } from "../../src/composition/records.ts"; +import { locatorFingerprintOf } from "../../src/composition/locator.ts"; import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; let unique = 0; @@ -574,6 +574,101 @@ describe("publishing one proposal", () => { } }); + 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("grants a closed or foreign socket no publication", async () => { const stub = executor(); await on(stub, (owner) => owner.initialize()); diff --git a/packages/workflow/tests/cloudflare/support/executor-object.ts b/packages/workflow/tests/cloudflare/support/executor-object.ts index 4788737b5..e39683434 100644 --- a/packages/workflow/tests/cloudflare/support/executor-object.ts +++ b/packages/workflow/tests/cloudflare/support/executor-object.ts @@ -336,6 +336,23 @@ export class ExecutorObject extends WorkflowOwnerObject { }; } + /** 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, + ); + } + damageRetainedBlob(): void { this.ctx.storage.sql.exec( "UPDATE vfs_blob_bytes SET bytes = ?", From 2290d4460a16c94330fa3c8fa4e2abe5fab735cc Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 19:50:01 -0400 Subject: [PATCH 28/42] =?UTF-8?q?=F0=9F=9A=9A=20Let=20the=20runner=20actua?= =?UTF-8?q?lly=20publish=20what=20it=20did=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner could decide a proposal and nothing could make one. `CommitIntent` carried the frontier and the events; the link refused every commit by construction; and the trees a mutation happens in were owned by a test helper. That is the half of D3a that makes the other half reachable. A transaction now carries what it decided: the expected frontier, the events, optionally one complete Workspace publication, and the retained mappings the same operation produced. Enlisting is a private capability handed to the body rather than something reachable from the database, so work that never received it cannot publish a Workspace by accident, and a second enlistment is refused — two Workspaces proposed for one commit is a choice nobody may make on the run's behalf. What is enlisted is detached on the way in: the caller still holds its arrays and may go on editing them, and the proposal the identity was computed over must not change underneath it. The Cloudflare link stages the pieces the owner does not already hold, encodes one closed command, and reads the decision. Content is addressed by what it is, so a Workspace the owner never lost is not sent again. The command identity is minted once per intent and reused verbatim if the answer is lost — the owner recognizes a retry by that identity, and regenerating one would ask a second question rather than the same question again. A lost answer returns undecided rather than failed, because whether the owner committed is exactly what cannot be known from the runner. Two trees, and the difference is the point. The materialization is the accepted root: what the owner last confirmed, restored so tools can work in it. The attempt is where a mutation happens, and it is disposable by construction. A documented failure throws the attempt away and leaves the accepted tree exactly as the owner confirmed it; working directly in the accepted tree would mean a failed effect had already changed the only local copy of the run's Workspace. Both are resources, so a raised failure, a cancellation and a refused commit all leave nothing behind, and only a performed owner answer promotes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/client.ts | 138 +++++- packages/workflow/src/deno/remote-files.ts | 39 +- packages/workflow/src/remote/collector.ts | 86 +++- packages/workflow/src/remote/invocation.ts | 159 ++++++ .../workflow/tests/remote-publication.test.ts | 465 ++++++++++++++++++ packages/workflow/tests/remote-read.test.ts | 7 +- 6 files changed, 884 insertions(+), 10 deletions(-) create mode 100644 packages/workflow/src/remote/invocation.ts create mode 100644 packages/workflow/tests/remote-publication.test.ts diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts index f443147ef..36901d953 100644 --- a/packages/workflow/src/cloudflare/client.ts +++ b/packages/workflow/src/cloudflare/client.ts @@ -26,12 +26,13 @@ * caller — half a journal that looks whole is worse than no journal. */ -import { Err, type Operation, type Result } from "effection"; +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 { OwnerAnswer, OwnerConnection } from "../remote/client.ts"; +import { OwnerLinkError, type OwnerAnswer, type OwnerConnection } from "../remote/client.ts"; import { parseRemoteJournalEntry, parseRemoteRetrieval, @@ -359,17 +360,144 @@ export function cloudflareReadLink( }; } -export function cloudflareOwnerLink(reads: RemoteReadLink): OwnerLink { +/** + * The bytes one proposed piece is made of, when the owner has to be sent them. + * + * A runner holds the content it captured; the owner may already hold some of + * it. Supplying bytes by identity lets the adapter stage only what is missing + * rather than resending a Workspace the owner never lost. + */ +export type ProposedBytes = (kind: "manifest" | "blob", digest: string) => Uint8Array | undefined; + +/** + * 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, + bytesOf: ProposedBytes = () => undefined, +): OwnerLink { return { *frontier(): Operation { return startingFrontier(yield* reads.frontier()); }, - *commit(_intent: CommitIntent): Operation> { - return Err(new CloudflareOwnerRefusalError("command:unavailable")); + + *commit(intent: CommitIntent): Operation> { + // Minted before anything is sent, and reused for the life of this intent. + const id = nextId(); + try { + yield* stageMissing(connection, nextId, intent, bytesOf); + const request = commitRequest(intent); + const answered = yield* ask(connection, id, request); + return answered.outcome === "refused" + ? Err(new CloudflareOwnerRefusalError(answered.refusal)) + : Ok(); + } catch (error) { + if (error instanceof OwnerLinkError) { + // The connection went while the answer was in flight. Whether the + // owner committed is exactly what cannot be known from here, 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; + } }, }; } +/** One command sent and one answer read, with the private refusal narrowed. */ +function* ask( + connection: OwnerConnection, + id: string, + request: Record, +): Operation<{ outcome: "performed" } | { outcome: "refused"; refusal: PrivateRefusal }> { + const offered = yield* connection.ask( + id, + request, + (value) => { + // A performed commit answers with what it published. Reading it proves + // the owner and this build agree about what just happened. + const found = members(value, ["workspaceRootId", "journalEventIds"]); + 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"); + } + return true; + }, + privateRefusal, + ); + return offered.outcome === "refused" + ? { outcome: "refused", refusal: privateRefusal(offered.refusal) } + : { outcome: "performed" }; +} + +/** + * 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, + bytesOf: ProposedBytes, +): Operation { + if (intent.publication === null) { + return; + } + for (const piece of intent.publication.content) { + const bytes = bytesOf(piece.kind, 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; + } + 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)), + }; +} + export function* stageCloudflareContent( connection: OwnerConnection, id: string, diff --git a/packages/workflow/src/deno/remote-files.ts b/packages/workflow/src/deno/remote-files.ts index fa482d45b..fafe82364 100644 --- a/packages/workflow/src/deno/remote-files.ts +++ b/packages/workflow/src/deno/remote-files.ts @@ -24,14 +24,18 @@ import { mkdir, readdir, readFile, + mkdtemp, readlink, + rm, symlink, utimes, writeFile, } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { type Operation, until } from "effection"; +import { ensure, type Operation, resource, until } from "effection"; import type { RunnerFiles, RunnerNode } from "../remote/materialize.ts"; +import type { TemporaryTrees } from "../remote/invocation.ts"; /** Whole seconds, which is what a retained entry records. */ function seconds(milliseconds: number): number { @@ -161,3 +165,36 @@ export function runnerFiles(): RunnerFiles { }, }; } + +/** + * 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/remote/collector.ts b/packages/workflow/src/remote/collector.ts index d5f29d323..1fec62b12 100644 --- a/packages/workflow/src/remote/collector.ts +++ b/packages/workflow/src/remote/collector.ts @@ -23,6 +23,7 @@ */ import { call, ensure, Ok, type Operation, type Result } from "effection"; +import type { RetainedMapping, WorkspacePublication } from "./publication.ts"; import type { DurableEvent } from "@executablemd/durable-streams"; import type { DurableStream } from "@executablemd/durable-streams"; import type { WorkflowRunTransaction } from "../storage/api.ts"; @@ -30,6 +31,8 @@ import type { WorkflowRunTransaction } from "../storage/api.ts"; /** Why a transaction could not be run or committed. */ export type CollectorRefusal = | "nested-transaction" + | "publication-already-enlisted" + | "too-many-mappings" | "transaction-closed" | "operation-inside-body" | "too-many-events" @@ -51,11 +54,32 @@ export interface StartingFrontier { readonly events: readonly DurableEvent[]; } -/** One closed intent, as the owner will receive it. */ +/** + * 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[]; +} + +/** + * What a Workspace operation enlisted, if one did. + * + * At most one per transaction. Two would be two Workspaces proposed for one + * commit, and the owner would have to choose — which is a decision nobody is + * entitled to make on the run's behalf. + */ +export interface WorkspaceEnlistment { + readonly publication: WorkspacePublication; + readonly mappings: readonly RetainedMapping[]; } /** What the collector needs from the connection. */ @@ -72,6 +96,9 @@ 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. * @@ -135,7 +162,7 @@ export function requireNoOpenTransaction(gate: TransactionGate): void { export function transactRemotely( link: OwnerLink, gate: TransactionGate, - body: (transaction: WorkflowRunTransaction) => Operation, + body: (transaction: WorkflowRunTransaction, enlist: EnlistWorkspace) => Operation, ): Operation> { return call(function* (): Operation> { // Taken synchronously, before the first suspension. Checking and then @@ -184,11 +211,34 @@ export function transactRemotely( }, }; + let enlisted: WorkspaceEnlistment | 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 = (proposal: WorkspaceEnlistment): void => { + if (!live) { + throw new RemoteTransactionError("transaction-closed"); + } + if (enlisted !== undefined) { + throw new RemoteTransactionError("publication-already-enlisted"); + } + if (proposal.mappings.length > MAX_MAPPINGS) { + throw new RemoteTransactionError("too-many-mappings"); + } + enlisted = detach(proposal); + }; + let outcome: T; try { // Everything the body started tears down before the intent is built, so // "no commit was sent" and "the body did not finish" are one statement. - outcome = yield* call(() => body({ journal })); + outcome = yield* call(() => body({ journal }, enlist)); } finally { // The handle is closed before the commit goes out, so a retained // transaction object refuses while the handle-level gate is still held. @@ -200,6 +250,8 @@ export function transactRemotely( expectedJournalEventId: starting.journalEventId, // A private snapshot. The collector's own array never leaves. events: appended.map((event) => structuredClone(event)), + publication: enlisted?.publication ?? null, + mappings: enlisted?.mappings ?? [], }); if (!committed.ok) { return committed; @@ -210,6 +262,34 @@ export function transactRemotely( }); } +/** How a Workspace operation enlists its one publication in the active transaction. */ +export type EnlistWorkspace = (proposal: WorkspaceEnlistment) => 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. + */ +function detach(proposal: WorkspaceEnlistment): WorkspaceEnlistment { + return Object.freeze({ + publication: Object.freeze({ + proposedWorkspaceRootId: proposal.publication.proposedWorkspaceRootId, + proposedManifest: proposal.publication.proposedManifest, + content: Object.freeze( + proposal.publication.content.map((piece) => Object.freeze({ ...piece })), + ), + }), + mappings: Object.freeze( + proposal.mappings.map((mapping) => + Object.freeze({ ...mapping, record: Object.freeze({ ...mapping.record }) }), + ), + ) as readonly RetainedMapping[], + }); +} + /** Discard a collector's work without sending it. */ export function abandon(gate: TransactionGate): Operation { return ensure(() => { diff --git a/packages/workflow/src/remote/invocation.ts b/packages/workflow/src/remote/invocation.ts new file mode 100644 index 000000000..ffb6c0569 --- /dev/null +++ b/packages/workflow/src/remote/invocation.ts @@ -0,0 +1,159 @@ +/** + * 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"; + +/** 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 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 this tree. */ + readonly at: HostPath; + /** + * Record which root this tree now is. + * + * Called by a promoted attempt and by nothing else. It moves no bytes: the + * attempt did that, and this says what they are. + */ + accept(workspaceRootId: string): void; +} + +/** One disposable place to make a mutation, and the way to keep it. */ +export interface Attempt { + readonly at: HostPath; + /** What the attempt now describes, captured and checked locally. */ + capture(): Operation; + /** + * Make this attempt the accepted materialization. + * + * Called only after the owner reports the commit performed. Anything else — + * a refusal, a lost response, a local failure — leaves the accepted tree + * where it was, because until the owner says otherwise the run is still at + * the root it started from. + */ + promote(): 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"); + const at: HostPath = (logical) => join(root, logical); + yield* materializeWorkspaceRoot(files, reads, at, workspaceRootId, reject); + let accepted = workspaceRootId; + yield* provide({ + get workspaceRootId(): string { + return accepted; + }, + at, + accept(next: string): void { + accepted = next; + }, + }); + }); +} + +/** + * 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 root = yield* trees.create("attempt"); + const at: HostPath = (logical) => join(root, logical); + yield* materializeWorkspaceRoot(files, reads, at, materialization.workspaceRootId, reject); + + let promoted = 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 (!promoted) { + yield* trees.remove(root); + } + }); + + yield* provide({ + at, + *capture(): Operation { + return yield* captureWorkspace(files, at, reject); + }, + *promote(): Operation { + const captured = yield* captureWorkspace(files, at, reject); + promoted = true; + materialization.accept(captured.root.rootId); + }, + }); + }); +} + +/** + * 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 join(root: string, logical: string): string { + return logical === "/" ? root : `${root}/${logical.slice(1)}`; +} diff --git a/packages/workflow/tests/remote-publication.test.ts b/packages/workflow/tests/remote-publication.test.ts new file mode 100644 index 000000000..26659c8cd --- /dev/null +++ b/packages/workflow/tests/remote-publication.test.ts @@ -0,0 +1,465 @@ +/** + * 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 { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import { type Operation, scoped, sleep, spawn, until } from "effection"; +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import { cloudflareOwnerLink } from "../src/cloudflare/client.ts"; +import { runnerFiles, useRunnerTrees } from "../src/deno/remote-files.ts"; +import { 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 { ProposedContent, 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"; + +function serve( + captured: Awaited> extends infer T ? never : never, +): never { + throw new Error("unused"); +} + +/** 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. + * + * `sizes` is what the owner would have measured after decoding the bytes it was + * sent. The runner checks that against what it sent, so an owner that guessed + * would be answering about content it had not read. + */ +function ownerAnswers(rootId: string, sizes: ReadonlyMap = new Map()) { + return (request: Record): Record => { + if (request["command"] === "stage") { + return { + outcome: "performed", + value: { + kind: request["kind"], + digest: request["digest"], + size: sizes.get(String(request["digest"])) ?? 0, + }, + }; + } + return { + outcome: "performed", + value: { workspaceRootId: rootId, journalEventIds: ["e1"] }, + }; + }; +} + +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 { + // 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(); + yield* scoped(function* () { + 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(), (kind, digest) => + kind === "manifest" + ? proposed.contents.get(digest)?.manifestBytes + : proposed.blobs.get(digest), + ); + const committed = yield* transactRemotely( + link, + createTransactionGate(), + function* (transaction, enlist) { + yield* transaction.journal.append(event("published")); + enlist({ + publication: { + proposedWorkspaceRootId: proposed.root.rootId, + proposedManifest: proposed.root.manifest, + content: [ + ...proposed.root.manifests.map((digest) => ({ + kind: "manifest" as const, + digest, + size: proposed.contents.get(digest)?.manifestBytes.length ?? 0, + })), + ...proposed.root.blobs.map((digest) => ({ + kind: "blob" as const, + digest, + size: proposed.blobs.get(digest)?.length ?? 0, + })), + ], + }, + mappings: [ + { + kind: "repository", + locator: LOCATOR, + record: { + name: "app", + locatorFingerprint: locatorFingerprintOf(LOCATOR), + requestedBase: null, + creationCommit: "9".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", + checkoutPath: "/docs", + }, + }, + ], + }); + return "done"; + }, + ); + expect(committed).toMatchObject({ ok: true }); + + // The last request is one closed commit carrying the whole proposal. + const commit = transport.sent.at(-1); + expect(commit?.["command"]).toBe("commit"); + expect(commit?.["expectedWorkspaceRootId"]).toBe(captured.root.rootId); + expect(commit?.["events"]).toEqual([serializeDurableEvent(event("published"))]); + const publication = commit?.["publication"] as Record; + expect(publication["proposedWorkspaceRootId"]).toBe(proposed.root.rootId); + expect(sha256Hex(`${WORKSPACE_ROOT_DOMAIN}${String(publication["proposedManifest"])}`)).toBe( + proposed.root.rootId, + ); + const mappings = commit?.["mappings"] as Record[]; + expect(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("keeps the accepted root until the owner performs the commit", function* () { + const files = runnerFiles(); + yield* scoped(function* () { + const trees = yield* useRunnerTrees(); + const { captured, reads } = yield* startingTree(); + 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"), "not published\n", { mode: 0o644 })); + // The owner refused, so nothing promotes. + }); + expect(materialization.workspaceRootId).toBe(captured.root.rootId); + + yield* scoped(function* () { + const attempt = yield* useAttempt(files, trees, reads, materialization, reject); + yield* until(writeFile(attempt.at("/NOTES.md"), "published\n", { mode: 0o644 })); + yield* attempt.promote(); + }); + // Only a promoted attempt moves what the run is at. + expect(materialization.workspaceRootId).not.toBe(captured.root.rootId); + }); + }); + + it("cannot be changed by a caller that kept its own copy", 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 content: ProposedContent[] = [{ kind: "manifest", digest: "a".repeat(64), size: 1 }]; + const mappings: RetainedMapping[] = [ + { + kind: "repository", + locator: LOCATOR, + record: { + name: "app", + locatorFingerprint: locatorFingerprintOf(LOCATOR), + requestedBase: null, + creationCommit: "9".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", + checkoutPath: "/docs", + }, + }, + ]; + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist({ + publication: { + proposedWorkspaceRootId: captured.root.rootId, + proposedManifest: captured.root.manifest, + content, + }, + mappings, + }); + // The caller still holds both arrays and edits them after admission. + content.push({ kind: "blob", digest: "b".repeat(64), size: 2 }); + const first = mappings[0]; + if (first?.kind === "repository") { + mappings[0] = { ...first, locator: "https://elsewhere.invalid/x.git" }; + } + return "done"; + }); + const commit = transport.sent.at(-1); + const publication = commit?.["publication"] as Record; + expect((publication["content"] as unknown[]).length).toBe(1); + expect((commit?.["mappings"] as Record[])[0]?.["locator"]).toBe(LOCATOR); + }); + }); + + it("refuses a second Workspace publication in one transaction", function* () { + const files = runnerFiles(); + void files; + yield* scoped(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 proposal = { + publication: { + proposedWorkspaceRootId: captured.root.rootId, + proposedManifest: captured.root.manifest, + content: [], + }, + mappings: [], + }; + let raised: unknown; + try { + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist(proposal); + enlist(proposal); + 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 index cf4b1e658..e0b0f4fbe 100644 --- a/packages/workflow/tests/remote-read.test.ts +++ b/packages/workflow/tests/remote-read.test.ts @@ -409,7 +409,12 @@ describe("semantic reads from a Cloudflare owner", () => { let committed: unknown; yield* scoped(function* () { const connection = yield* useOwnerConnection(transport.socket); - const link = cloudflareOwnerLink(cloudflareReadLink(connection, ids(), RUN_ID)); + 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. From dd08600fe153c8949c3bac7158873cea9655ede0 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 19:51:25 -0400 Subject: [PATCH 29/42] =?UTF-8?q?=F0=9F=A7=B9=20Read=20the=20sent=20comman?= =?UTF-8?q?d=20instead=20of=20asserting=20its=20shape=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production-path test carried a dead helper, two unused bindings, two redundant scopes and a chain of assertions that told the compiler what the request was rather than checking it. What is being proved is the shape of what the runner sent, so it is read through narrowing helpers that fail with the member that disagreed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- .../workflow/tests/remote-publication.test.ts | 194 ++++++++++-------- 1 file changed, 109 insertions(+), 85 deletions(-) diff --git a/packages/workflow/tests/remote-publication.test.ts b/packages/workflow/tests/remote-publication.test.ts index 26659c8cd..437f6bfd2 100644 --- a/packages/workflow/tests/remote-publication.test.ts +++ b/packages/workflow/tests/remote-publication.test.ts @@ -51,12 +51,6 @@ function event(name: string) { const LOCATOR = "https://git.example.invalid/octo/app.git"; -function serve( - captured: Awaited> extends infer T ? never : never, -): never { - throw new Error("unused"); -} - /** A recording connection: every request it was sent, and canned answers. */ function wire(answer: (request: Record) => Record) { const sent: Record[] = []; @@ -125,6 +119,48 @@ function ownerAnswers(rootId: string, sizes: ReadonlyMap = new M }; } +/** 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)}`; @@ -273,17 +309,15 @@ describe("what the production runner publishes", () => { expect(committed).toMatchObject({ ok: true }); // The last request is one closed commit carrying the whole proposal. - const commit = transport.sent.at(-1); - expect(commit?.["command"]).toBe("commit"); - expect(commit?.["expectedWorkspaceRootId"]).toBe(captured.root.rootId); - expect(commit?.["events"]).toEqual([serializeDurableEvent(event("published"))]); - const publication = commit?.["publication"] as Record; + 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, ); - const mappings = commit?.["mappings"] as Record[]; - expect(mappings[0]?.["locator"]).toBe(LOCATOR); + 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); @@ -382,84 +416,74 @@ describe("what the production runner publishes", () => { }); it("cannot be changed by a caller that kept its own copy", 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 content: ProposedContent[] = [{ kind: "manifest", digest: "a".repeat(64), size: 1 }]; - const mappings: RetainedMapping[] = [ - { - kind: "repository", - locator: LOCATOR, - record: { - name: "app", - locatorFingerprint: locatorFingerprintOf(LOCATOR), - requestedBase: null, - creationCommit: "9".repeat(40), - primaryBranch: "main", - objectFormat: "sha1", - checkoutPath: "/docs", - }, + 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 content: ProposedContent[] = [{ kind: "manifest", digest: "a".repeat(64), size: 1 }]; + const mappings: RetainedMapping[] = [ + { + kind: "repository", + locator: LOCATOR, + record: { + name: "app", + locatorFingerprint: locatorFingerprintOf(LOCATOR), + requestedBase: null, + creationCommit: "9".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", + checkoutPath: "/docs", }, - ]; - yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { - enlist({ - publication: { - proposedWorkspaceRootId: captured.root.rootId, - proposedManifest: captured.root.manifest, - content, - }, - mappings, - }); - // The caller still holds both arrays and edits them after admission. - content.push({ kind: "blob", digest: "b".repeat(64), size: 2 }); - const first = mappings[0]; - if (first?.kind === "repository") { - mappings[0] = { ...first, locator: "https://elsewhere.invalid/x.git" }; - } - return "done"; - }); - const commit = transport.sent.at(-1); - const publication = commit?.["publication"] as Record; - expect((publication["content"] as unknown[]).length).toBe(1); - expect((commit?.["mappings"] as Record[])[0]?.["locator"]).toBe(LOCATOR); - }); - }); - - it("refuses a second Workspace publication in one transaction", function* () { - const files = runnerFiles(); - void files; - yield* scoped(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 proposal = { + }, + ]; + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist({ publication: { proposedWorkspaceRootId: captured.root.rootId, proposedManifest: captured.root.manifest, - content: [], + content, }, - mappings: [], - }; - let raised: unknown; - try { - yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { - enlist(proposal); - enlist(proposal); - return "done"; - }); - } catch (error) { - raised = error; + mappings, + }); + // The caller still holds both arrays and edits them after admission. + content.push({ kind: "blob", digest: "b".repeat(64), size: 2 }); + const first = mappings[0]; + if (first?.kind === "repository") { + mappings[0] = { ...first, locator: "https://elsewhere.invalid/x.git" }; } - // 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); + return "done"; }); + const commit = lastCommit(transport.sent); + expect(memberList(member(commit, "publication"), "content")).toHaveLength(1); + expect(text(memberList(commit, "mappings")[0] ?? {}, "locator")).toBe(LOCATOR); + }); + + it("refuses a second Workspace publication in one transaction", 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 proposal = { + publication: { + proposedWorkspaceRootId: captured.root.rootId, + proposedManifest: captured.root.manifest, + content: [], + }, + mappings: [], + }; + let raised: unknown; + try { + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist(proposal); + enlist(proposal); + 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); }); }); From 867e9a8df08275d3b8ab2af16674bf718cb8dd77 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 19:56:21 -0400 Subject: [PATCH 30/42] =?UTF-8?q?=F0=9F=A4=9D=20Prove=20the=20two=20captur?= =?UTF-8?q?e=20implementations=20describe=20one=20Workspace=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local host walks the content 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. Until now the round trip proved only that the runner agreed with itself. The fixture is now the local provider's own capture. A real run database is opened, the discriminating tree is written through the authoritative Workspace transaction, and `capture({ publish: true })` retains the root exactly as a run does. That root and the content rows it closes over are 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 one string. It found the disagreement it was written to find. The retained format carries modification times in milliseconds, because the retaining host's clock is `Date.now`; the runner adapter reported seconds. Every retained tree would have looked like a different one, and writing a millisecond value through an interface that speaks seconds would have put each file tens of thousands of years from now, where the filesystem cannot keep it. The adapter now preserves the unit the format uses and converts only where the host primitive requires it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/deno/remote-files.ts | 21 +- .../tests/remote-interoperability.test.ts | 190 ++++++++++++++++++ .../tests/remote-materialization.test.ts | 2 +- 3 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 packages/workflow/tests/remote-interoperability.test.ts diff --git a/packages/workflow/src/deno/remote-files.ts b/packages/workflow/src/deno/remote-files.ts index fafe82364..1e807706b 100644 --- a/packages/workflow/src/deno/remote-files.ts +++ b/packages/workflow/src/deno/remote-files.ts @@ -37,9 +37,17 @@ import { ensure, type Operation, resource, until } from "effection"; import type { RunnerFiles, RunnerNode } from "../remote/materialize.ts"; import type { TemporaryTrees } from "../remote/invocation.ts"; -/** Whole seconds, which is what a retained entry records. */ -function seconds(milliseconds: number): number { - return Math.floor(milliseconds / 1000); +/** + * 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( @@ -63,7 +71,7 @@ function describeStats( // a retained mode that carried them would not round-trip through the // format's own bound. mode: stats.mode & 0o7777, - mtime: seconds(stats.mtimeMs), + 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. @@ -114,7 +122,8 @@ export function runnerFiles(): RunnerFiles { }, *setModifiedAt(path: string, mtime: number): Operation { - yield* until(utimes(path, mtime, mtime)); + // `utimes` speaks seconds; the format speaks milliseconds. + yield* until(utimes(path, mtime / 1000, mtime / 1000)); }, /** @@ -125,7 +134,7 @@ export function runnerFiles(): RunnerFiles { * entirely. */ *setLinkModifiedAt(path: string, mtime: number): Operation { - yield* until(lutimes(path, mtime, mtime)); + yield* until(lutimes(path, mtime / 1000, mtime / 1000)); }, /** diff --git a/packages/workflow/tests/remote-interoperability.test.ts b/packages/workflow/tests/remote-interoperability.test.ts new file mode 100644 index 000000000..abe7e7a66 --- /dev/null +++ b/packages/workflow/tests/remote-interoperability.test.ts @@ -0,0 +1,190 @@ +/** + * 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 { 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 { + // 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-materialization.test.ts b/packages/workflow/tests/remote-materialization.test.ts index b5d09d5dd..79c5ec4ff 100644 --- a/packages/workflow/tests/remote-materialization.test.ts +++ b/packages/workflow/tests/remote-materialization.test.ts @@ -195,7 +195,7 @@ describe("materializing a retained Workspace root", () => { // 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); + 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. From aec12b80a8a29aeba222822cd01a1ae4d9cfbfc0 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Thu, 3 Sep 2026 22:40:26 -0400 Subject: [PATCH 31/42] =?UTF-8?q?=F0=9F=8E=AF=20Make=20a=20retry=20the=20s?= =?UTF-8?q?ame=20question,=20and=20seal=20only=20after=20teardown=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completing the discriminating regressions `698-e-6.md` asked for surfaced two defects in what had just been built. The command identity was counted rather than derived. The owner recognizes a retry by that identity and returns the decision it already made — so an identity that changed between attempts would ask a second question, and the owner would apply the proposal twice. It is now a digest of the exact bytes being sent: two attempts at one proposal share an identity, two different proposals cannot, and a retry is byte-equivalent. That retry necessarily happens on a new connection, because the one that lost the answer is gone and a connection refuses to reuse a correlation id of its own; the identity is stable across connections, which is where it has to be. And the body ran through `call()`, which let a resource whose teardown failed surface its failure after the commit had already gone out. The comment above it claimed the opposite. The body now runs in a scope closed before the intent is built, so "no commit was sent" and "the body did not finish" really are one statement — the one ordering here that cannot be taken back. The rest of the list is now covered: a journal-only commit proposing nothing and staging nothing, all three retained mapping kinds encoded with the locator only where one belongs, a refused answer and a lost answer each promoting nothing and leaving no attempt tree, and a transaction past its local bound sending nothing. The materialization suite uses the production trees resource rather than a helper of its own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/client.ts | 21 +- packages/workflow/src/remote/collector.ts | 12 +- .../tests/remote-materialization.test.ts | 25 +- .../workflow/tests/remote-publication.test.ts | 296 +++++++++++++++++- 4 files changed, 326 insertions(+), 28 deletions(-) diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts index 36901d953..2e06066f7 100644 --- a/packages/workflow/src/cloudflare/client.ts +++ b/packages/workflow/src/cloudflare/client.ts @@ -390,11 +390,14 @@ export function cloudflareOwnerLink( }, *commit(intent: CommitIntent): Operation> { - // Minted before anything is sent, and reused for the life of this intent. - const id = nextId(); + // 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, bytesOf); - const request = commitRequest(intent); const answered = yield* ask(connection, id, request); return answered.outcome === "refused" ? Err(new CloudflareOwnerRefusalError(answered.refusal)) @@ -413,6 +416,18 @@ export function cloudflareOwnerLink( }; } +/** + * 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, with the private refusal narrowed. */ function* ask( connection: OwnerConnection, diff --git a/packages/workflow/src/remote/collector.ts b/packages/workflow/src/remote/collector.ts index 1fec62b12..52a9dba3f 100644 --- a/packages/workflow/src/remote/collector.ts +++ b/packages/workflow/src/remote/collector.ts @@ -22,7 +22,7 @@ * tries to infer what the body did. */ -import { call, ensure, Ok, type Operation, type Result } from "effection"; +import { call, ensure, Ok, type Operation, type Result, scoped } from "effection"; import type { RetainedMapping, WorkspacePublication } from "./publication.ts"; import type { DurableEvent } from "@executablemd/durable-streams"; import type { DurableStream } from "@executablemd/durable-streams"; @@ -236,9 +236,13 @@ export function transactRemotely( let outcome: T; try { - // Everything the body started tears down before the intent is built, so - // "no commit was sent" and "the body did not finish" are one statement. - outcome = yield* call(() => body({ journal }, enlist)); + // 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)); } finally { // The handle is closed before the commit goes out, so a retained // transaction object refuses while the handle-level gate is still held. diff --git a/packages/workflow/tests/remote-materialization.test.ts b/packages/workflow/tests/remote-materialization.test.ts index 79c5ec4ff..ab68ec386 100644 --- a/packages/workflow/tests/remote-materialization.test.ts +++ b/packages/workflow/tests/remote-materialization.test.ts @@ -31,7 +31,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import process from "node:process"; -import { runnerFiles } from "../src/deno/remote-files.ts"; +import { runnerFiles, useRunnerTrees } from "../src/deno/remote-files.ts"; import { captureWorkspace, materializeWorkspaceRoot, @@ -46,21 +46,6 @@ function reject(reason: string): never { throw new Error(reason); } -/** - * A temporary directory owned by the scope that asked for it. - * - * A resource rather than a scoped operation: the tree has to outlive the call - * that created it and end with the invocation that owns it, which is the whole - * lifetime claim materialization makes. - */ -function useTemporaryDirectory(): Operation { - return resource(function* (provide) { - const path = yield* until(mkdtemp(join(tmpdir(), "xmd-materialize-"))); - yield* ensure(() => until(rm(path, { recursive: true, force: true }))); - yield* provide(path); - }); -} - /** Where one logical Workspace path sits under `root`. */ function at(root: string): (logical: string) => string { return (logical) => (logical === "/" ? root : join(root, logical.slice(1))); @@ -168,7 +153,8 @@ describe("materializing a retained Workspace root", () => { // explicit rather than incidental. const previous = process.umask(0o022); const files: RunnerFiles = runnerFiles(); - const source = yield* useTemporaryDirectory(); + const trees = yield* useRunnerTrees(); + const source = yield* trees.create("source"); yield* buildTree(source); const captured = yield* captureWorkspace(files, at(source), reject); @@ -205,7 +191,7 @@ describe("materializing a retained Workspace root", () => { } expect(captured.contents.get(large.manifest)?.chunks).toHaveLength(2); - const destination = yield* useTemporaryDirectory(); + const destination = yield* trees.create("destination"); yield* materializeWorkspaceRoot( files, servedBy(captured), @@ -235,7 +221,8 @@ describe("materializing a retained Workspace root", () => { const files: RunnerFiles = runnerFiles(); let path = ""; yield* scoped(function* () { - path = yield* useTemporaryDirectory(); + 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 diff --git a/packages/workflow/tests/remote-publication.test.ts b/packages/workflow/tests/remote-publication.test.ts index 437f6bfd2..5cfee8bd1 100644 --- a/packages/workflow/tests/remote-publication.test.ts +++ b/packages/workflow/tests/remote-publication.test.ts @@ -14,11 +14,16 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { serializeDurableEvent } from "@executablemd/durable-streams"; -import { type Operation, scoped, sleep, spawn, until } from "effection"; +import { ensure, type Operation, scoped, sleep, spawn, until } from "effection"; import { mkdir, readdir, 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 { createTransactionGate, transactRemotely } from "../src/remote/collector.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 { @@ -51,6 +56,23 @@ function event(name: string) { 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[] = []; @@ -458,6 +480,276 @@ describe("what the production runner publishes", () => { expect(text(memberList(commit, "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* () { + yield* scoped(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()); + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist({ + publication: { + proposedWorkspaceRootId: captured.root.rootId, + proposedManifest: captured.root.manifest, + content: [], + }, + mappings: [ + 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: [], + }; + 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* () { + yield* scoped(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: [], + }; + 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* () { + yield* scoped(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()); + let raised: unknown; + try { + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist({ + publication: { + proposedWorkspaceRootId: captured.root.rootId, + proposedManifest: captured.root.manifest, + content: [], + }, + // More retained mappings than one intent may carry. + mappings: 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); + }); + }); + it("refuses a second Workspace publication in one transaction", function* () { const { captured, reads } = yield* startingTree(); const transport = wire(ownerAnswers(captured.root.rootId)); From 11236ff9ce8ffe9fc950280a58436c82eab347d6 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Fri, 4 Sep 2026 08:14:00 -0400 Subject: [PATCH 32/42] =?UTF-8?q?=F0=9F=A7=BE=20Make=20a=20decision=20surv?= =?UTF-8?q?ive=20the=20connection=20that=20asked=20for=20it=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ways a committed run could fail to converge. A mutation's decision lived in the acquisition-scoped ledger, and a replacement acquisition discards its predecessor's scratch. That is right for staged bytes and read decisions, which belong to the connection that produced them, and wrong for the fact that a mutation was applied — which is the one thing the next connection needs to tell "this already happened" from "this never happened". So the exact case the retry exists for was the case it could not answer: if the commit landed, the same request met a moved frontier and was refused as stale; if it did not land, the same request performed; and the runner could not tell those histories apart, so it could not know whether to promote or discard. Mutation decisions are now keyed by the run, cleanup never touches them, and a reused identity with different bytes is still a conflict. A decision is not a lease and does not expire because a socket did. A performed answer was read for syntax and thrown away. The adapter accepted an answer naming any root — including the root the run was already at while the proposal published a different one — and returned success. The answer is now checked against what was asked: the root the proposal selected, and one event identity for each event sent. An owner agreeing to something else is not an owner this runner can go on talking to. And promotion took no evidence and moved no bytes. Any holder could call it before a commit, after a refusal or after an ambiguous loss, and it relabelled the accepted tree with the new root while the promoted tree stayed unreachable — so the invocation would read the Workspace it used to be at under the name of the one it is now at. Promotion now requires the owner's performed decision, and that decision has to name the root this attempt actually captured. The attempt's own tree becomes the accepted materialization, the accepted path answers with the promoted bytes, and the tree the run used to be at is removed. Sealing also reached further than it looked. A shallow copy left an Agent session's provider assertion shared with the caller, and the content bytes were fetched through a callback at staging time rather than captured at enlistment — so a caller could change the retained session identity, or the bytes staged under a digest, after the transaction had sealed. Mappings are now copied all the way down and the bytes travel with the intent, checked against the identity they were proposed under before any stage request. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/client.ts | 65 +- .../workflow/src/cloudflare/dispatcher.ts | 47 +- .../workflow/src/cloudflare/private-schema.ts | 38 +- packages/workflow/src/remote/collector.ts | 63 +- packages/workflow/src/remote/invocation.ts | 94 ++- packages/workflow/src/remote/publication.ts | 18 + .../tests/cloudflare/remote-owner.vitest.ts | 69 +-- .../tests/cloudflare/remote-publish.vitest.ts | 52 ++ .../workflow/tests/remote-publication.test.ts | 563 +++++++++++------- .../workflow/tests/remote-transaction.test.ts | 24 +- 10 files changed, 731 insertions(+), 302 deletions(-) diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts index 2e06066f7..90f2d201e 100644 --- a/packages/workflow/src/cloudflare/client.ts +++ b/packages/workflow/src/cloudflare/client.ts @@ -32,6 +32,7 @@ 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"; import { parseRemoteJournalEntry, @@ -360,15 +361,6 @@ export function cloudflareReadLink( }; } -/** - * The bytes one proposed piece is made of, when the owner has to be sent them. - * - * A runner holds the content it captured; the owner may already hold some of - * it. Supplying bytes by identity lets the adapter stage only what is missing - * rather than resending a Workspace the owner never lost. - */ -export type ProposedBytes = (kind: "manifest" | "blob", digest: string) => Uint8Array | undefined; - /** * The runner's production link to its owner. * @@ -382,14 +374,13 @@ export function cloudflareOwnerLink( connection: OwnerConnection, reads: RemoteReadLink, nextId: () => string, - bytesOf: ProposedBytes = () => undefined, ): OwnerLink { return { *frontier(): Operation { return startingFrontier(yield* reads.frontier()); }, - *commit(intent: CommitIntent): Operation> { + *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 @@ -397,11 +388,11 @@ export function cloudflareOwnerLink( const request = commitRequest(intent); const id = commandIdentity(request); try { - yield* stageMissing(connection, nextId, intent, bytesOf); - const answered = yield* ask(connection, id, request); + yield* stageMissing(connection, nextId, intent); + const answered = yield* ask(connection, id, request, intent); return answered.outcome === "refused" ? Err(new CloudflareOwnerRefusalError(answered.refusal)) - : Ok(); + : Ok(answered.decision); } catch (error) { if (error instanceof OwnerLinkError) { // The connection went while the answer was in flight. Whether the @@ -428,31 +419,52 @@ function commandIdentity(request: Record): string { return `commit-${sha256Hex(JSON.stringify(request))}`; } -/** One command sent and one answer read, with the private refusal narrowed. */ +/** + * 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, -): Operation<{ outcome: "performed" } | { outcome: "refused"; refusal: PrivateRefusal }> { + 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) => { - // A performed commit answers with what it published. Reading it proves - // the owner and this build agree about what just happened. + (value): CommitDecision => { const found = members(value, ["workspaceRootId", "journalEventIds"]); - rootId(found.get("workspaceRootId")); + 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"); } - return true; + 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" }; + : { outcome: "performed", decision: offered.value }; } /** @@ -468,18 +480,23 @@ function* stageMissing( connection: OwnerConnection, nextId: () => string, intent: CommitIntent, - bytesOf: ProposedBytes, ): Operation { if (intent.publication === null) { return; } for (const piece of intent.publication.content) { - const bytes = bytesOf(piece.kind, piece.digest); + 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); } } diff --git a/packages/workflow/src/cloudflare/dispatcher.ts b/packages/workflow/src/cloudflare/dispatcher.ts index 26df1b59c..607bd08a2 100644 --- a/packages/workflow/src/cloudflare/dispatcher.ts +++ b/packages/workflow/src/cloudflare/dispatcher.ts @@ -46,7 +46,7 @@ import { import { bytesOf, decodeBase64, sha256Hex } from "./encoding.ts"; import { readContent, readFrontier, readJournalPage, readRoot } from "./owner-reads.ts"; import type { OwnerTransactions } from "./owner-transaction.ts"; -import { COMMAND_TABLE, STAGING_TABLE } from "./private-schema.ts"; +import { COMMAND_TABLE, MUTATION_TABLE, STAGING_TABLE } from "./private-schema.ts"; import { applyCommit } from "./publish.ts"; import { recognizeObject } from "./recognition.ts"; @@ -240,6 +240,32 @@ export function dispatchCommand( throw new CommandError("duplicate-conflict"); } 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. + if (command.command === "commit") { + 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"); + } + return decision; + } + } + const previous = ctx.storage.sql .exec( `SELECT request_fingerprint, response FROM ${COMMAND_TABLE} @@ -286,6 +312,25 @@ export function dispatchCommand( encoded, responseBytes, ); + if (command.command === "commit") { + // Recorded in this same transaction as the mutation it describes, so a + // crash cannot leave one without the other. + const mutations = ctx.storage.sql + .exec(`SELECT count(*) AS decided FROM ${MUTATION_TABLE}`) + .toArray()[0]; + if (integer(mutations?.["decided"]) >= MAX_COMMANDS) { + throw new CommandError("capacity"); + } + ctx.storage.sql.exec( + `INSERT INTO ${MUTATION_TABLE} + (command_id, request_fingerprint, response, response_bytes) + VALUES (?, ?, ?, ?)`, + command.id, + fingerprint, + encoded, + responseBytes, + ); + } return result; }); } diff --git a/packages/workflow/src/cloudflare/private-schema.ts b/packages/workflow/src/cloudflare/private-schema.ts index 4353cf56e..ee3b6ae39 100644 --- a/packages/workflow/src/cloudflare/private-schema.ts +++ b/packages/workflow/src/cloudflare/private-schema.ts @@ -28,6 +28,24 @@ 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"; const COMMAND_SQL = `CREATE TABLE ${COMMAND_TABLE} ( @@ -52,15 +70,25 @@ const STAGING_SQL = `CREATE TABLE ${STAGING_TABLE} ( PRIMARY KEY (acquisition_id, kind, digest) ) 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) +) 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 }], ]); export const PRIVATE_OBJECT_NAMES: readonly string[] = Object.freeze([...PRIVATE_OBJECTS.keys()]); export function initializePrivateSchema(storage: OwnerStorage): void { - storage.sql.exec(`${COMMAND_SQL};\n\n${STAGING_SQL};`); + storage.sql.exec(`${COMMAND_SQL};\n\n${STAGING_SQL};\n\n${MUTATION_SQL};`); } export function privateStructureFailure( @@ -79,6 +107,14 @@ export function privateStructureFailure( 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); diff --git a/packages/workflow/src/remote/collector.ts b/packages/workflow/src/remote/collector.ts index 52a9dba3f..85af88b60 100644 --- a/packages/workflow/src/remote/collector.ts +++ b/packages/workflow/src/remote/collector.ts @@ -23,7 +23,7 @@ */ import { call, ensure, Ok, type Operation, type Result, scoped } from "effection"; -import type { RetainedMapping, WorkspacePublication } from "./publication.ts"; +import type { CommitDecision, RetainedMapping, WorkspacePublication } from "./publication.ts"; import type { DurableEvent } from "@executablemd/durable-streams"; import type { DurableStream } from "@executablemd/durable-streams"; import type { WorkflowRunTransaction } from "../storage/api.ts"; @@ -68,6 +68,8 @@ export interface CommitIntent { 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; } /** @@ -80,6 +82,20 @@ export interface CommitIntent { export interface WorkspaceEnlistment { readonly publication: WorkspacePublication; readonly mappings: readonly RetainedMapping[]; + /** + * The bytes for every piece the publication names, by identity. + * + * Supplied at enlistment rather than fetched later. The proposal's identity + * is a digest over content, so bytes that could still change after the + * transaction sealed would let one identity describe two different + * Workspaces — and the adapter would stage whatever the buffer happened to + * hold by the time it looked. + * + * Only pieces the owner may not already hold need appear. What is absent is + * content the owner is expected to have, and a proposal naming content + * nobody can supply is refused rather than guessed at. + */ + readonly bytes: ReadonlyMap; } /** What the collector needs from the connection. */ @@ -87,7 +103,7 @@ 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>; + commit(intent: CommitIntent): Operation>; } /** The most events one intent may carry. */ @@ -256,6 +272,7 @@ export function transactRemotely( events: appended.map((event) => structuredClone(event)), publication: enlisted?.publication ?? null, mappings: enlisted?.mappings ?? [], + bytes: enlisted?.bytes ?? new Map(), }); if (!committed.ok) { return committed; @@ -283,14 +300,44 @@ function detach(proposal: WorkspaceEnlistment): WorkspaceEnlistment { proposedWorkspaceRootId: proposal.publication.proposedWorkspaceRootId, proposedManifest: proposal.publication.proposedManifest, content: Object.freeze( - proposal.publication.content.map((piece) => Object.freeze({ ...piece })), + proposal.publication.content.map((piece) => + Object.freeze({ kind: piece.kind, digest: piece.digest, size: piece.size }), + ), ), }), - mappings: Object.freeze( - proposal.mappings.map((mapping) => - Object.freeze({ ...mapping, record: Object.freeze({ ...mapping.record }) }), - ), - ) as readonly RetainedMapping[], + mappings: Object.freeze(proposal.mappings.map(detachMapping)), + // A copy of every buffer, not a reference to one. A caller that goes on + // writing into the array it captured must not be able to change what this + // proposal stages. + bytes: new Map([...proposal.bytes].map(([digest, bytes]) => [digest, bytes.slice()])), + }); +} + +/** + * 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 }), + }), }); } diff --git a/packages/workflow/src/remote/invocation.ts b/packages/workflow/src/remote/invocation.ts index ffb6c0569..d736fcc1c 100644 --- a/packages/workflow/src/remote/invocation.ts +++ b/packages/workflow/src/remote/invocation.ts @@ -31,6 +31,7 @@ import { type RunnerFiles, } from "./materialize.ts"; import type { RemoteReadLink } from "./read.ts"; +import type { CommitDecision } from "./publication.ts"; /** A directory this invocation owns for as long as it needs one. */ export interface TemporaryTrees { @@ -44,15 +45,15 @@ export interface TemporaryTrees { export interface Materialization { /** The root this tree is, as the owner confirmed it. */ readonly workspaceRootId: string; - /** Where a logical Workspace path sits in this tree. */ - readonly at: HostPath; /** - * Record which root this tree now is. + * Where a logical Workspace path sits in the accepted tree. * - * Called by a promoted attempt and by nothing else. It moves no bytes: the - * attempt did that, and this says what they are. + * 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. */ - accept(workspaceRootId: string): void; + at(logical: string): string; } /** One disposable place to make a mutation, and the way to keep it. */ @@ -63,12 +64,14 @@ export interface Attempt { /** * Make this attempt the accepted materialization. * - * Called only after the owner reports the commit performed. Anything else — - * a refusal, a lost response, a local failure — leaves the accepted tree - * where it was, because until the owner says otherwise the run is still at - * the root it started from. + * It takes the owner's performed decision because that decision is the + * authority: nothing else may promote, and a decision naming a different root + * than this attempt captured is not this attempt's decision. Passing it is + * the proof, which is why there is no argument-free way to do this — a + * refusal, an ambiguous loss and a local failure all leave the caller with + * nothing to pass. */ - promote(): Operation; + promote(decision: CommitDecision): Operation; } /** @@ -85,24 +88,41 @@ export function useMaterialization( reads: RemoteReadLink, workspaceRootId: string, reject: WorkspaceRejection, -): Operation { +): Operation { return resource(function* (provide) { const root = yield* trees.create("accepted"); - const at: HostPath = (logical) => join(root, logical); - yield* materializeWorkspaceRoot(files, reads, at, workspaceRootId, reject); - let accepted = workspaceRootId; + yield* materializeWorkspaceRoot(files, reads, at(root), workspaceRootId, reject); + let accepted = { root, workspaceRootId }; yield* provide({ get workspaceRootId(): string { - return accepted; + return accepted.workspaceRootId; + }, + at(logical: string): string { + return at(accepted.root)(logical); }, - at, - accept(next: string): void { + *replace(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); }, }); }); } +/** + * The accepted materialization, plus the one operation that may move it. + * + * `replace` is not on `Materialization` because everything that merely reads + * the Workspace should not be able to change which Workspace it is reading. + * Only an attempt holding a performed decision reaches this. + */ +export interface AcceptedMaterialization extends Materialization { + replace(next: { root: string; workspaceRootId: string }): Operation; +} + /** * A disposable copy of the accepted tree, for one mutation. * @@ -115,13 +135,18 @@ export function useAttempt( files: RunnerFiles, trees: TemporaryTrees, reads: RemoteReadLink, - materialization: Materialization, + materialization: AcceptedMaterialization, reject: WorkspaceRejection, ): Operation { return resource(function* (provide) { const root = yield* trees.create("attempt"); - const at: HostPath = (logical) => join(root, logical); - yield* materializeWorkspaceRoot(files, reads, at, materialization.workspaceRootId, reject); + yield* materializeWorkspaceRoot( + files, + reads, + at(root), + materialization.workspaceRootId, + reject, + ); let promoted = false; // Registered before the attempt is handed over, so every exit removes it — @@ -133,14 +158,27 @@ export function useAttempt( }); yield* provide({ - at, + at: at(root), *capture(): Operation { - return yield* captureWorkspace(files, at, reject); + return yield* captureWorkspace(files, at(root), reject); }, - *promote(): Operation { - const captured = yield* captureWorkspace(files, at, reject); + *promote(decision: CommitDecision): Operation { + if (promoted) { + // One decision promotes one attempt once. A second promotion would be + // moving the accepted tree somewhere it has already been moved from. + reject("this attempt has already been promoted"); + } + const captured = yield* captureWorkspace(files, at(root), reject); + if (decision.workspaceRootId !== captured.root.rootId) { + // The owner published something other than what this attempt holds. + // Promoting would label these bytes with a root they are not. + reject("the owner's decision names a root this attempt did not capture"); + } promoted = true; - materialization.accept(captured.root.rootId); + // The tree itself becomes the accepted one. Recording the identity + // without moving the bytes would leave the invocation reading the + // Workspace it used to be at under the name of the one it is now at. + yield* materialization.replace({ root, workspaceRootId: captured.root.rootId }); }, }); }); @@ -154,6 +192,6 @@ export function useAttempt( * host's path conventions. The logical root is `/` and everything under it is * relative to the tree this invocation was given. */ -function join(root: string, logical: string): string { - return logical === "/" ? root : `${root}/${logical.slice(1)}`; +function at(root: string): HostPath { + return (logical) => (logical === "/" ? root : `${root}/${logical.slice(1)}`); } diff --git a/packages/workflow/src/remote/publication.ts b/packages/workflow/src/remote/publication.ts index 73f157645..2c7371b5b 100644 --- a/packages/workflow/src/remote/publication.ts +++ b/packages/workflow/src/remote/publication.ts @@ -73,3 +73,21 @@ export type RetainedMapping = } | { 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/tests/cloudflare/remote-owner.vitest.ts b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts index 5c50cf934..c7519c8b1 100644 --- a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts @@ -406,15 +406,13 @@ describe("the remote owner protocol", () => { const stub = executor(); await on(stub, (owner) => owner.initialize()); await on(stub, (owner) => owner.appendJournal("before", "before")); - await admit(stub); - const first = await send(stub, "same", { command: "frontier" }); + const socket = await connect(stub); + const first = await ask(socket, "same", { command: "frontier" }); await on(stub, (owner) => owner.appendJournal("after", "after")); - expect( - await on(stub, (owner) => - record(owner.send(1, JSON.stringify({ command: "frontier", id: "same" }))), - ), - ).toEqual(first); - expect(await send(stub, "same", { command: "root", workspaceRootId: ROOT_ID })).toEqual({ + // 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", @@ -497,36 +495,35 @@ describe("the remote owner protocol", () => { }); it("leaves no staged row when decoding or digest validation fails", async () => { - const stub = executor(); - await on(stub, (owner) => owner.initialize()); - await admit(stub); const bytes = new TextEncoder().encode("piece"); - expect( - await send(stub, "bad-base64", { - command: "stage", - kind: "blob", - digest: sha256Hex(bytes), - bytes: "not base64", - }), - ).toMatchObject({ outcome: "refused", refusal: "command:malformed-member" }); - expect( - await send(stub, "bad-digest", { - command: "stage", - kind: "blob", - digest: "0".repeat(64), - bytes: encodeBase64(bytes), - }), - ).toMatchObject({ outcome: "refused", refusal: "command:malformed-member" }); const oversized = new Uint8Array(MAX_CONTENT_BYTES + 1); - expect( - await send(stub, "oversized", { - command: "stage", - kind: "blob", - digest: sha256Hex(oversized), - bytes: encodeBase64(oversized), - }), - ).toMatchObject({ outcome: "refused", refusal: "command:too-large" }); - expect(await on(stub, (owner) => owner.scratch())).toEqual({ commands: 0, staged: 0 }); + // 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 () => { diff --git a/packages/workflow/tests/cloudflare/remote-publish.vitest.ts b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts index 5bf7b210c..d4616e2c1 100644 --- a/packages/workflow/tests/cloudflare/remote-publish.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts @@ -669,6 +669,58 @@ describe("publishing one proposal", () => { 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()); diff --git a/packages/workflow/tests/remote-publication.test.ts b/packages/workflow/tests/remote-publication.test.ts index 5cfee8bd1..e6a5ccec0 100644 --- a/packages/workflow/tests/remote-publication.test.ts +++ b/packages/workflow/tests/remote-publication.test.ts @@ -15,9 +15,10 @@ 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, writeFile } from "node:fs/promises"; +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 { encodeBase64 } from "../src/cloudflare/encoding.ts"; import { runnerFiles, useRunnerTrees } from "../src/deno/remote-files.ts"; import { type CommitIntent, @@ -56,6 +57,34 @@ function event(name: string) { const LOCATOR = "https://git.example.invalid/octo/app.git"; +/** The exact closure a proposed root names, in canonical order. */ +function inventoryOf(captured: CapturedWorkspace): ProposedContent[] { + return [ + ...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, + })), + ]; +} + +/** Every piece a capture can supply, by identity. */ +function proposedBytes(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; +} + /** The Repository mapping these tests enlist. */ function repositoryMapping(): RetainedMapping { return { @@ -118,11 +147,15 @@ function wire(answer: (request: Record) => Record = new Map()) { +function ownerAnswers(_rootId: string, sizes: ReadonlyMap = new Map()) { return (request: Record): Record => { if (request["command"] === "stage") { return { @@ -134,9 +167,18 @@ function ownerAnswers(rootId: string, sizes: ReadonlyMap = new M }, }; } + 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: rootId, journalEventIds: ["e1"] }, + value: { + workspaceRootId: selected, + journalEventIds: events.map((_entry, index) => `event-${index}`), + }, }; }; } @@ -258,92 +300,73 @@ function* startingTree(): Operation<{ captured: CapturedWorkspace; reads: Remote describe("what the production runner publishes", () => { it("sends one closed commit describing everything the transaction decided", function* () { const files = runnerFiles(); - yield* scoped(function* () { - 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 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 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(), (kind, digest) => - kind === "manifest" - ? proposed.contents.get(digest)?.manifestBytes - : proposed.blobs.get(digest), - ); - const committed = yield* transactRemotely( - link, - createTransactionGate(), - function* (transaction, enlist) { - yield* transaction.journal.append(event("published")); - enlist({ - publication: { - proposedWorkspaceRootId: proposed.root.rootId, - proposedManifest: proposed.root.manifest, - content: [ - ...proposed.root.manifests.map((digest) => ({ - kind: "manifest" as const, - digest, - size: proposed.contents.get(digest)?.manifestBytes.length ?? 0, - })), - ...proposed.root.blobs.map((digest) => ({ - kind: "blob" as const, - digest, - size: proposed.blobs.get(digest)?.length ?? 0, - })), - ], - }, - mappings: [ - { - kind: "repository", - locator: LOCATOR, - record: { - name: "app", - locatorFingerprint: locatorFingerprintOf(LOCATOR), - requestedBase: null, - creationCommit: "9".repeat(40), - primaryBranch: "main", - objectFormat: "sha1", - checkoutPath: "/docs", - }, - }, + const link = cloudflareOwnerLink(connection, reads, ids()); + const committed = yield* transactRemotely( + link, + createTransactionGate(), + function* (transaction, enlist) { + yield* transaction.journal.append(event("published")); + enlist({ + publication: { + proposedWorkspaceRootId: proposed.root.rootId, + proposedManifest: proposed.root.manifest, + content: [ + ...proposed.root.manifests.map((digest) => ({ + kind: "manifest" as const, + digest, + size: proposed.contents.get(digest)?.manifestBytes.length ?? 0, + })), + ...proposed.root.blobs.map((digest) => ({ + kind: "blob" as const, + digest, + size: proposed.blobs.get(digest)?.length ?? 0, + })), ], - }); - return "done"; - }, - ); - expect(committed).toMatchObject({ ok: true }); + }, + mappings: [repositoryMapping()], + bytes: proposedBytes(proposed), + }); + 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); - }); + // 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* () { @@ -408,33 +431,76 @@ describe("what the production runner publishes", () => { void outcomes; }); - it("keeps the accepted root until the owner performs the commit", function* () { + it("promotes only with the owner's decision, and moves the tree with it", function* () { const files = runnerFiles(); - yield* scoped(function* () { - const trees = yield* useRunnerTrees(); - const { captured, reads } = yield* startingTree(); - 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"), "not published\n", { mode: 0o644 })); - // The owner refused, so nothing promotes. - }); - expect(materialization.workspaceRootId).toBe(captured.root.rootId); + 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 = ""; + 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); + } - yield* scoped(function* () { - const attempt = yield* useAttempt(files, trees, reads, materialization, reject); - yield* until(writeFile(attempt.at("/NOTES.md"), "published\n", { mode: 0o644 })); - yield* attempt.promote(); - }); - // Only a promoted attempt moves what the run is at. - expect(materialization.workspaceRootId).not.toBe(captured.root.rootId); + // A decision the owner did not give cannot promote. + let raised: unknown; + try { + yield* attempt.promote({ workspaceRootId: captured.root.rootId, journalEventIds: [] }); + } catch (error) { + raised = error; + } + expect(raised).toBeInstanceOf(Error); + expect(materialization.workspaceRootId).toBe(captured.root.rootId); + + // The owner's own answer, naming the root this attempt captured. + const committed = yield* link.commit({ + expectedWorkspaceRootId: captured.root.rootId, + expectedJournalEventId: null, + events: [], + publication: { + proposedWorkspaceRootId: proposed.root.rootId, + proposedManifest: proposed.root.manifest, + content: inventoryOf(proposed), + }, + mappings: [], + bytes: proposedBytes(proposed), }); + if (!committed.ok) { + throw committed.error; + } + yield* attempt.promote(committed.value); + + // The accepted materialization is the promoted tree, not a relabelled + // copy of the old one: the file the effect wrote is readable through it. + expect(materialization.workspaceRootId).not.toBe(captured.root.rootId); + expect(materialization.at("/")).toBe(attemptRoot); + 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("cannot be changed by a caller that kept its own copy", function* () { @@ -466,6 +532,7 @@ describe("what the production runner publishes", () => { content, }, mappings, + bytes: new Map(), }); // The caller still holds both arrays and edits them after admission. content.push({ kind: "blob", digest: "b".repeat(64), size: 2 }); @@ -511,60 +578,59 @@ describe("what the production runner publishes", () => { }); it("encodes every kind of retained mapping the owner accepts", function* () { - yield* scoped(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()); - yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { - enlist({ - publication: { - proposedWorkspaceRootId: captured.root.rootId, - proposedManifest: captured.root.manifest, - content: [], - }, - mappings: [ - repositoryMapping(), - { - kind: "worktree", - record: { - repositoryName: "app", - name: "feature", - requestedBranch: "feature", - requestedBase: null, - creationCommit: "2".repeat(40), - checkoutPath: "/docs", - }, + const { captured, reads } = yield* startingTree(); + const transport = wire(ownerAnswers(captured.root.rootId)); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist({ + publication: { + proposedWorkspaceRootId: captured.root.rootId, + proposedManifest: captured.root.manifest, + content: [], + }, + bytes: new Map(), + mappings: [ + repositoryMapping(), + { + kind: "worktree", + record: { + repositoryName: "app", + name: "feature", + requestedBranch: "feature", + requestedBase: null, + creationCommit: "2".repeat(40), + checkoutPath: "/docs", }, - { - kind: "agent-session", - record: { + }, + { + kind: "agent-session", + record: { + provider: "acp", + agentCommand: "/usr/bin/agent", + sessionIdentity: "session-1", + sessionKey: agentSessionKey({ 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", - }, + }), + 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); + 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* () { @@ -587,6 +653,7 @@ describe("what the production runner publishes", () => { events: [], publication: null, mappings: [], + bytes: new Map(), }; const committed = yield* link.commit(intent); expect([attempt, committed.ok]).toEqual([attempt, true]); @@ -601,24 +668,23 @@ describe("what the production runner publishes", () => { }); it("asks a different question for a different proposal", function* () { - yield* scoped(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: [], - }; - 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"]); - }); + 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(), + }; + 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* () { @@ -723,31 +789,127 @@ describe("what the production runner publishes", () => { }); it("sends nothing when the transaction exceeds a local bound", function* () { - yield* scoped(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()); - let raised: unknown; - try { - yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { - enlist({ - publication: { - proposedWorkspaceRootId: captured.root.rootId, - proposedManifest: captured.root.manifest, - content: [], - }, - // More retained mappings than one intent may carry. - mappings: Array.from({ length: 300 }, () => repositoryMapping()), - }); - return "done"; + const { captured, reads } = yield* startingTree(); + const transport = wire(ownerAnswers(captured.root.rootId)); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + let raised: unknown; + try { + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist({ + publication: { + proposedWorkspaceRootId: captured.root.rootId, + proposedManifest: captured.root.manifest, + content: [], + }, + // More retained mappings than one intent may carry. + mappings: Array.from({ length: 300 }, () => repositoryMapping()), + bytes: new Map(), }); - } catch (error) { - raised = error; - } - expect(raised).toBeInstanceOf(Error); - expect(transport.sent.some((request) => request["command"] === "commit")).toBe(false); + return "done"; + }); + } catch (error) { + raised = error; + } + expect(raised).toBeInstanceOf(Error); + expect(transport.sent.some((request) => request["command"] === "commit")).toBe(false); + }); + + 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(), + }); + 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(), + }); + // One identity per event, or the two sides disagree about what history + // this commit created. + expect(committed.ok).toBe(false); + }); + + it("seals nested mapping values and content bytes against later mutation", function* () { + 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 assertion = { kind: "acp-session", value: "admitted" }; + const identity = { + provider: "acp", + agentCommand: "/usr/bin/agent", + sessionIdentity: "session-1", + }; + const piece = new TextEncoder().encode("admitted bytes"); + const digest = sha256Hex(piece); + sizes.set(digest, piece.length); + const bytes = new Map([[digest, piece]]); + + yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { + enlist({ + publication: { + proposedWorkspaceRootId: captured.root.rootId, + proposedManifest: captured.root.manifest, + content: [{ kind: "blob", digest, size: piece.length }], + }, + mappings: [ + { + kind: "agent-session", + record: { + ...identity, + sessionKey: agentSessionKey(identity), + policy: "strict", + assertion, + createdAt: "2026-09-03T00:00:00.000Z", + }, + }, + ], + bytes, + }); + // The caller still holds the assertion object and the byte buffer, and + // edits both after the transaction admitted them. + assertion.value = "changed after admission"; + piece.fill(0); + bytes.set(digest, new TextEncoder().encode("substituted")); + return "done"; }); + + // What was sent is what was admitted, not what the caller did afterwards. + const mapping = memberList(lastCommit(transport.sent), "mappings")[0] ?? {}; + expect(text(member(member(mapping, "record"), "assertion"), "value")).toBe("admitted"); + const staged = transport.sent.find((request) => request["command"] === "stage"); + expect(staged?.["digest"]).toBe(digest); + expect(staged?.["bytes"]).toBe(encodeBase64(new TextEncoder().encode("admitted bytes"))); }); it("refuses a second Workspace publication in one transaction", function* () { @@ -762,6 +924,7 @@ describe("what the production runner publishes", () => { content: [], }, mappings: [], + bytes: new Map(), }; let raised: unknown; try { diff --git a/packages/workflow/tests/remote-transaction.test.ts b/packages/workflow/tests/remote-transaction.test.ts index 5d5a2d794..1d19b4dd3 100644 --- a/packages/workflow/tests/remote-transaction.test.ts +++ b/packages/workflow/tests/remote-transaction.test.ts @@ -26,6 +26,7 @@ import { 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. @@ -88,6 +89,19 @@ function event(name: string): DurableEvent { }; } +/** 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: { @@ -110,12 +124,14 @@ function link( } return starting; }, - *commit(intent: CommitIntent): Operation> { + *commit(intent: CommitIntent): Operation> { sent.push(intent); if (options.blockCommit !== undefined) { yield* options.blockCommit.operation; } - return options.commit === undefined ? Ok(undefined) : options.commit(intent); + return options.commit === undefined + ? Ok(decisionFor(intent)) + : mapDecision(options.commit(intent), intent); }, }; return { owner, sent, starting }; @@ -352,8 +368,8 @@ describe("a remote transaction", () => { *frontier(): Operation { throw new Error("transport failed"); }, - *commit(): Operation> { - return Ok(undefined); + *commit(): Operation> { + return Ok({ workspaceRootId: "root-a", journalEventIds: [] }); }, }; try { From 45ad250d129a6bdb6976510f95678483957d54cd Mon Sep 17 00:00:00 2001 From: Min Kim Date: Fri, 4 Sep 2026 09:17:35 -0400 Subject: [PATCH 33/42] =?UTF-8?q?=F0=9F=94=90=20Let=20the=20transaction=20?= =?UTF-8?q?that=20got=20the=20answer=20be=20the=20one=20that=20promotes=20?= =?UTF-8?q?(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotion took a value shaped like the owner's decision, and any caller could write one down. Comparing it against the attempt's own capture caught a wrong root and nothing else: a fabricated decision naming the right root promoted just as well as the real thing. The accepted materialization also carried a public `replace()`, so anything that could create an attempt could move the run to a Workspace the owner had never selected. And the one operation that did receive the authoritative answer — the transaction itself — discarded it, so completing an owner-authorized promotion meant leaving the supported path and repeating the protocol by hand. The authority is no longer data. An attempt offers a proposal, and that proposal carries a way back to the attempt that nothing else can construct; the transaction sends it, validates the answer against what it sent, and then transfers the tree itself, before it reports success. A caller may still build an enlistment by hand — it simply carries no attempt, so there is nothing to transfer, which is the right outcome rather than a missing step. `promote()` and `replace()` are gone from every surface a caller holds. What is left on the materialization is where a path is and which root it is; what is left on an attempt is where it is, what it captured, and how to offer it. The capability that moves the accepted tree is a symbol only this module can write, so a value that did not come from `useMaterialization()` cannot be mistaken for one. The evidence follows the production path end to end: mutate an attempt, enlist its capture, return from the body, and find that by the time the transaction reported success the accepted path had become the attempt's tree and read the attempted bytes, while the old tree was gone. The supported surfaces are enumerated and shown to expose no replacement or promotion, and a value naming the exact proposed root is handed to the only thing that takes an enlistment and moves nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/remote/collector.ts | 24 +++ packages/workflow/src/remote/invocation.ts | 157 +++++++++++++----- .../workflow/tests/remote-publication.test.ts | 115 ++++++++----- 3 files changed, 215 insertions(+), 81 deletions(-) diff --git a/packages/workflow/src/remote/collector.ts b/packages/workflow/src/remote/collector.ts index 85af88b60..946b14e61 100644 --- a/packages/workflow/src/remote/collector.ts +++ b/packages/workflow/src/remote/collector.ts @@ -96,6 +96,20 @@ export interface WorkspaceEnlistment { * nobody can supply is refused rather than guessed at. */ readonly bytes: ReadonlyMap; + /** + * How this proposal's attempt becomes the accepted Workspace. + * + * Supplied by the attempt that produced the proposal, and reachable nowhere + * else. The transaction calls it once, after the owner has performed the + * exact commit this proposal describes and the answer has been validated + * against it — which is what makes the performed answer the authority rather + * than a value that merely looks like one. + * + * Absent when the proposal came from somewhere other than a disposable + * attempt. Then there is nothing to transfer, which is the correct outcome + * rather than a missing step. + */ + readonly transfer?: (decision: CommitDecision) => Operation; } /** What the collector needs from the connection. */ @@ -277,6 +291,13 @@ export function transactRemotely( 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 (enlisted?.transfer !== undefined) { + yield* enlisted.transfer(committed.value); + } // Only now. `T` is the body's own value and never crossed the connection. return Ok(outcome); } @@ -306,6 +327,9 @@ function detach(proposal: WorkspaceEnlistment): WorkspaceEnlistment { ), }), mappings: Object.freeze(proposal.mappings.map(detachMapping)), + // Carried through rather than copied: it is a capability, not data, and the + // attempt that created it is the only thing that can honour it. + ...(proposal.transfer === undefined ? {} : { transfer: proposal.transfer }), // A copy of every buffer, not a reference to one. A caller that goes on // writing into the array it captured must not be able to change what this // proposal stages. diff --git a/packages/workflow/src/remote/invocation.ts b/packages/workflow/src/remote/invocation.ts index d736fcc1c..57bc42b27 100644 --- a/packages/workflow/src/remote/invocation.ts +++ b/packages/workflow/src/remote/invocation.ts @@ -31,7 +31,8 @@ import { type RunnerFiles, } from "./materialize.ts"; import type { RemoteReadLink } from "./read.ts"; -import type { CommitDecision } from "./publication.ts"; +import type { CommitDecision, ProposedContent, RetainedMapping } from "./publication.ts"; +import type { WorkspaceEnlistment } from "./collector.ts"; /** A directory this invocation owns for as long as it needs one. */ export interface TemporaryTrees { @@ -41,6 +42,16 @@ export interface TemporaryTrees { 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. */ @@ -56,22 +67,37 @@ export interface Materialization { at(logical: string): string; } -/** One disposable place to make a mutation, and the way to keep it. */ +/** 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 { readonly at: HostPath; /** What the attempt now describes, captured and checked locally. */ capture(): Operation; /** - * Make this attempt the accepted materialization. + * Offer this attempt's captured Workspace to the active transaction. * - * It takes the owner's performed decision because that decision is the - * authority: nothing else may promote, and a decision naming a different root - * than this attempt captured is not this attempt's decision. Passing it is - * the proof, which is why there is no argument-free way to do this — a - * refusal, an ambiguous loss and a local failure all leave the caller with - * nothing to pass. + * The value it returns carries a way back to this attempt that nothing else + * can construct. A caller may build an enlistment by hand, but it will carry + * no attempt, and so it can move no accepted materialization — which is the + * point: a value shaped like an owner's decision is not authority, and there + * is nowhere to hand one. */ - promote(decision: CommitDecision): Operation; + propose( + captured: CapturedWorkspace, + mappings: readonly RetainedMapping[], + ): Operation; } /** @@ -88,19 +114,19 @@ export function useMaterialization( reads: RemoteReadLink, workspaceRootId: string, reject: WorkspaceRejection, -): Operation { +): Operation { return resource(function* (provide) { const root = yield* trees.create("accepted"); yield* materializeWorkspaceRoot(files, reads, at(root), workspaceRootId, reject); let accepted = { root, workspaceRootId }; - yield* provide({ + const materialization: AcceptedMaterialization = { get workspaceRootId(): string { return accepted.workspaceRootId; }, at(logical: string): string { return at(accepted.root)(logical); }, - *replace(next: { root: string; workspaceRootId: string }): Operation { + *[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. @@ -108,19 +134,24 @@ export function useMaterialization( // nothing can reach and nothing will clean up until the invocation ends. yield* trees.remove(previous); }, - }); + }; + yield* provide(materialization); }); } /** - * The accepted materialization, plus the one operation that may move it. + * The accepted materialization, with the capability an attempt needs. * - * `replace` is not on `Materialization` because everything that merely reads - * the Workspace should not be able to change which Workspace it is reading. - * Only an attempt holding a performed decision reaches this. + * 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. */ -export interface AcceptedMaterialization extends Materialization { - replace(next: { root: string; workspaceRootId: string }): Operation; +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; } /** @@ -135,10 +166,11 @@ export function useAttempt( files: RunnerFiles, trees: TemporaryTrees, reads: RemoteReadLink, - materialization: AcceptedMaterialization, + materialization: Materialization, reject: WorkspaceRejection, ): Operation { return resource(function* (provide) { + const accept = accepting(materialization, reject); const root = yield* trees.create("attempt"); yield* materializeWorkspaceRoot( files, @@ -148,42 +180,87 @@ export function useAttempt( reject, ); - let promoted = false; + 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 (!promoted) { + if (!transferred) { yield* trees.remove(root); } }); + /** + * Make this attempt the accepted materialization. + * + * Reached only through the enlistment `propose()` produced, and only by the + * transaction that has just validated the owner's answer for this exact + * proposal. The decision is compared with what this attempt captured, so an + * answer about some other Workspace transfers nothing. + */ + function* transfer(decision: CommitDecision, captured: CapturedWorkspace): 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 capture"); + } + transferred = true; + yield* accept({ root, workspaceRootId: captured.root.rootId }); + } + yield* provide({ at: at(root), *capture(): Operation { return yield* captureWorkspace(files, at(root), reject); }, - *promote(decision: CommitDecision): Operation { - if (promoted) { - // One decision promotes one attempt once. A second promotion would be - // moving the accepted tree somewhere it has already been moved from. - reject("this attempt has already been promoted"); - } - const captured = yield* captureWorkspace(files, at(root), reject); - if (decision.workspaceRootId !== captured.root.rootId) { - // The owner published something other than what this attempt holds. - // Promoting would label these bytes with a root they are not. - reject("the owner's decision names a root this attempt did not capture"); - } - promoted = true; - // The tree itself becomes the accepted one. Recording the identity - // without moving the bytes would leave the invocation reading the - // Workspace it used to be at under the name of the one it is now at. - yield* materialization.replace({ root, workspaceRootId: captured.root.rootId }); + // deno-lint-ignore require-yield + *propose( + captured: CapturedWorkspace, + mappings: readonly RetainedMapping[], + ): Operation { + return { + publication: { + proposedWorkspaceRootId: captured.root.rootId, + proposedManifest: captured.root.manifest, + content: inventoryOf(captured), + }, + mappings, + bytes: bytesOf(captured), + transfer: (decision) => transfer(decision, captured), + }; }, }); }); } +/** The exact closure a captured root names, in canonical order. */ +function inventoryOf(captured: CapturedWorkspace): ProposedContent[] { + return [ + ...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, + })), + ]; +} + +/** 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. * diff --git a/packages/workflow/tests/remote-publication.test.ts b/packages/workflow/tests/remote-publication.test.ts index e6a5ccec0..b5e958444 100644 --- a/packages/workflow/tests/remote-publication.test.ts +++ b/packages/workflow/tests/remote-publication.test.ts @@ -431,7 +431,7 @@ describe("what the production runner publishes", () => { void outcomes; }); - it("promotes only with the owner's decision, and moves the tree with it", function* () { + it("transfers the attempt inside the transaction that the owner performed", function* () { const files = runnerFiles(); const trees = yield* useRunnerTrees(); const { captured, reads } = yield* startingTree(); @@ -449,50 +449,35 @@ describe("what the production runner publishes", () => { const acceptedBefore = materialization.at("/"); let attemptRoot = ""; - 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); - } - - // A decision the owner did not give cannot promote. - let raised: unknown; - try { - yield* attempt.promote({ workspaceRootId: captured.root.rootId, journalEventIds: [] }); - } catch (error) { - raised = error; - } - expect(raised).toBeInstanceOf(Error); - expect(materialization.workspaceRootId).toBe(captured.root.rootId); - - // The owner's own answer, naming the root this attempt captured. - const committed = yield* link.commit({ - expectedWorkspaceRootId: captured.root.rootId, - expectedJournalEventId: null, - events: [], - publication: { - proposedWorkspaceRootId: proposed.root.rootId, - proposedManifest: proposed.root.manifest, - content: inventoryOf(proposed), - }, - mappings: [], - bytes: proposedBytes(proposed), + 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(yield* attempt.propose(proposed, [])); + // Still the old Workspace while the answer is unknown. + acceptedDuringBody = materialization.at("/"); + return "done"; + }); }); - if (!committed.ok) { - throw committed.error; - } - yield* attempt.promote(committed.value); - // The accepted materialization is the promoted tree, not a relabelled - // copy of the old one: the file the effect wrote is readable through it. - expect(materialization.workspaceRootId).not.toBe(captured.root.rootId); + 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 { @@ -503,6 +488,54 @@ describe("what the production runner publishes", () => { 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 })); + const proposed = yield* attempt.capture(); + + // Everything a caller can reach: reading the Workspace, capturing an + // attempt, and offering a proposal. Nothing here promotes or replaces. + expect(Object.keys(materialization).toSorted()).toEqual(["at", "workspaceRootId"]); + expect(Object.keys(attempt).toSorted()).toEqual(["at", "capture", "propose"]); + + // A value shaped exactly like the owner's answer, for the exact root this + // attempt captured, handed to the only thing that takes an enlistment. + // It carries no attempt, so it can transfer nothing. + const forged = { + publication: { + proposedWorkspaceRootId: proposed.root.rootId, + proposedManifest: proposed.root.manifest, + content: [], + }, + mappings: [], + bytes: new Map(), + }; + const transport = wire(ownerAnswers("")); + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareOwnerLink(connection, reads, ids()); + yield* transactRemotely(link, createTransactionGate(), function* (_tx, enlist) { + enlist(forged); + return "done"; + }); + }); + + 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 { captured, reads } = yield* startingTree(); const transport = wire(ownerAnswers(captured.root.rootId)); From 3a3de81baaabbe190827627990934e15a25e34e9 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Fri, 4 Sep 2026 10:07:53 -0400 Subject: [PATCH 34/42] =?UTF-8?q?=F0=9F=8E=9F=EF=B8=8F=20Enlist=20the=20at?= =?UTF-8?q?tempt,=20not=20a=20proposal=20about=20it=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capability moved again instead of going away. `propose()` handed back an object with a callable `transfer`, so the same fabricated decision that used to promote through `attempt.promote(decision)` now promoted through `proposal.transfer(decision)` — before any commit, comparing only a caller-supplied root against a caller-held capture. The regression that was supposed to catch this built a *different* enlistment without that member, so it never touched the bypass at all. Two more things followed from taking a capture as an argument. The transfer moved the live directory while the identity came from whatever capture was passed, so a body could enlist, keep writing, and have the owner commit one root while the runner labelled different bytes with it. And a hand-built publication with no attempt behind it committed happily: the owner advanced and the invocation stayed on its old tree. So `enlist` takes the attempt. Not a proposal, not a capture — the attempt itself. The transaction seals it after the body and everything it started have finished, which means the proposal is the tree as it finally is rather than as it was at some earlier moment; the same seal produces the transfer, and the transaction calls it after validating the owner's answer. A publication that no live attempt owns is now unrepresentable rather than merely refused. What a caller holds is a place to work and a way to read what it did. Sealing and transfer hang off a symbol in a module no entrypoint reaches, so they are not on the declared surface and cannot be written down by anything that does not already have them. The evidence follows: enlist an attempt, keep writing to it, and the committed root, the transferred tree and the recaptured accepted root are one root and one set of bytes — the final ones. The supported surfaces are enumerated and carry no promote, transfer, replace or accept. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/remote/collector.ts | 106 ++--- packages/workflow/src/remote/invocation.ts | 70 ++-- packages/workflow/src/remote/seal.ts | 41 ++ .../workflow/tests/remote-publication.test.ts | 375 +++++++++--------- 4 files changed, 288 insertions(+), 304 deletions(-) create mode 100644 packages/workflow/src/remote/seal.ts diff --git a/packages/workflow/src/remote/collector.ts b/packages/workflow/src/remote/collector.ts index 946b14e61..f3f613ee8 100644 --- a/packages/workflow/src/remote/collector.ts +++ b/packages/workflow/src/remote/collector.ts @@ -24,6 +24,7 @@ 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"; @@ -72,46 +73,6 @@ export interface CommitIntent { readonly bytes: ReadonlyMap; } -/** - * What a Workspace operation enlisted, if one did. - * - * At most one per transaction. Two would be two Workspaces proposed for one - * commit, and the owner would have to choose — which is a decision nobody is - * entitled to make on the run's behalf. - */ -export interface WorkspaceEnlistment { - readonly publication: WorkspacePublication; - readonly mappings: readonly RetainedMapping[]; - /** - * The bytes for every piece the publication names, by identity. - * - * Supplied at enlistment rather than fetched later. The proposal's identity - * is a digest over content, so bytes that could still change after the - * transaction sealed would let one identity describe two different - * Workspaces — and the adapter would stage whatever the buffer happened to - * hold by the time it looked. - * - * Only pieces the owner may not already hold need appear. What is absent is - * content the owner is expected to have, and a proposal naming content - * nobody can supply is refused rather than guessed at. - */ - readonly bytes: ReadonlyMap; - /** - * How this proposal's attempt becomes the accepted Workspace. - * - * Supplied by the attempt that produced the proposal, and reachable nowhere - * else. The transaction calls it once, after the owner has performed the - * exact commit this proposal describes and the answer has been validated - * against it — which is what makes the performed answer the authority rather - * than a value that merely looks like one. - * - * Absent when the proposal came from somewhere other than a disposable - * attempt. Then there is nothing to transfer, which is the correct outcome - * rather than a missing step. - */ - readonly transfer?: (decision: CommitDecision) => Operation; -} - /** What the collector needs from the connection. */ export interface OwnerLink { /** One bounded read that opens and closes its own owner-side read. */ @@ -241,7 +202,7 @@ export function transactRemotely( }, }; - let enlisted: WorkspaceEnlistment | undefined; + let enlisted: { attempt: SealableAttempt; mappings: readonly RetainedMapping[] } | undefined; /** * How a Workspace operation puts its result into this transaction. * @@ -251,17 +212,22 @@ export function transactRemotely( * 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 = (proposal: WorkspaceEnlistment): void => { + const enlist: EnlistWorkspace = ( + attempt: SealableAttempt, + mappings: readonly RetainedMapping[] = [], + ): void => { if (!live) { throw new RemoteTransactionError("transaction-closed"); } if (enlisted !== undefined) { throw new RemoteTransactionError("publication-already-enlisted"); } - if (proposal.mappings.length > MAX_MAPPINGS) { + if (mappings.length > MAX_MAPPINGS) { throw new RemoteTransactionError("too-many-mappings"); } - enlisted = detach(proposal); + // 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 outcome: T; @@ -279,14 +245,18 @@ export function transactRemotely( 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: enlisted?.publication ?? null, - mappings: enlisted?.mappings ?? [], - bytes: enlisted?.bytes ?? new Map(), + publication: sealed?.publication ?? null, + mappings: sealed?.mappings ?? [], + bytes: sealed?.bytes ?? new Map(), }); if (!committed.ok) { return committed; @@ -295,8 +265,8 @@ export function transactRemotely( // 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 (enlisted?.transfer !== undefined) { - yield* enlisted.transfer(committed.value); + 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); @@ -304,8 +274,20 @@ export function transactRemotely( }); } -/** How a Workspace operation enlists its one publication in the active transaction. */ -export type EnlistWorkspace = (proposal: WorkspaceEnlistment) => void; +/** + * 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; /** * A copy nobody else holds a reference into. @@ -315,28 +297,6 @@ export type EnlistWorkspace = (proposal: WorkspaceEnlistment) => void; * publication whose inventory or manifest changed afterwards would not be the * one its identity was computed over. */ -function detach(proposal: WorkspaceEnlistment): WorkspaceEnlistment { - return Object.freeze({ - publication: Object.freeze({ - proposedWorkspaceRootId: proposal.publication.proposedWorkspaceRootId, - proposedManifest: proposal.publication.proposedManifest, - content: Object.freeze( - proposal.publication.content.map((piece) => - Object.freeze({ kind: piece.kind, digest: piece.digest, size: piece.size }), - ), - ), - }), - mappings: Object.freeze(proposal.mappings.map(detachMapping)), - // Carried through rather than copied: it is a capability, not data, and the - // attempt that created it is the only thing that can honour it. - ...(proposal.transfer === undefined ? {} : { transfer: proposal.transfer }), - // A copy of every buffer, not a reference to one. A caller that goes on - // writing into the array it captured must not be able to change what this - // proposal stages. - bytes: new Map([...proposal.bytes].map(([digest, bytes]) => [digest, bytes.slice()])), - }); -} - /** * One mapping, copied all the way down. * diff --git a/packages/workflow/src/remote/invocation.ts b/packages/workflow/src/remote/invocation.ts index 57bc42b27..795d2b4c8 100644 --- a/packages/workflow/src/remote/invocation.ts +++ b/packages/workflow/src/remote/invocation.ts @@ -32,7 +32,7 @@ import { } from "./materialize.ts"; import type { RemoteReadLink } from "./read.ts"; import type { CommitDecision, ProposedContent, RetainedMapping } from "./publication.ts"; -import type { WorkspaceEnlistment } from "./collector.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 { @@ -81,23 +81,10 @@ interface AcceptedMaterialization extends Materialization { * proposal, the transaction sends it, and only the transaction — holding the * answer it just validated — transfers the tree. */ -export interface Attempt { +export interface Attempt extends SealableAttempt { readonly at: HostPath; - /** What the attempt now describes, captured and checked locally. */ + /** What the attempt describes right now, captured and checked locally. */ capture(): Operation; - /** - * Offer this attempt's captured Workspace to the active transaction. - * - * The value it returns carries a way back to this attempt that nothing else - * can construct. A caller may build an enlistment by hand, but it will carry - * no attempt, and so it can move no accepted materialization — which is the - * point: a value shaped like an owner's decision is not authority, and there - * is nowhere to hand one. - */ - propose( - captured: CapturedWorkspace, - mappings: readonly RetainedMapping[], - ): Operation; } /** @@ -189,35 +176,27 @@ export function useAttempt( } }); - /** - * Make this attempt the accepted materialization. - * - * Reached only through the enlistment `propose()` produced, and only by the - * transaction that has just validated the owner's answer for this exact - * proposal. The decision is compared with what this attempt captured, so an - * answer about some other Workspace transfers nothing. - */ - function* transfer(decision: CommitDecision, captured: CapturedWorkspace): 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 capture"); - } - transferred = true; - yield* accept({ root, workspaceRootId: captured.root.rootId }); - } - yield* provide({ at: at(root), *capture(): Operation { return yield* captureWorkspace(files, at(root), reject); }, - // deno-lint-ignore require-yield - *propose( - captured: CapturedWorkspace, - mappings: readonly RetainedMapping[], - ): Operation { + + /** + * 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, @@ -226,7 +205,16 @@ export function useAttempt( }, mappings, bytes: bytesOf(captured), - transfer: (decision) => transfer(decision, 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 }); + }, }; }, }); 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/tests/remote-publication.test.ts b/packages/workflow/tests/remote-publication.test.ts index b5e958444..72760e737 100644 --- a/packages/workflow/tests/remote-publication.test.ts +++ b/packages/workflow/tests/remote-publication.test.ts @@ -18,7 +18,6 @@ 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 { encodeBase64 } from "../src/cloudflare/encoding.ts"; import { runnerFiles, useRunnerTrees } from "../src/deno/remote-files.ts"; import { type CommitIntent, @@ -40,7 +39,7 @@ import { } from "../src/workspace/root-manifest.ts"; import { sha256Hex } from "../src/workspace/sha256.ts"; import { locatorFingerprintOf } from "../src/composition/locator.ts"; -import type { ProposedContent, RetainedMapping } from "../src/remote/publication.ts"; +import type { RetainedMapping } from "../src/remote/publication.ts"; function reject(reason: string): never { throw new Error(reason); @@ -57,34 +56,6 @@ function event(name: string) { const LOCATOR = "https://git.example.invalid/octo/app.git"; -/** The exact closure a proposed root names, in canonical order. */ -function inventoryOf(captured: CapturedWorkspace): ProposedContent[] { - return [ - ...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, - })), - ]; -} - -/** Every piece a capture can supply, by identity. */ -function proposedBytes(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; -} - /** The Repository mapping these tests enlist. */ function repositoryMapping(): RetainedMapping { return { @@ -155,15 +126,20 @@ function wire(answer: (request: Record) => Record = new Map()) { +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: sizes.get(String(request["digest"])) ?? 0, + size: (encoded.length / 4) * 3 - padding, }, }; } @@ -329,26 +305,7 @@ describe("what the production runner publishes", () => { createTransactionGate(), function* (transaction, enlist) { yield* transaction.journal.append(event("published")); - enlist({ - publication: { - proposedWorkspaceRootId: proposed.root.rootId, - proposedManifest: proposed.root.manifest, - content: [ - ...proposed.root.manifests.map((digest) => ({ - kind: "manifest" as const, - digest, - size: proposed.contents.get(digest)?.manifestBytes.length ?? 0, - })), - ...proposed.root.blobs.map((digest) => ({ - kind: "blob" as const, - digest, - size: proposed.blobs.get(digest)?.length ?? 0, - })), - ], - }, - mappings: [repositoryMapping()], - bytes: proposedBytes(proposed), - }); + enlist(attempt, [repositoryMapping()]); return "done"; }, ); @@ -462,7 +419,7 @@ describe("what the production runner publishes", () => { sizes.set(digest, blob.length); } return yield* transactRemotely(link, createTransactionGate(), function* (_tx, enlist) { - enlist(yield* attempt.propose(proposed, [])); + enlist(attempt); // Still the old Workspace while the answer is unknown. acceptedDuringBody = materialization.at("/"); return "done"; @@ -504,32 +461,24 @@ describe("what the production runner publishes", () => { yield* scoped(function* () { const attempt = yield* useAttempt(files, trees, reads, materialization, reject); yield* until(writeFile(attempt.at("/NOTES.md"), "never published\n", { mode: 0o644 })); - const proposed = yield* attempt.capture(); - // Everything a caller can reach: reading the Workspace, capturing an - // attempt, and offering a proposal. Nothing here promotes or replaces. + // Everything a caller can reach by name. Reading where the Workspace is, + // reading what an attempt holds — and nothing that moves either. expect(Object.keys(materialization).toSorted()).toEqual(["at", "workspaceRootId"]); - expect(Object.keys(attempt).toSorted()).toEqual(["at", "capture", "propose"]); - - // A value shaped exactly like the owner's answer, for the exact root this - // attempt captured, handed to the only thing that takes an enlistment. - // It carries no attempt, so it can transfer nothing. - const forged = { - publication: { - proposedWorkspaceRootId: proposed.root.rootId, - proposedManifest: proposed.root.manifest, - content: [], - }, - mappings: [], - bytes: new Map(), - }; - const transport = wire(ownerAnswers("")); - const connection = yield* useOwnerConnection(transport.socket); - const link = cloudflareOwnerLink(connection, reads, ids()); - yield* transactRemotely(link, createTransactionGate(), function* (_tx, enlist) { - enlist(forged); - return "done"; - }); + expect(Object.keys(attempt).toSorted()).toEqual(["at", "capture"]); + 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); @@ -537,47 +486,36 @@ describe("what the production runner publishes", () => { }); 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(captured.root.rootId)); + const transport = wire(ownerAnswers("")); const connection = yield* useOwnerConnection(transport.socket); const link = cloudflareOwnerLink(connection, reads, ids()); - const content: ProposedContent[] = [{ kind: "manifest", digest: "a".repeat(64), size: 1 }]; - const mappings: RetainedMapping[] = [ - { - kind: "repository", - locator: LOCATOR, - record: { - name: "app", - locatorFingerprint: locatorFingerprintOf(LOCATOR), - requestedBase: null, - creationCommit: "9".repeat(40), - primaryBranch: "main", - objectFormat: "sha1", - checkoutPath: "/docs", - }, - }, - ]; - yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { - enlist({ - publication: { - proposedWorkspaceRootId: captured.root.rootId, - proposedManifest: captured.root.manifest, - content, - }, - mappings, - bytes: new Map(), + 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"; }); - // The caller still holds both arrays and edits them after admission. - content.push({ kind: "blob", digest: "b".repeat(64), size: 2 }); - const first = mappings[0]; - if (first?.kind === "repository") { - mappings[0] = { ...first, locator: "https://elsewhere.invalid/x.git" }; - } - return "done"; }); - const commit = lastCommit(transport.sent); - expect(memberList(member(commit, "publication"), "content")).toHaveLength(1); - expect(text(memberList(commit, "mappings")[0] ?? {}, "locator")).toBe(LOCATOR); + expect(text(memberList(lastCommit(transport.sent), "mappings")[0] ?? {}, "locator")).toBe( + LOCATOR, + ); }); it("sends a journal-only commit with no publication and stages nothing", function* () { @@ -611,19 +549,23 @@ describe("what the production runner publishes", () => { }); 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(captured.root.rootId)); + const transport = wire(ownerAnswers("")); const connection = yield* useOwnerConnection(transport.socket); const link = cloudflareOwnerLink(connection, reads, ids()); - yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { - enlist({ - publication: { - proposedWorkspaceRootId: captured.root.rootId, - proposedManifest: captured.root.manifest, - content: [], - }, - bytes: new Map(), - mappings: [ + 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", @@ -652,9 +594,9 @@ describe("what the production runner publishes", () => { createdAt: "2026-09-03T00:00:00.000Z", }, }, - ], + ]); + return "done"; }); - return "done"; }); const mappings = memberList(lastCommit(transport.sent), "mappings"); expect(mappings.map((mapping) => mapping["kind"])).toEqual([ @@ -822,30 +764,38 @@ describe("what the production runner publishes", () => { }); 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(captured.root.rootId)); + 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; - try { - yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { - enlist({ - publication: { - proposedWorkspaceRootId: captured.root.rootId, - proposedManifest: captured.root.manifest, - content: [], - }, + 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. - mappings: Array.from({ length: 300 }, () => repositoryMapping()), - bytes: new Map(), + enlist( + attempt, + Array.from({ length: 300 }, () => repositoryMapping()), + ); + return "done"; }); - return "done"; - }); - } catch (error) { - raised = error; - } + } 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* () { @@ -890,12 +840,20 @@ describe("what the production runner publishes", () => { expect(committed.ok).toBe(false); }); - it("seals nested mapping values and content bytes against later mutation", function* () { + it("seals a nested mapping value against later mutation", function* () { + const files = runnerFiles(); + const trees = yield* useRunnerTrees(); const { captured, reads } = yield* startingTree(); - const sizes = new Map(); - const transport = wire(ownerAnswers("", sizes)); + 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 = { @@ -903,19 +861,10 @@ describe("what the production runner publishes", () => { agentCommand: "/usr/bin/agent", sessionIdentity: "session-1", }; - const piece = new TextEncoder().encode("admitted bytes"); - const digest = sha256Hex(piece); - sizes.set(digest, piece.length); - const bytes = new Map([[digest, piece]]); - - yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { - enlist({ - publication: { - proposedWorkspaceRootId: captured.root.rootId, - proposedManifest: captured.root.manifest, - content: [{ kind: "blob", digest, size: piece.length }], - }, - mappings: [ + 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: { @@ -926,51 +875,97 @@ describe("what the production runner publishes", () => { createdAt: "2026-09-03T00:00:00.000Z", }, }, - ], - bytes, + ]); + // The caller still holds the nested assertion object and edits it. + assertion.value = "changed after admission"; + return "done"; }); - // The caller still holds the assertion object and the byte buffer, and - // edits both after the transaction admitted them. - assertion.value = "changed after admission"; - piece.fill(0); - bytes.set(digest, new TextEncoder().encode("substituted")); - return "done"; }); - // What was sent is what was admitted, not what the caller did afterwards. const mapping = memberList(lastCommit(transport.sent), "mappings")[0] ?? {}; expect(text(member(member(mapping, "record"), "assertion"), "value")).toBe("admitted"); - const staged = transport.sent.find((request) => request["command"] === "stage"); - expect(staged?.["digest"]).toBe(digest); - expect(staged?.["bytes"]).toBe(encodeBase64(new TextEncoder().encode("admitted bytes"))); + }); + + 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(captured.root.rootId)); + const transport = wire(ownerAnswers("")); const connection = yield* useOwnerConnection(transport.socket); const link = cloudflareOwnerLink(connection, reads, ids()); - const proposal = { - publication: { - proposedWorkspaceRootId: captured.root.rootId, - proposedManifest: captured.root.manifest, - content: [], - }, - mappings: [], - bytes: new Map(), - }; + const materialization = yield* useMaterialization( + files, + trees, + reads, + captured.root.rootId, + reject, + ); let raised: unknown; - try { - yield* transactRemotely(link, createTransactionGate(), function* (_transaction, enlist) { - enlist(proposal); - enlist(proposal); - 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. + 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); }); From 1088f75786bc9bd30e83fc15cc5eddb5b0e6fdf0 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Fri, 4 Sep 2026 10:47:47 -0400 Subject: [PATCH 35/42] =?UTF-8?q?=F0=9F=97=84=EF=B8=8F=20Give=20a=20remote?= =?UTF-8?q?=20run=20the=20same=20handle=20a=20local=20one=20has=20(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface a run's storage answers is the same wherever the run lives. This is that handle, backed by a connection: snapshots that stay snapshots, a journal that reads fresh and appends through the one commit path, a caller-owned transaction, retrieval replacement, and an anchored execution list. Two mechanisms keep one handle in order because they answer different questions. A turn stops two operations interleaving, so unrelated work waits and then proceeds. A scope-local marker records that this scope is *inside* a transaction on this handle, so a nested transaction or an ordinary operation called from the body is refused at once rather than queued behind a turn its own caller is holding and will not release. A queue alone deadlocks that; a flag alone mistakes unrelated work for nested work. Retrieval replacement is its own mutation, not a degenerate commit. It appends nothing and publishes nothing, and its revision is the owner's arithmetic over what is stored — two handles that both read revision one before either wrote would otherwise both write two and the second would lose the first. Clearing removes the row, so the next replacement starts counting again. The identity is minted per invocation rather than derived from the request, because two calls carrying identical metadata are two replacements and must not collapse into one; and because a decision now names its command kind, one textual id used for a commit and for a replacement is two requests rather than a recognized retry. Executions read as one anchored snapshot for the same reason the journal does: a caller assembling a list across several requests must see one moment rather than whatever the table held at each of them. The first page fixes the terminal row and every later page is held to it, with the run identity and the sequence travelling privately so the runner can refuse another run's page and prove adjacency — neither becomes part of the record it returns. Failures cross as the categories the local host reports for the same condition. Command names, refusal spellings, rows and cursors stay below the adapter; they describe a protocol nobody above it is party to. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/client.ts | 206 ++++++++++- packages/workflow/src/cloudflare/commands.ts | 85 +++++ .../workflow/src/cloudflare/dispatcher.ts | 59 +++- .../workflow/src/cloudflare/owner-reads.ts | 89 ++++- packages/workflow/src/cloudflare/publish.ts | 82 +++++ packages/workflow/src/remote/database.ts | 331 ++++++++++++++++++ packages/workflow/src/remote/records.ts | 36 ++ .../tests/cloudflare/remote-owner.vitest.ts | 8 +- 8 files changed, 885 insertions(+), 11 deletions(-) create mode 100644 packages/workflow/src/remote/database.ts diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts index 90f2d201e..9937b1ab1 100644 --- a/packages/workflow/src/cloudflare/client.ts +++ b/packages/workflow/src/cloudflare/client.ts @@ -35,6 +35,7 @@ import type { CommitIntent, OwnerLink, StartingFrontier } from "../remote/collec import type { CommitDecision } from "../remote/publication.ts"; import { OwnerLinkError, type OwnerAnswer, type OwnerConnection } from "../remote/client.ts"; import { + parseRemoteExecution, parseRemoteJournalEntry, parseRemoteRetrieval, parseRemoteRunRecord, @@ -54,7 +55,16 @@ import { type WorkspaceRootManifest, } from "../workspace/root-manifest.ts"; import { decodeContentManifest } from "../workspace/content-manifest.ts"; -import { JOURNAL_PAGE_ENTRIES, MAX_CONTENT_BYTES } from "./commands.ts"; +import { EXECUTION_PAGE_ENTRIES, JOURNAL_PAGE_ENTRIES, MAX_CONTENT_BYTES } from "./commands.ts"; +import type { RemoteRunLink } from "../remote/database.ts"; +import { + WorkflowDatabaseCorruptError, + 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 = @@ -70,6 +80,9 @@ export type PrivateRefusal = | "command:duplicate-conflict" | "command:capacity" | "command:unavailable" + | "command:stale-root" + | "command:stale-journal" + | "command:mapping-conflict" | "storage:foreign" | "storage:unsupported-version" | "storage:corrupt"; @@ -143,6 +156,9 @@ function privateRefusal(value: string): PrivateRefusal { case "command:duplicate-conflict": case "command:capacity": case "command:unavailable": + case "command:stale-root": + case "command:stale-journal": + case "command:mapping-conflict": case "storage:foreign": case "storage:unsupported-version": case "storage:corrupt": @@ -559,3 +575,191 @@ export function* stageCloudflareContent( ), ); } + +/** + * 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. + */ +export function cloudflareRunLink( + connection: OwnerConnection, + reads: RemoteReadLink, + nextId: () => string, +): RemoteRunLink { + const publication = cloudflareOwnerLink(connection, reads, nextId); + return { + frontier: publication.frontier, + commit: publication.commit, + + *frontierSnapshot(): Operation { + return yield* reads.frontier(); + }, + + *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"); + } + return parsed; + }, + privateRefusal, + ); + return answered.outcome === "refused" + ? Err(storageFailure(privateRefusal(answered.refusal))) + : Ok(answered.value); + } catch (error) { + return Err(translate(error)); + } + }, + + *readExecutions(): Operation> { + try { + const found: { sequence: number; record: DocumentExecutionRecord }[] = []; + let anchor: number | null | undefined; + let after: number | null = null; + let done = false; + while (!done) { + const page: ExecutionPage = yield* askPage(connection, nextId(), anchor ?? null, after); + anchor ??= page.anchor; + if (page.anchor !== (anchor ?? null) || page.after !== after) { + return Err( + new WorkflowRecordMalformedError( + "document executions", + "a page did not continue its anchored snapshot", + ), + ); + } + for (const row of page.rows) { + found.push(row); + } + after = page.rows.at(-1)?.sequence ?? after; + done = page.done; + } + return Ok(found.map((row) => row.record)); + } catch (error) { + return Err(translate(error)); + } + }, + }; +} + +/** 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, + anchor: number | null, + after: number | null, +): Operation { + const answered = yield* connection.ask( + id, + { command: "executions", anchor: anchor === 0 ? null : anchor, after }, + (value): ExecutionPage => { + const found = members(value, ["runId", "anchor", "after", "rows", "done"]); + 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 (typeof found.get("done") !== "boolean") { + return fail("an execution page did not say whether it was terminal"); + } + let previous = after; + 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"); + } + if (previous !== null && sequence <= previous) { + return fail("an execution page repeated or reordered a row"); + } + previous = sequence; + 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. + */ +function storageFailure(refusal: PrivateRefusal): WorkflowStorageError { + if (refusal.startsWith("storage:")) { + return new WorkflowDatabaseCorruptError("the workflow run's remote storage", refusal.slice(8)); + } + 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:capacity") { + return new WorkflowRequestError("this run's owner cannot accept more work on this connection."); + } + return new WorkflowTransactionError("this run's owner refused the operation."); +} + +/** Any failure from the private protocol, as a provider-neutral one. */ +function translate(error: unknown): WorkflowStorageError { + if (error instanceof CloudflareOwnerRefusalError) { + return storageFailure(error.refusal); + } + if (error instanceof WorkflowStorageError) { + return error; + } + return new WorkflowTransactionError("this run's owner could not be reached."); +} diff --git a/packages/workflow/src/cloudflare/commands.ts b/packages/workflow/src/cloudflare/commands.ts index f7d84786c..55b2442f9 100644 --- a/packages/workflow/src/cloudflare/commands.ts +++ b/packages/workflow/src/cloudflare/commands.ts @@ -20,6 +20,10 @@ 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; /** The most content identities one proposal may name. */ export const MAX_PROPOSED_PIECES = 8192; /** The most retained mapping changes one proposal may carry. */ @@ -34,6 +38,8 @@ export type CommandName = | "content" | "stage" | "commit" + | "retrieval" + | "executions" | "settle"; export type CommandRefusal = @@ -143,6 +149,40 @@ export type ProposedMapping = | { 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; +} + export interface SettleCommand extends CommandEnvelope { readonly command: "settle"; readonly completion: DocumentExecutionCompletion; @@ -156,6 +196,8 @@ export type RunnerCommand = | ContentCommand | StageCommand | CommitCommand + | RetrievalCommand + | ExecutionsCommand | SettleCommand; export type CommandResult = @@ -179,6 +221,8 @@ const MEMBERS: Record = { "mappings", "events", ], + retrieval: [...ENVELOPE, "expectedWorkspaceRootId", "metadata"], + executions: [...ENVELOPE, "anchor", "after"], settle: [...ENVELOPE, "completion", "expectedWorkspaceRootId"], }; @@ -255,6 +299,18 @@ function kind(members: Map): ContentKind { * 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"); @@ -294,6 +350,8 @@ export function parseCommand(raw: string): RunnerCommand { command !== "content" && command !== "stage" && command !== "commit" && + command !== "retrieval" && + command !== "executions" && command !== "settle" ) { throw new CommandError("unknown-command"); @@ -338,6 +396,33 @@ export function parseCommand(raw: string): RunnerCommand { 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 === "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) { diff --git a/packages/workflow/src/cloudflare/dispatcher.ts b/packages/workflow/src/cloudflare/dispatcher.ts index 607bd08a2..8ee8096f8 100644 --- a/packages/workflow/src/cloudflare/dispatcher.ts +++ b/packages/workflow/src/cloudflare/dispatcher.ts @@ -44,14 +44,34 @@ import { type RunnerCommand, } from "./commands.ts"; import { bytesOf, decodeBase64, sha256Hex } from "./encoding.ts"; -import { readContent, readFrontier, readJournalPage, readRoot } from "./owner-reads.ts"; +import { + readContent, + readExecutions, + readFrontier, + readJournalPage, + readRoot, +} from "./owner-reads.ts"; import type { OwnerTransactions } from "./owner-transaction.ts"; import { COMMAND_TABLE, MUTATION_TABLE, STAGING_TABLE } from "./private-schema.ts"; -import { applyCommit } from "./publish.ts"; +import { applyCommit, applyRetrieval } from "./publish.ts"; import { recognizeObject } from "./recognition.ts"; function requestFingerprint(command: RunnerCommand): string { - return sha256Hex(JSON.stringify(command)); + // 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. + */ +function mutating(command: RunnerCommand): boolean { + return command.command === "commit" || command.command === "retrieval"; } function integer(value: unknown): number { @@ -103,10 +123,23 @@ 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 === "journal" || + command.command === "root" || + command.command === "content" || + command.command === "executions") ) { return JSON.stringify({ id: command.id, outcome: "reconstruct" }); } @@ -219,6 +252,20 @@ function perform( value: applyCommit(ctx.storage, acquisitionId, command, mintEventId), }; } + 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), + }; + } // `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. @@ -245,7 +292,7 @@ export function dispatchCommand( // 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. - if (command.command === "commit") { + if (mutating(command)) { const decided = ctx.storage.sql .exec( `SELECT request_fingerprint, response FROM ${MUTATION_TABLE} WHERE command_id = ?`, @@ -312,7 +359,7 @@ export function dispatchCommand( encoded, responseBytes, ); - if (command.command === "commit") { + if (mutating(command)) { // Recorded in this same transaction as the mutation it describes, so a // crash cannot leave one without the other. const mutations = ctx.storage.sql diff --git a/packages/workflow/src/cloudflare/owner-reads.ts b/packages/workflow/src/cloudflare/owner-reads.ts index 1f43833d3..d84aa8e9c 100644 --- a/packages/workflow/src/cloudflare/owner-reads.ts +++ b/packages/workflow/src/cloudflare/owner-reads.ts @@ -26,7 +26,7 @@ */ import { parseDurableEvent } from "@executablemd/durable-streams"; -import { readRetrieval, readRunRecord, type Row } from "../sqlite/rows.ts"; +import { readDocumentExecution, readRetrieval, readRunRecord, type Row } from "../sqlite/rows.ts"; import { WorkflowRecordMalformedError } from "../storage/errors.ts"; import { parseWorkspaceRootManifest, @@ -37,6 +37,8 @@ import { import { type ContentManifest, decodeContentManifest } from "../workspace/content-manifest.ts"; import { CommandError, + EXECUTION_PAGE_BYTES, + EXECUTION_PAGE_ENTRIES, JOURNAL_PAGE_BYTES, JOURNAL_PAGE_ENTRIES, MAX_CONTENT_BYTES, @@ -466,3 +468,88 @@ function piece( } return validatedBlob(storage, rootId, root.manifests, digest); } + +/** One page of document executions, anchored to the snapshot that began it. */ +export interface ExecutionsValue { + readonly runId: string; + readonly anchor: number | null; + readonly after: number | null; + readonly rows: readonly { readonly sequence: number; readonly record: Row }[]; + 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 { + if (anchor === null) { + const last = byteRows( + storage, + "SELECT sequence FROM document_executions ORDER BY sequence DESC LIMIT 1", + )[0]; + if (last !== undefined) { + return corrupt("an empty execution snapshot was anchored against existing rows"); + } + return { runId, anchor, 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, + anchor, + EXECUTION_PAGE_ENTRIES + 1, + ); + + const page: { sequence: number; record: Row }[] = []; + let encoded = 0; + for (const row of found.slice(0, EXECUTION_PAGE_ENTRIES)) { + const at = safeInteger(row["sequence"], "execution sequence"); + // Parsed here as well as on the runner: a row this owner cannot read is + // storage damage, and sending it would make the runner report damage it + // cannot attribute. + readDocumentExecution(row); + const bytes = new TextEncoder().encode(JSON.stringify(row)).length; + if (page.length > 0 && encoded + bytes > EXECUTION_PAGE_BYTES) { + break; + } + if (bytes > MAX_CONTENT_BYTES) { + throw new CommandError("too-large"); + } + page.push({ sequence: at, record: row }); + encoded += bytes; + } + + const done = found.length <= page.length; + if (done && page.at(-1)?.sequence !== anchor) { + return corrupt("an anchored execution snapshot is incomplete"); + } + return { runId, anchor, 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/publish.ts b/packages/workflow/src/cloudflare/publish.ts index a1344465d..c8704718d 100644 --- a/packages/workflow/src/cloudflare/publish.ts +++ b/packages/workflow/src/cloudflare/publish.ts @@ -51,6 +51,7 @@ 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 { readRetrieval } from "../sqlite/rows.ts"; import { bytesOf } from "./encoding.ts"; import { STAGING_TABLE } from "./private-schema.ts"; import type { OwnerStorage } from "./storage.ts"; @@ -678,3 +679,84 @@ function applyMapping(storage: OwnerStorage, mapping: ProposedMapping): void { 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/remote/database.ts b/packages/workflow/src/remote/database.ts new file mode 100644 index 000000000..ff6db23bb --- /dev/null +++ b/packages/workflow/src/remote/database.ts @@ -0,0 +1,331 @@ +/** + * 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 type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; +import type { JournalEntry, WorkflowRunDatabase, WorkflowRunTransaction } from "../storage/api.ts"; +import { WorkflowDatabaseClosedError, WorkflowTransactionError } from "../storage/errors.ts"; +import type { + DefinitionRetrieval, + DocumentExecutionRecord, + WorkflowRunRecord, +} from "../storage/record.ts"; +import { createTransactionGate, type OwnerLink, transactRemotely } from "./collector.ts"; +import type { EnlistWorkspace } from "./collector.ts"; +import type { RemoteFrontierSnapshot } from "./read.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; + /** 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>; +} + +/** + * 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; +} + +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; +} + +/** One handle's cooperative turn, so two operations never interleave on it. */ +interface Turns { + take(body: () => Operation): Operation; +} + +function createTurns(): Turns { + const waiting = createSignal(); + let held = false; + return { + *take(body: () => Operation): Operation { + while (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(); + } + held = true; + try { + return yield* body(); + } finally { + held = false; + waiting.send(); + } + }, + }; +} + +/** 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. */ + function* turn(body: () => Operation>): Operation> { + const admitted = yield* admit(); + if (!admitted.ok) { + return admitted; + } + return yield* turns.take(body); + } + + /** The same, for a `DurableStream` member, which raises instead. */ + function* raising(result: Result): Operation { + if (!result.ok) { + throw result.error; + } + return result.value; + } + + 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> { + return yield* transactRemotely(link, gate, function* (transaction, enlist) { + // 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 }); + return yield* body(transaction); + }); + }); + } + + const handle: WorkflowRunDatabase = { + get record(): WorkflowRunRecord { + return record; + }, + + get retrieval(): DefinitionRetrieval | undefined { + return retrieval; + }, + + get journal(): DurableStream { + return ordinary; + }, + + 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> { + const replaced = yield* turn(function* () { + const snapshot = yield* link.frontierSnapshot(); + return yield* link.replaceRetrieval( + snapshot.workspaceRootId, + metadata === undefined ? null : canonical(metadata), + ); + }); + if (!replaced.ok) { + return replaced; + } + // 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 = replaced.value; + return Ok(); + }, + + *readDocumentExecutions(): Operation> { + return yield* turn(() => link.readExecutions()); + }, + }; + + yield* ensure(() => { + closed = true; + }); + yield* provide(handle); + }); +} + +/** + * 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 = {}; + for (const key of Object.keys(value).sort()) { + const held = (value as Record)[key]; + if (held !== undefined) { + members[key] = sorted(held); + } + } + return members; +} diff --git a/packages/workflow/src/remote/records.ts b/packages/workflow/src/remote/records.ts index 1853f35a3..ab58b50ad 100644 --- a/packages/workflow/src/remote/records.ts +++ b/packages/workflow/src/remote/records.ts @@ -28,6 +28,7 @@ import { } from "../storage/members.ts"; import { type DefinitionRetrieval, + type DocumentExecutionRecord, parseRunId, parseWorkflowRunStatus, parseWorkflowStopReason, @@ -141,3 +142,38 @@ export function parseRemoteJournalEntry(value: unknown): JournalEntry { 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); + 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 stopped: DocumentExecutionRecord = { + ...record, + stoppedAt: instant(found.get("stoppedAt"), "$.stoppedAt"), + stopStatus: parseWorkflowRunStatus(found.get("stopStatus"), "$.stopStatus", fail), + }; + if (!found.has("stopReason")) { + return Object.freeze(stopped); + } + return Object.freeze({ + ...stopped, + stopReason: parseWorkflowStopReason(found.get("stopReason"), "$.stopReason", fail), + }); +} diff --git a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts index c7519c8b1..395ff462f 100644 --- a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts @@ -451,7 +451,9 @@ describe("the remote owner protocol", () => { 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()); - await admit(stub); + // 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); @@ -461,7 +463,7 @@ describe("the remote owner protocol", () => { // bytes writes a new row rather than finding the abandoned one. Nothing was // inherited; it was discarded and done afresh. expect( - await send(stub, "stage", { + await ask(successor, "stage", { command: "stage", kind: "blob", digest, @@ -469,7 +471,7 @@ describe("the remote owner protocol", () => { }), ).toMatchObject({ outcome: "performed", value: { digest } }); expect(await on(stub, (owner) => owner.scratch())).toEqual({ commands: 1, staged: 1 }); - expect(await send(stub, "frontier-new", { command: "frontier" })).toMatchObject({ + expect(await ask(successor, "frontier-new", { command: "frontier" })).toMatchObject({ outcome: "performed", value: { workspaceRootId: ROOT_ID }, }); From fdb2b4a5a71262231927bf246631c0125a87a7a0 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Fri, 4 Sep 2026 10:52:42 -0400 Subject: [PATCH 36/42] =?UTF-8?q?=F0=9F=A7=AE=20Anchor=20the=20execution?= =?UTF-8?q?=20list=20where=20the=20owner=20can=20choose=20the=20anchor=20(?= =?UTF-8?q?#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner had no way to learn which snapshot it was reading. It sent the first page with no anchor, and the owner treated that as a claim the run had no executions — true only for an empty run, and storage damage for every other, so a run with any history could not read its own list at all. The owner chooses the anchor now. A first request carries none, the owner fixes the terminal row at that moment and answers with it, and every later page is held to what the first one chose. An empty run answers with an explicit empty anchor, which is a different answer from a terminal page and is read as one. The evidence is the case that exposed it: 129 executions, a first page that returns 128 rows and the anchor it selected, another execution begun while the read is in flight, and a final page that ends exactly at the anchor without it. Also proves the retrieval mutation on real storage: revisions counted by the owner, byte-identical metadata under two identities counted as two replacements, clearing removing the row so the next one starts again, one invocation retried across a lost answer and eviction applying once, the same identity with different content refused, and a replacement against a root the run has left refused without touching the row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/client.ts | 8 +- .../workflow/src/cloudflare/owner-reads.ts | 23 +- packages/workflow/src/remote/database.ts | 34 +- .../tests/cloudflare/remote-publish.vitest.ts | 127 +++++ .../cloudflare/support/executor-object.ts | 17 + .../workflow/tests/remote-database.test.ts | 442 ++++++++++++++++++ 6 files changed, 624 insertions(+), 27 deletions(-) create mode 100644 packages/workflow/tests/remote-database.test.ts diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts index 9937b1ab1..6fbacb65f 100644 --- a/packages/workflow/src/cloudflare/client.ts +++ b/packages/workflow/src/cloudflare/client.ts @@ -644,8 +644,10 @@ export function cloudflareRunLink( let done = false; while (!done) { const page: ExecutionPage = yield* askPage(connection, nextId(), anchor ?? null, after); - anchor ??= page.anchor; - if (page.anchor !== (anchor ?? null) || page.after !== after) { + // The first page chooses the anchor; every later one is held to it. + const expected = anchor === undefined ? page.anchor : anchor; + anchor = expected; + if (page.anchor !== expected || page.after !== after) { return Err( new WorkflowRecordMalformedError( "document executions", @@ -683,7 +685,7 @@ function* askPage( ): Operation { const answered = yield* connection.ask( id, - { command: "executions", anchor: anchor === 0 ? null : anchor, after }, + { command: "executions", anchor, after }, (value): ExecutionPage => { const found = members(value, ["runId", "anchor", "after", "rows", "done"]); const offered = found.get("rows"); diff --git a/packages/workflow/src/cloudflare/owner-reads.ts b/packages/workflow/src/cloudflare/owner-reads.ts index d84aa8e9c..5d5c16a79 100644 --- a/packages/workflow/src/cloudflare/owner-reads.ts +++ b/packages/workflow/src/cloudflare/owner-reads.ts @@ -497,15 +497,16 @@ export function readExecutions( anchor: number | null, after: number | null, ): ExecutionsValue { - if (anchor === null) { - const last = byteRows( - storage, - "SELECT sequence FROM document_executions ORDER BY sequence DESC LIMIT 1", - )[0]; - if (last !== undefined) { - return corrupt("an empty execution snapshot was anchored against existing rows"); + // 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, after, rows: [], done: true }; + return { runId, anchor: null, after, rows: [], done: true }; } const found = byteRows( @@ -515,7 +516,7 @@ export function readExecutions( FROM document_executions WHERE sequence > ? AND sequence <= ? ORDER BY sequence ASC LIMIT ?`, after ?? 0, - anchor, + selected, EXECUTION_PAGE_ENTRIES + 1, ); @@ -539,10 +540,10 @@ export function readExecutions( } const done = found.length <= page.length; - if (done && page.at(-1)?.sequence !== anchor) { + if (done && page.at(-1)?.sequence !== selected) { return corrupt("an anchored execution snapshot is incomplete"); } - return { runId, anchor, after, rows: page, done }; + return { runId, anchor: selected, after, rows: page, done }; } /** The terminal execution sequence right now, or `null` when there is none. */ diff --git a/packages/workflow/src/remote/database.ts b/packages/workflow/src/remote/database.ts index ff6db23bb..248bc25b5 100644 --- a/packages/workflow/src/remote/database.ts +++ b/packages/workflow/src/remote/database.ts @@ -124,6 +124,20 @@ export function* activeWorkspaceRoute( return route; } +/** + * 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; @@ -131,21 +145,21 @@ interface Turns { function createTurns(): Turns { const waiting = createSignal(); - let held = false; + const holder = { held: false }; return { *take(body: () => Operation): Operation { - while (held) { + 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(); } - held = true; + holder.held = true; try { return yield* body(); } finally { - held = false; + holder.held = false; waiting.send(); } }, @@ -190,14 +204,6 @@ export function useRemoteRunDatabase( return yield* turns.take(body); } - /** The same, for a `DurableStream` member, which raises instead. */ - function* raising(result: Result): Operation { - if (!result.ok) { - throw result.error; - } - return result.value; - } - const ordinary: DurableStream = { *readAll(): Operation { return yield* raising( @@ -321,7 +327,9 @@ function sorted(value: Json): Json { return value; } const members: Record = {}; - for (const key of Object.keys(value).sort()) { + 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); diff --git a/packages/workflow/tests/cloudflare/remote-publish.vitest.ts b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts index d4616e2c1..e93197f6a 100644 --- a/packages/workflow/tests/cloudflare/remote-publish.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts @@ -735,3 +735,130 @@ describe("publishing one proposal", () => { 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/support/executor-object.ts b/packages/workflow/tests/cloudflare/support/executor-object.ts index e39683434..7ab9cb51d 100644 --- a/packages/workflow/tests/cloudflare/support/executor-object.ts +++ b/packages/workflow/tests/cloudflare/support/executor-object.ts @@ -353,6 +353,23 @@ export class ExecutorObject extends WorkflowOwnerObject { ); } + /** 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, + ); + } + + /** 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; + } + damageRetainedBlob(): void { this.ctx.storage.sql.exec( "UPDATE vfs_blob_bytes SET bytes = ?", diff --git a/packages/workflow/tests/remote-database.test.ts b/packages/workflow/tests/remote-database.test.ts new file mode 100644 index 000000000..2b882055c --- /dev/null +++ b/packages/workflow/tests/remote-database.test.ts @@ -0,0 +1,442 @@ +/** + * 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"; + +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 + *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("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); + let caught: unknown; + try { + yield* database.transact(function* () { + throw new Error("the body failed"); + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect(raising.commits).toHaveLength(0); + }); + }); + + 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); + }); + }); +}); From 6473ff9dda020666f06b337ef9d9a5e44d8a406f Mon Sep 17 00:00:00 2001 From: Min Kim Date: Fri, 4 Sep 2026 12:30:16 -0400 Subject: [PATCH 37/42] =?UTF-8?q?=F0=9F=A7=B7=20Prove=20the=20snapshot,=20?= =?UTF-8?q?and=20answer=20failures=20the=20way=20the=20interface=20says=20?= =?UTF-8?q?(#698)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ways the remote database could accept or report something it should not. An execution page was believed on very little. The run identity was required to be present and never compared, so another run's retained history passed. Rows had only to increase, so a gap, a first row that was not the first, or a row past the anchor all passed — and a page could call itself terminal while stopping short of the anchor, which turned an incomplete history into a complete-looking answer. There is a state machine now: the first page fixes the snapshot, every later page continues from the exact cursor it was asked for, rows begin at the next sequence and stay adjacent and within the anchor, an empty snapshot is terminal and carries nothing, and terminal means exactly at the anchor. A record must be one of its two legal shapes, so a stop status without a stop, or a member the shape does not declare, is refused rather than ignored. The page's own byte bound is enforced on both sides, including a single row too large to page at all. The public boundary leaked in both directions. Members that return `Result` raised when the link raised; a body that failed propagated instead of returning the failed `Result` the same condition returns from the local host; and the publication link's own errors reached callers as themselves, so a private refusal class and its spelling crossed a provider-neutral interface. Each is translated once now, at the adapter, and the storage distinctions are kept: a store belonging to something else is not damage, a version this build does not implement is not damage, and a record it cannot read is not an unreachable owner. Those are different facts and a host acts on them differently. Retrieval accepted any well-formed answer. A performed reply naming different metadata would have installed where the definition is fetched from — so the answer is now required to be canonically the value that was asked for, and a contradiction fails closed with the snapshot untouched. Input is parsed by the rules a stored value is held to and bounded before anything is sent, because a value this build will not keep is not a request to make. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/client.ts | 156 +++++++++++++++--- .../workflow/src/cloudflare/owner-reads.ts | 6 +- packages/workflow/src/remote/database.ts | 121 ++++++++++++-- packages/workflow/src/remote/records.ts | 22 ++- .../workflow/tests/remote-database.test.ts | 52 +++++- packages/workflow/tests/remote-read.test.ts | 115 +++++++++++++ 6 files changed, 417 insertions(+), 55 deletions(-) diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts index 6fbacb65f..b942f8c2d 100644 --- a/packages/workflow/src/cloudflare/client.ts +++ b/packages/workflow/src/cloudflare/client.ts @@ -55,10 +55,18 @@ import { type WorkspaceRootManifest, } from "../workspace/root-manifest.ts"; import { decodeContentManifest } from "../workspace/content-manifest.ts"; -import { EXECUTION_PAGE_ENTRIES, JOURNAL_PAGE_ENTRIES, MAX_CONTENT_BYTES } from "./commands.ts"; +import { + EXECUTION_PAGE_BYTES, + EXECUTION_PAGE_ENTRIES, + JOURNAL_PAGE_ENTRIES, + MAX_CONTENT_BYTES, +} from "./commands.ts"; import type { RemoteRunLink } from "../remote/database.ts"; +import { SCHEMA_VERSION } from "../sqlite/workflow-schema.ts"; import { WorkflowDatabaseCorruptError, + WorkflowDatabaseFormatError, + WorkflowSchemaVersionError, WorkflowRecordMalformedError, WorkflowRequestError, WorkflowStorageError, @@ -589,14 +597,40 @@ export function cloudflareRunLink( connection: OwnerConnection, reads: RemoteReadLink, nextId: () => string, + expectedRunId: string, ): RemoteRunLink { const publication = cloudflareOwnerLink(connection, reads, nextId); return { - frontier: publication.frontier, - commit: publication.commit, + /** + * 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 { - return yield* reads.frontier(); + try { + return yield* reads.frontier(); + } catch (error) { + throw translate(error); + } }, *replaceRetrieval( @@ -638,30 +672,58 @@ export function cloudflareRunLink( *readExecutions(): Operation> { try { - const found: { sequence: number; record: DocumentExecutionRecord }[] = []; + 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(), anchor ?? null, after); - // The first page chooses the anchor; every later one is held to it. + 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( - new WorkflowRecordMalformedError( - "document executions", - "a page did not continue its anchored snapshot", - ), - ); + 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) { - found.push(row); + 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 = page.rows.at(-1)?.sequence ?? after; + after = previous; done = page.done; } - return Ok(found.map((row) => row.record)); + return Ok(found); } catch (error) { return Err(translate(error)); } @@ -669,6 +731,11 @@ export function cloudflareRunLink( }; } +/** 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; @@ -680,6 +747,7 @@ interface ExecutionPage { function* askPage( connection: OwnerConnection, id: string, + expectedRunId: string, anchor: number | null, after: number | null, ): Operation { @@ -688,24 +756,28 @@ function* askPage( { 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 (new TextEncoder().encode(JSON.stringify(offered)).length > 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"); } - let previous = after; 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"); } - if (previous !== null && sequence <= previous) { - return fail("an execution page repeated or reordered a row"); - } - previous = sequence; return { sequence, record: parseRemoteExecution(item.get("record")) }; }); return { @@ -741,8 +813,18 @@ function nullableSequence(value: unknown): number | null { * line: they describe a protocol nobody above here is party to. */ function storageFailure(refusal: PrivateRefusal): WorkflowStorageError { - if (refusal.startsWith("storage:")) { - return new WorkflowDatabaseCorruptError("the workflow run's remote storage", refusal.slice(8)); + // 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"); + } + if (refusal === "storage:unsupported-version") { + return new WorkflowSchemaVersionError(REMOTE_STORE, 0, 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( @@ -755,7 +837,22 @@ function storageFailure(refusal: PrivateRefusal): WorkflowStorageError { return new WorkflowTransactionError("this run's owner refused the operation."); } -/** Any failure from the private protocol, as a provider-neutral one. */ +/** + * 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. + */ function translate(error: unknown): WorkflowStorageError { if (error instanceof CloudflareOwnerRefusalError) { return storageFailure(error.refusal); @@ -763,5 +860,14 @@ function translate(error: unknown): WorkflowStorageError { if (error instanceof WorkflowStorageError) { return error; } - return new WorkflowTransactionError("this run's owner could not be reached."); + 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) { + 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/owner-reads.ts b/packages/workflow/src/cloudflare/owner-reads.ts index 5d5c16a79..c7ba43876 100644 --- a/packages/workflow/src/cloudflare/owner-reads.ts +++ b/packages/workflow/src/cloudflare/owner-reads.ts @@ -532,7 +532,11 @@ export function readExecutions( if (page.length > 0 && encoded + bytes > EXECUTION_PAGE_BYTES) { break; } - if (bytes > MAX_CONTENT_BYTES) { + if (bytes > EXECUTION_PAGE_BYTES) { + // One row larger than a whole page. The bound is the page's, not the + // message envelope's: a row that could never fit means this snapshot + // cannot be paged at all, and answering with it anyway would send + // something the runner is required to refuse. throw new CommandError("too-large"); } page.push({ sequence: at, record: row }); diff --git a/packages/workflow/src/remote/database.ts b/packages/workflow/src/remote/database.ts index 248bc25b5..5bd7da037 100644 --- a/packages/workflow/src/remote/database.ts +++ b/packages/workflow/src/remote/database.ts @@ -35,7 +35,14 @@ import { } from "effection"; import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; import type { JournalEntry, WorkflowRunDatabase, WorkflowRunTransaction } from "../storage/api.ts"; -import { WorkflowDatabaseClosedError, WorkflowTransactionError } from "../storage/errors.ts"; +import { + WorkflowDatabaseClosedError, + WorkflowRecordMalformedError, + WorkflowRequestError, + WorkflowStorageError, + WorkflowTransactionError, +} from "../storage/errors.ts"; +import { parseJsonValue } from "../storage/members.ts"; import type { DefinitionRetrieval, DocumentExecutionRecord, @@ -124,6 +131,18 @@ export function* activeWorkspaceRoute( 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. * @@ -195,13 +214,25 @@ export function useRemoteRunDatabase( return Ok(); } - /** One turn at the handle, for an ordinary operation. */ + /** + * 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(body); + return yield* turns.take(function* (): Operation> { + try { + return yield* body(); + } catch (error) { + return Err(failure(error)); + } + }); } const ordinary: DurableStream = { @@ -242,17 +273,25 @@ export function useRemoteRunDatabase( ); } return yield* turns.take(function* (): Operation> { - return yield* transactRemotely(link, gate, function* (transaction, enlist) { - // 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(), + try { + return yield* transactRemotely(link, gate, function* (transaction, enlist) { + // 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 }); + return yield* body(transaction); }); - yield* ActiveRoute.set({ database: handle, transaction, enlist }); - 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)); + } }); } @@ -279,20 +318,48 @@ export function useRemoteRunDatabase( }, *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)); + } + if (encoded !== null && new TextEncoder().encode(encoded).length > MAX_RETRIEVAL_BYTES) { + return Err( + new WorkflowRequestError( + "this retrieval metadata is larger than one message may carry, so it was not sent.", + ), + ); + } + const replaced = yield* turn(function* () { const snapshot = yield* link.frontierSnapshot(); - return yield* link.replaceRetrieval( - snapshot.workspaceRootId, - metadata === undefined ? null : canonical(metadata), - ); + 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 = replaced.value; + retrieval = answered; return Ok(); }, @@ -308,6 +375,24 @@ export function useRemoteRunDatabase( }); } +/** The most bytes one canonical retrieval value may carry. */ +const MAX_RETRIEVAL_BYTES = 8 * 1024 * 1024; + +/** 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. * diff --git a/packages/workflow/src/remote/records.ts b/packages/workflow/src/remote/records.ts index ab58b50ad..7897720d3 100644 --- a/packages/workflow/src/remote/records.ts +++ b/packages/workflow/src/remote/records.ts @@ -153,6 +153,22 @@ export function parseRemoteJournalEntry(value: unknown): JournalEntry { */ 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"); @@ -164,16 +180,16 @@ export function parseRemoteExecution(value: unknown): DocumentExecutionRecord { if (!found.has("stoppedAt")) { return Object.freeze(record); } - const stopped: DocumentExecutionRecord = { + const halted: DocumentExecutionRecord = { ...record, stoppedAt: instant(found.get("stoppedAt"), "$.stoppedAt"), stopStatus: parseWorkflowRunStatus(found.get("stopStatus"), "$.stopStatus", fail), }; if (!found.has("stopReason")) { - return Object.freeze(stopped); + return Object.freeze(halted); } return Object.freeze({ - ...stopped, + ...halted, stopReason: parseWorkflowStopReason(found.get("stopReason"), "$.stopReason", fail), }); } diff --git a/packages/workflow/tests/remote-database.test.ts b/packages/workflow/tests/remote-database.test.ts index 2b882055c..18bcef0bf 100644 --- a/packages/workflow/tests/remote-database.test.ts +++ b/packages/workflow/tests/remote-database.test.ts @@ -358,6 +358,38 @@ describe("a run whose storage is somewhere else", () => { }); }); + 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")), @@ -410,16 +442,20 @@ describe("a run whose storage is somewhere else", () => { const raising = owner(); yield* scoped(function* () { const database = yield* useDatabase(raising.link); - let caught: unknown; - try { - yield* database.transact(function* () { - throw new Error("the body failed"); - }); - } catch (error) { - caught = error; + // 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(caught).toBeInstanceOf(Error); expect(raising.commits).toHaveLength(0); + // And the handle is still usable afterwards. + expect(ok(yield* database.readJournalEntries())).toEqual([]); }); }); diff --git a/packages/workflow/tests/remote-read.test.ts b/packages/workflow/tests/remote-read.test.ts index e0b0f4fbe..ab66205e5 100644 --- a/packages/workflow/tests/remote-read.test.ts +++ b/packages/workflow/tests/remote-read.test.ts @@ -20,8 +20,10 @@ import { scoped } from "effection"; import { cloudflareOwnerLink, cloudflareReadLink, + cloudflareRunLink, stageCloudflareContent, } from "../src/cloudflare/client.ts"; +import { WorkflowStorageError } from "../src/storage/errors.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"; @@ -431,4 +433,117 @@ describe("semantic reads from a Cloudflare owner", () => { // 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" } }, + ]), + }; + + 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, + cloudflareReadLink(connection, ids(), RUN_ID), + 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("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, + cloudflareReadLink(connection, ids(), RUN_ID), + 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, + cloudflareReadLink(connection, ids(), RUN_ID), + ids(), + RUN_ID, + ); + const read = yield* link.readExecutions(); + expect(read.ok && read.value).toEqual([]); + }); + }); }); From e61793cacb8febac6d1cb64e49905e415b3140e0 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Fri, 4 Sep 2026 12:52:53 -0400 Subject: [PATCH 38/42] =?UTF-8?q?=F0=9F=90=9B=20Send=20the=20record=20the?= =?UTF-8?q?=20client=20parses,=20and=20carry=20the=20version=20the=20owner?= =?UTF-8?q?=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner validated each execution row and then sent the physical row anyway, so the composed owner-to-client path could not return a single execution. Both halves passed against a hand-built counterpart, which is exactly how the disagreement survived; the new workerd regression composes the real pages through the real client and fails against the previous commit. The page bound is now one function both ends call, measuring the exact `rows` member as it crosses. The unsupported-version refusal carries the version the owner actually read instead of a placeholder. A retrieval answer describing another replacement is settled where the answer arrives, closing the channel. The message bound is enforced against the complete serialized request, correlation id included, before it is registered or sent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/client.ts | 28 ++++- packages/workflow/src/cloudflare/commands.ts | 17 ++- .../workflow/src/cloudflare/owner-reads.ts | 34 +++--- packages/workflow/src/cloudflare/owner.ts | 7 ++ packages/workflow/src/remote/client.ts | 18 ++- packages/workflow/src/remote/database.ts | 19 ++- .../tests/cloudflare/remote-owner.vitest.ts | 112 +++++++++++++++++- .../cloudflare/support/executor-object.ts | 12 ++ packages/workflow/tests/remote-client.test.ts | 37 ++++++ packages/workflow/tests/remote-read.test.ts | 58 ++++++++- 10 files changed, 303 insertions(+), 39 deletions(-) diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts index b942f8c2d..00d62e903 100644 --- a/packages/workflow/src/cloudflare/client.ts +++ b/packages/workflow/src/cloudflare/client.ts @@ -58,11 +58,13 @@ 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 } from "../remote/database.ts"; import { SCHEMA_VERSION } from "../sqlite/workflow-schema.ts"; +import { canonicalJson } from "../storage/record.ts"; import { WorkflowDatabaseCorruptError, WorkflowDatabaseFormatError, @@ -92,7 +94,7 @@ export type PrivateRefusal = | "command:stale-journal" | "command:mapping-conflict" | "storage:foreign" - | "storage:unsupported-version" + | `storage:unsupported-version-v${number}` | "storage:corrupt"; export class CloudflareOwnerRefusalError extends Error { @@ -168,11 +170,17 @@ function privateRefusal(value: string): PrivateRefusal { case "command:stale-journal": case "command:mapping-conflict": case "storage:foreign": - case "storage:unsupported-version": case "storage:corrupt": return value; - default: + default: { + // The one category that carries a value: the schema version the owner + // actually read, bounded and parsed rather than guessed. + const unsupported = /^storage:unsupported-version-v(\d{1,6})$/.exec(value); + if (unsupported !== null) { + return `storage:unsupported-version-v${Number(unsupported[1])}`; + } return fail("it named an unknown refusal category"); + } } } @@ -658,6 +666,13 @@ export function cloudflareRunLink( 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, @@ -764,7 +779,7 @@ function* askPage( if (!Array.isArray(offered) || offered.length > EXECUTION_PAGE_ENTRIES) { return fail("an execution page was not one bounded page"); } - if (new TextEncoder().encode(JSON.stringify(offered)).length > EXECUTION_PAGE_BYTES) { + 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"); @@ -820,8 +835,9 @@ function storageFailure(refusal: PrivateRefusal): WorkflowStorageError { if (refusal === "storage:foreign") { return new WorkflowDatabaseFormatError(REMOTE_STORE, "it belongs to something else"); } - if (refusal === "storage:unsupported-version") { - return new WorkflowSchemaVersionError(REMOTE_STORE, 0, SCHEMA_VERSION); + const unsupported = /^storage:unsupported-version-v(\d{1,6})$/.exec(refusal); + if (unsupported !== null) { + return new WorkflowSchemaVersionError(REMOTE_STORE, Number(unsupported[1]), SCHEMA_VERSION); } if (refusal === "storage:corrupt") { return new WorkflowDatabaseCorruptError(REMOTE_STORE, "its retained records do not agree"); diff --git a/packages/workflow/src/cloudflare/commands.ts b/packages/workflow/src/cloudflare/commands.ts index 55b2442f9..6fa306611 100644 --- a/packages/workflow/src/cloudflare/commands.ts +++ b/packages/workflow/src/cloudflare/commands.ts @@ -12,8 +12,10 @@ import { type WorktreeRecord, } from "../composition/records.ts"; import { type AgentSessionRecord, parseAgentSessionRecord } from "../storage/agent-session.ts"; +import { MAX_MESSAGE_BYTES } from "../remote/client.ts"; + +export { MAX_MESSAGE_BYTES }; -export const MAX_MESSAGE_BYTES = 8 * 1024 * 1024; export const MAX_CONTENT_BYTES = 1024 * 1024; export const MAX_STAGED_BYTES = 2 * 1024 * 1024; export const MAX_COMMANDS = 256; @@ -24,6 +26,19 @@ export const JOURNAL_PAGE_BYTES = 512 * 1024; 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. */ diff --git a/packages/workflow/src/cloudflare/owner-reads.ts b/packages/workflow/src/cloudflare/owner-reads.ts index c7ba43876..a76580432 100644 --- a/packages/workflow/src/cloudflare/owner-reads.ts +++ b/packages/workflow/src/cloudflare/owner-reads.ts @@ -27,6 +27,7 @@ import { parseDurableEvent } from "@executablemd/durable-streams"; import { readDocumentExecution, readRetrieval, readRunRecord, type Row } from "../sqlite/rows.ts"; +import type { DocumentExecutionRecord } from "../storage/record.ts"; import { WorkflowRecordMalformedError } from "../storage/errors.ts"; import { parseWorkspaceRootManifest, @@ -39,6 +40,7 @@ import { CommandError, EXECUTION_PAGE_BYTES, EXECUTION_PAGE_ENTRIES, + executionPageBytes, JOURNAL_PAGE_BYTES, JOURNAL_PAGE_ENTRIES, MAX_CONTENT_BYTES, @@ -474,7 +476,7 @@ export interface ExecutionsValue { readonly runId: string; readonly anchor: number | null; readonly after: number | null; - readonly rows: readonly { readonly sequence: number; readonly record: Row }[]; + readonly rows: readonly { readonly sequence: number; readonly record: DocumentExecutionRecord }[]; readonly done: boolean; } @@ -520,27 +522,23 @@ export function readExecutions( EXECUTION_PAGE_ENTRIES + 1, ); - const page: { sequence: number; record: Row }[] = []; - let encoded = 0; + const page: { sequence: number; record: DocumentExecutionRecord }[] = []; for (const row of found.slice(0, EXECUTION_PAGE_ENTRIES)) { const at = safeInteger(row["sequence"], "execution sequence"); - // Parsed here as well as on the runner: a row this owner cannot read is - // storage damage, and sending it would make the runner report damage it - // cannot attribute. - readDocumentExecution(row); - const bytes = new TextEncoder().encode(JSON.stringify(row)).length; - if (page.length > 0 && encoded + bytes > EXECUTION_PAGE_BYTES) { + // 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; } - if (bytes > EXECUTION_PAGE_BYTES) { - // One row larger than a whole page. The bound is the page's, not the - // message envelope's: a row that could never fit means this snapshot - // cannot be paged at all, and answering with it anyway would send - // something the runner is required to refuse. - throw new CommandError("too-large"); - } - page.push({ sequence: at, record: row }); - encoded += bytes; + page.push(entry); } const done = found.length <= page.length; diff --git a/packages/workflow/src/cloudflare/owner.ts b/packages/workflow/src/cloudflare/owner.ts index c1dbf96d4..41d985f55 100644 --- a/packages/workflow/src/cloudflare/owner.ts +++ b/packages/workflow/src/cloudflare/owner.ts @@ -91,6 +91,13 @@ export function refusalOf(error: unknown): string { 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) { diff --git a/packages/workflow/src/remote/client.ts b/packages/workflow/src/remote/client.ts index 93aa599b0..ef4af4006 100644 --- a/packages/workflow/src/remote/client.ts +++ b/packages/workflow/src/remote/client.ts @@ -24,6 +24,15 @@ 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" @@ -309,6 +318,13 @@ export function useOwnerConnection(socket: OwnerSocket): Operation MAX_MESSAGE_BYTES) { + throw new OwnerLinkError("too-large"); + } const settle = withResolvers>(); waiting.set(id, { deliver(answer: RawAnswer): boolean { @@ -336,7 +352,7 @@ export function useOwnerConnection(socket: OwnerSocket): Operation MAX_RETRIEVAL_BYTES) { - return Err( - new WorkflowRequestError( - "this retrieval metadata is larger than one message may carry, so it was not sent.", - ), - ); - } - const replaced = yield* turn(function* () { const snapshot = yield* link.frontierSnapshot(); return yield* link.replaceRetrieval(snapshot.workspaceRootId, encoded); @@ -375,9 +375,6 @@ export function useRemoteRunDatabase( }); } -/** The most bytes one canonical retrieval value may carry. */ -const MAX_RETRIEVAL_BYTES = 8 * 1024 * 1024; - /** How a malformed retrieval value is reported, before anything is sent. */ function retrievalFailure(reason: string, path: string): Error { return new WorkflowRequestError( diff --git a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts index 395ff462f..73c03f795 100644 --- a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts @@ -32,8 +32,16 @@ import { VALID_CLAIMS, } from "./support/executor-object.ts"; import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; +import { call, 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; @@ -118,6 +126,32 @@ function askFrame( }); } +/** + * 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): OwnerSocket { + const listeners = new Map(); + return { + send: (data) => socket.send(data), + close: () => socket.close(), + addEventListener(type, listener) { + const bound: EventListener = (event) => listener(event as { data?: unknown }); + listeners.set(listener, bound); + socket.addEventListener(type, bound); + }, + removeEventListener(type, listener) { + const bound = listeners.get(listener); + if (bound !== undefined) { + socket.removeEventListener(type, bound); + } + }, + }; +} + function record(value: unknown): Record { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new Error("expected an object answer"); @@ -157,7 +191,7 @@ describe("the remote owner protocol", () => { owner.initialize(); owner.rewriteMarker(0x584d4431, 2); }, - "storage:unsupported-version", + "storage:unsupported-version-v2", ], [ "damaged", @@ -268,6 +302,82 @@ describe("the remote owner protocol", () => { 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; + const outcome = await run(function* () { + const connection = yield* useOwnerConnection(ownerSocket(socket)); + const ids = () => `read-${(identifier += 1)}`; + const link = cloudflareRunLink( + connection, + cloudflareReadLink(connection, ids, RUN_ID), + ids, + RUN_ID, + ); + const first = yield* link.readExecutions(); + // Appended after the snapshot was anchored, and while the read is still + // paging: the anchor is what decides, not when the rows were written. + yield* call(() => on(stub, (owner) => owner.beginExecution("execution-later", NEW_START))); + return { first, second: yield* link.readExecutions() }; + }); + + 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("returns only content referenced by one validated root", async () => { const stub = executor(); await on(stub, (owner) => owner.initialize()); diff --git a/packages/workflow/tests/cloudflare/support/executor-object.ts b/packages/workflow/tests/cloudflare/support/executor-object.ts index 7ab9cb51d..7a6ef6512 100644 --- a/packages/workflow/tests/cloudflare/support/executor-object.ts +++ b/packages/workflow/tests/cloudflare/support/executor-object.ts @@ -362,6 +362,18 @@ export class ExecutorObject extends WorkflowOwnerObject { ); } + /** 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 diff --git a/packages/workflow/tests/remote-client.test.ts b/packages/workflow/tests/remote-client.test.ts index b9711a6de..74fa2b515 100644 --- a/packages/workflow/tests/remote-client.test.ts +++ b/packages/workflow/tests/remote-client.test.ts @@ -15,6 +15,7 @@ import { type OwnerSocket, OwnerLinkError, type SocketListener, + MAX_MESSAGE_BYTES, useOwnerConnection, } from "../src/remote/client.ts"; @@ -469,6 +470,42 @@ describe("a connection to a run's owner", () => { 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; diff --git a/packages/workflow/tests/remote-read.test.ts b/packages/workflow/tests/remote-read.test.ts index ab66205e5..0616654b7 100644 --- a/packages/workflow/tests/remote-read.test.ts +++ b/packages/workflow/tests/remote-read.test.ts @@ -23,7 +23,8 @@ import { cloudflareRunLink, stageCloudflareContent, } from "../src/cloudflare/client.ts"; -import { WorkflowStorageError } from "../src/storage/errors.ts"; +import { WorkflowSchemaVersionError, WorkflowStorageError } from "../src/storage/errors.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"; @@ -338,6 +339,61 @@ describe("semantic reads from a Cloudflare owner", () => { 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, + cloudflareReadLink(connection, ids(), RUN_ID), + 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("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. + const transport = wire(() => ({ + outcome: "refused", + refusal: "storage:unsupported-version-v7", + })); + let outcome: unknown; + yield* scoped(function* () { + const connection = yield* useOwnerConnection(transport.socket); + const link = cloudflareRunLink( + connection, + cloudflareReadLink(connection, ids(), RUN_ID), + ids(), + RUN_ID, + ); + outcome = yield* link.readExecutions(); + }); + const failed = outcome as { ok: boolean; error: Error }; + expect(failed.ok).toBe(false); + expect(failed.error).toEqual(expect.any(WorkflowSchemaVersionError)); + const version = failed.error as WorkflowSchemaVersionError; + expect([version.stored, version.supported]).toEqual([7, SCHEMA_VERSION]); + }); + it("closes when content bytes disagree with the requested identity", function* () { const transport = wire(() => ({ outcome: "performed", From 0303ae20403a79503fc7b48e18e5ec5ee38510a3 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Fri, 4 Sep 2026 13:24:33 -0400 Subject: [PATCH 39/42] =?UTF-8?q?=F0=9F=90=9B=20Report=20what=20actually?= =?UTF-8?q?=20failed,=20and=20close=20the=20version=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parser failure was discarded in favour of the channel's, so a contradictory retrieval answer reached the public boundary as an unreachable owner. The request whose answer could not be read now keeps that failure and every other waiter still learns the channel ended, which lets the adapter report a malformed record. A commit answer this build cannot read is undecided for the same reason a lost connection is. An oversized request is classified once, at the adapter boundary, so the database returns a request failure rather than a transaction failure; the duplicate mapping that could never run is gone. Schema versions now have one domain, taken from the carrier they live in and used at marker recognition, refusal encoding, refusal parsing and the public error. Version zero is a partial initialization and so damage, and a value the carrier could never hold is damaged retained data — neither is a version this build is behind. The composed execution regression now writes its later row between the two page requests of one read, and both ends are held to the shared page bound. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/client.ts | 45 ++- packages/workflow/src/cloudflare/marker.ts | 13 +- .../workflow/src/cloudflare/recognition.ts | 4 +- packages/workflow/src/remote/client.ts | 43 ++- packages/workflow/src/remote/database.ts | 8 - .../workflow/src/sqlite/workflow-schema.ts | 22 ++ .../tests/cloudflare/owner-storage.vitest.ts | 24 +- .../tests/cloudflare/remote-owner.vitest.ts | 119 +++++++- packages/workflow/tests/remote-client.test.ts | 14 +- packages/workflow/tests/remote-read.test.ts | 267 ++++++++++++++++-- 10 files changed, 488 insertions(+), 71 deletions(-) diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts index 00d62e903..bc70835b9 100644 --- a/packages/workflow/src/cloudflare/client.ts +++ b/packages/workflow/src/cloudflare/client.ts @@ -63,7 +63,7 @@ import { MAX_CONTENT_BYTES, } from "./commands.ts"; import type { RemoteRunLink } from "../remote/database.ts"; -import { SCHEMA_VERSION } from "../sqlite/workflow-schema.ts"; +import { isSchemaVersion, SCHEMA_VERSION } from "../sqlite/workflow-schema.ts"; import { canonicalJson } from "../storage/record.ts"; import { WorkflowDatabaseCorruptError, @@ -175,9 +175,9 @@ function privateRefusal(value: string): PrivateRefusal { default: { // The one category that carries a value: the schema version the owner // actually read, bounded and parsed rather than guessed. - const unsupported = /^storage:unsupported-version-v(\d{1,6})$/.exec(value); - if (unsupported !== null) { - return `storage:unsupported-version-v${Number(unsupported[1])}`; + const unsupported = readUnsupportedVersion(value); + if (unsupported !== undefined) { + return `storage:unsupported-version-v${unsupported}`; } return fail("it named an unknown refusal category"); } @@ -305,6 +305,22 @@ function parseContent(value: unknown): RemoteContent { 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, @@ -426,9 +442,10 @@ export function cloudflareOwnerLink( ? Err(new CloudflareOwnerRefusalError(answered.refusal)) : Ok(answered.decision); } catch (error) { - if (error instanceof OwnerLinkError) { - // The connection went while the answer was in flight. Whether the - // owner committed is exactly what cannot be known from here, so the + 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); @@ -835,9 +852,9 @@ function storageFailure(refusal: PrivateRefusal): WorkflowStorageError { if (refusal === "storage:foreign") { return new WorkflowDatabaseFormatError(REMOTE_STORE, "it belongs to something else"); } - const unsupported = /^storage:unsupported-version-v(\d{1,6})$/.exec(refusal); - if (unsupported !== null) { - return new WorkflowSchemaVersionError(REMOTE_STORE, Number(unsupported[1]), SCHEMA_VERSION); + 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"); @@ -883,6 +900,14 @@ function translate(error: unknown): WorkflowStorageError { ); } 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/marker.ts b/packages/workflow/src/cloudflare/marker.ts index e111577dc..da15833c9 100644 --- a/packages/workflow/src/cloudflare/marker.ts +++ b/packages/workflow/src/cloudflare/marker.ts @@ -20,7 +20,7 @@ * refused rather than migrated. */ -import { APPLICATION_ID, SCHEMA_VERSION } from "../sqlite/workflow-schema.ts"; +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"; @@ -43,6 +43,7 @@ export type MarkerFailure = | { 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 }; /** @@ -76,6 +77,16 @@ export function readMarker(rows: readonly Record[]): SchemaMark 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 }; } diff --git a/packages/workflow/src/cloudflare/recognition.ts b/packages/workflow/src/cloudflare/recognition.ts index e283e5b4d..9368f9e31 100644 --- a/packages/workflow/src/cloudflare/recognition.ts +++ b/packages/workflow/src/cloudflare/recognition.ts @@ -163,7 +163,9 @@ export function recognizeObject(storage: OwnerStorage): void { ? "its schema marker table holds no identity row" : marker.kind === "duplicated" ? "its schema marker table holds more than one identity row" - : "its schema marker row does not describe an identity", + : 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", }); } diff --git a/packages/workflow/src/remote/client.ts b/packages/workflow/src/remote/client.ts index ef4af4006..d03d2e152 100644 --- a/packages/workflow/src/remote/client.ts +++ b/packages/workflow/src/remote/client.ts @@ -44,6 +44,17 @@ export type LinkRefusal = | "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"; @@ -210,10 +221,13 @@ export function useOwnerConnection(socket: OwnerSocket): Operation(); @@ -252,11 +266,11 @@ export function useOwnerConnection(socket: OwnerSocket): Operation>(); waiting.set(id, { - deliver(answer: RawAnswer): boolean { + deliver(answer: RawAnswer): Error | undefined { if (answer.outcome === "refused") { let refusal: string; try { refusal = parseRefusal(answer.refusal); - } catch { - return false; + } catch (error) { + settle.reject(unreadable(error)); + return unreadable(error); } settle.resolve({ outcome: "refused", refusal }); - return true; + return undefined; } let value: T; try { value = parse(answer.value); - } catch { - return false; + } 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 true; + return undefined; }, fail(error: OwnerLinkError): void { settle.reject(error); diff --git a/packages/workflow/src/remote/database.ts b/packages/workflow/src/remote/database.ts index 310e160a7..2607346f3 100644 --- a/packages/workflow/src/remote/database.ts +++ b/packages/workflow/src/remote/database.ts @@ -49,7 +49,6 @@ import type { WorkflowRunRecord, } from "../storage/record.ts"; import { createTransactionGate, type OwnerLink, transactRemotely } from "./collector.ts"; -import { OwnerLinkError } from "./client.ts"; import type { EnlistWorkspace } from "./collector.ts"; import type { RemoteFrontierSnapshot } from "./read.ts"; @@ -141,13 +140,6 @@ export function* activeWorkspaceRoute( * string nobody can act on. */ function failure(error: unknown): Error { - if (error instanceof OwnerLinkError && error.refusal === "too-large") { - // The channel measured the whole request and never sent it. To a caller - // that is not a lost connection, it is a request too large to make. - return new WorkflowRequestError( - "this request is larger than one message may carry, so it was not sent.", - ); - } return error instanceof Error ? error : new WorkflowTransactionError(String(error)); } diff --git a/packages/workflow/src/sqlite/workflow-schema.ts b/packages/workflow/src/sqlite/workflow-schema.ts index 1f9c8d0a3..df89b9e9e 100644 --- a/packages/workflow/src/sqlite/workflow-schema.ts +++ b/packages/workflow/src/sqlite/workflow-schema.ts @@ -33,6 +33,28 @@ 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'"; /** diff --git a/packages/workflow/tests/cloudflare/owner-storage.vitest.ts b/packages/workflow/tests/cloudflare/owner-storage.vitest.ts index 82544a870..1a28ea72c 100644 --- a/packages/workflow/tests/cloudflare/owner-storage.vitest.ts +++ b/packages/workflow/tests/cloudflare/owner-storage.vitest.ts @@ -69,13 +69,35 @@ describe("recognizing an owner object", () => { expect(await on(stub, (o) => o.recognize())).toBe("refused:unsupported-version"); }); - it("refuses version zero the same way", async () => { + 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()); diff --git a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts index 73c03f795..bded8699e 100644 --- a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts @@ -32,7 +32,7 @@ import { VALID_CLAIMS, } from "./support/executor-object.ts"; import { generateKeys, signToken, type TestKeys } from "./support/tokens.ts"; -import { call, run } from "effection"; +import { run } from "effection"; import { type OwnerSocket, type SocketListener, @@ -133,23 +133,35 @@ function askFrame( * wider than the four members the client uses, so the binding is written out * rather than asserted. */ -function ownerSocket(socket: WebSocket): OwnerSocket { +function ownerSocket(socket: WebSocket, beforeSend?: (raw: string) => Promise | undefined) { const listeners = new Map(); - return { - send: (data) => socket.send(data), + 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 bound: EventListener = (event) => listener(event as { data?: unknown }); - listeners.set(listener, bound); - socket.addEventListener(type, bound); + 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); - if (bound !== undefined) { - socket.removeEventListener(type, bound); + const found = listeners.get(listener); + if (found !== undefined) { + socket.removeEventListener(type, found); } }, }; + return bound; } function record(value: unknown): Record { @@ -326,8 +338,25 @@ describe("the remote owner protocol", () => { 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(ownerSocket(socket)); + const connection = yield* useOwnerConnection(wire); const ids = () => `read-${(identifier += 1)}`; const link = cloudflareRunLink( connection, @@ -336,11 +365,10 @@ describe("the remote owner protocol", () => { RUN_ID, ); const first = yield* link.readExecutions(); - // Appended after the snapshot was anchored, and while the read is still - // paging: the anchor is what decides, not when the rows were written. - yield* call(() => on(stub, (owner) => owner.beginExecution("execution-later", NEW_START))); 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; @@ -378,6 +406,71 @@ describe("the remote owner protocol", () => { 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, + cloudflareReadLink(connection, ids, RUN_ID), + 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, + cloudflareReadLink(connection, ids, RUN_ID), + 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()); diff --git a/packages/workflow/tests/remote-client.test.ts b/packages/workflow/tests/remote-client.test.ts index 74fa2b515..6dcd3fb5f 100644 --- a/packages/workflow/tests/remote-client.test.ts +++ b/packages/workflow/tests/remote-client.test.ts @@ -272,7 +272,7 @@ describe("a connection to a run's owner", () => { expect(refusalOf(raised)).toBe("unknown-answer"); }); - it("fails every waiter when a success value cannot be parsed", function* () { + 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* () { @@ -300,9 +300,15 @@ describe("a connection to a run's owner", () => { yield* second; }); expect(raised).toHaveLength(2); - for (const error of raised) { - expect(refusalOf(error)).toBe("malformed-answer"); - } + // 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); }); diff --git a/packages/workflow/tests/remote-read.test.ts b/packages/workflow/tests/remote-read.test.ts index 0616654b7..d4166c05d 100644 --- a/packages/workflow/tests/remote-read.test.ts +++ b/packages/workflow/tests/remote-read.test.ts @@ -23,17 +23,28 @@ import { cloudflareRunLink, stageCloudflareContent, } from "../src/cloudflare/client.ts"; -import { WorkflowSchemaVersionError, WorkflowStorageError } from "../src/storage/errors.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"; @@ -116,6 +127,16 @@ function wire(answer: (request: Record) => Record { + return { + record: runRecord(), + retrieval: null, + workspaceRootId: ROOT_ID, + journalEventId: null, + }; +} + function ids(): () => string { let id = 0; return () => `request-${(id += 1)}`; @@ -141,6 +162,21 @@ function failure(error: unknown): string { 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 @@ -318,7 +354,7 @@ describe("semantic reads from a Cloudflare owner", () => { raised = error; } }); - expect([description, failure(raised)]).toEqual([description, "malformed-answer"]); + expect([description, unreadable(raised)]).toEqual([description, "malformed-record"]); expect([description, transport.closes]).toEqual([description, 1]); expect([description, transport.listeners]).toEqual([description, 0]); } @@ -335,7 +371,7 @@ describe("semantic reads from a Cloudflare owner", () => { raised = error; } }); - expect(failure(raised)).toBe("malformed-answer"); + expect(unreadable(raised)).toBe("malformed-record"); expect(transport.closes).toBe(1); }); @@ -368,30 +404,175 @@ describe("semantic reads from a Cloudflare owner", () => { expect(transport.closes).toBe(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. - const transport = wire(() => ({ - outcome: "refused", - refusal: "storage:unsupported-version-v7", - })); - let outcome: unknown; + 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, - cloudflareReadLink(connection, ids(), RUN_ID), - ids(), + cloudflareReadLink(connection, next, RUN_ID), + 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, + cloudflareReadLink(connection, next, RUN_ID), + next, RUN_ID, ); - outcome = yield* link.readExecutions(); + 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); }); - const failed = outcome as { ok: boolean; error: Error }; - expect(failed.ok).toBe(false); - expect(failed.error).toEqual(expect.any(WorkflowSchemaVersionError)); - const version = failed.error as WorkflowSchemaVersionError; - expect([version.stored, version.supported]).toEqual([7, SCHEMA_VERSION]); + 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, + cloudflareReadLink(connection, next, RUN_ID), + 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, + cloudflareReadLink(connection, next, RUN_ID), + 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* () { @@ -417,7 +598,7 @@ describe("semantic reads from a Cloudflare owner", () => { raised = error; } }); - expect(failure(raised)).toBe("malformed-answer"); + expect(unreadable(raised)).toBe("malformed-record"); expect(transport.closes).toBe(1); }); it("hands the collector one assembled frontier and no page mechanics", function* () { @@ -515,6 +696,12 @@ describe("semantic reads from a Cloudflare owner", () => { "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)) { @@ -543,6 +730,44 @@ describe("semantic reads from a Cloudflare owner", () => { } }); + 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, + cloudflareReadLink(connection, next, RUN_ID), + 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> = { From ddd9f0c02bc239a50cfad63bd742864e0a257621 Mon Sep 17 00:00:00 2001 From: Min Kim Date: Fri, 4 Sep 2026 14:33:20 -0400 Subject: [PATCH 40/42] =?UTF-8?q?=E2=9C=A8=20Run=20Workspace=20work=20on?= =?UTF-8?q?=20the=20runner=20against=20a=20run=20the=20owner=20holds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Deno coordinator opens a transaction, hands a mutation the authoritative filesystem and the retained metadata, and commits both together. This is that shape with the storage somewhere else: the Workspace is a real directory materialized from the exact admitted root, the metadata is a detached snapshot of that same admitted state, and the commit is one intent the owner performs atomically or not at all. Root, journal anchor and every retained mapping are admitted in one owner-side read, because they are one state. It is 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 — a later insert can sort before it and never be seen. A count and byte ceiling refuses instead, and refuses whole. The attempt is created outside the transaction, because the collector seals it after the body tears down. Inside the exact callback the route proves it starts from the admitted state before the document runs, and drift refuses there. A documented Workspace failure journals against the unchanged root; everything else raises and publishes nothing. The Filesystem contract, the Repository/Worktree metadata shapes, the journaled-failure base class and Agent-session reconciliation move to neutral modules so both hosts implement one contract rather than two. The Deno adapter keeps its rows, its savepoint and its synchronous-SQLite exception. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/client.ts | 13 + packages/workflow/src/cloudflare/commands.ts | 12 + .../workflow/src/cloudflare/dispatcher.ts | 11 +- .../workflow/src/cloudflare/owner-reads.ts | 126 +++ .../src/deno/remote-workspace-files.ts | 181 +++++ .../src/deno/workspace/agent-sessions.ts | 103 +-- .../workflow/src/deno/workspace/errors.ts | 26 +- .../workflow/src/deno/workspace/filesystem.ts | 41 +- .../src/deno/workspace/repositories.ts | 24 +- packages/workflow/src/remote/collector.ts | 26 +- packages/workflow/src/remote/database.ts | 8 +- packages/workflow/src/remote/journal-route.ts | 94 +++ packages/workflow/src/remote/mappings.ts | 255 ++++++ packages/workflow/src/remote/read.ts | 3 + packages/workflow/src/remote/records.ts | 139 ++++ packages/workflow/src/remote/workspace.ts | 366 +++++++++ .../workflow/src/storage/agent-session.ts | 90 ++ packages/workflow/src/workspace/failure.ts | 28 + packages/workflow/src/workspace/filesystem.ts | 43 + packages/workflow/src/workspace/metadata.ts | 33 + .../tests/cloudflare/remote-publish.vitest.ts | 59 ++ .../cloudflare/support/executor-object.ts | 17 + .../tests/remote-interoperability.test.ts | 6 + .../tests/remote-materialization.test.ts | 6 + .../workflow/tests/remote-publication.test.ts | 6 + .../workflow/tests/remote-workspace.test.ts | 766 ++++++++++++++++++ 26 files changed, 2309 insertions(+), 173 deletions(-) create mode 100644 packages/workflow/src/deno/remote-workspace-files.ts create mode 100644 packages/workflow/src/remote/journal-route.ts create mode 100644 packages/workflow/src/remote/mappings.ts create mode 100644 packages/workflow/src/remote/workspace.ts create mode 100644 packages/workflow/src/workspace/failure.ts create mode 100644 packages/workflow/src/workspace/filesystem.ts create mode 100644 packages/workflow/src/workspace/metadata.ts create mode 100644 packages/workflow/tests/remote-workspace.test.ts diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts index bc70835b9..f5d55a800 100644 --- a/packages/workflow/src/cloudflare/client.ts +++ b/packages/workflow/src/cloudflare/client.ts @@ -36,7 +36,9 @@ import type { CommitDecision } from "../remote/publication.ts"; import { OwnerLinkError, type OwnerAnswer, type OwnerConnection } from "../remote/client.ts"; import { parseRemoteExecution, + parseRemoteInvocationSnapshot, parseRemoteJournalEntry, + type RemoteInvocationSnapshot, parseRemoteRetrieval, parseRemoteRunRecord, RemoteRecordError, @@ -327,6 +329,17 @@ export function cloudflareReadLink( expectedRunId: string, ): RemoteReadLink { return { + *invocationSnapshot(): Operation { + return answer( + yield* connection.ask( + nextId(), + { command: "mappings" }, + (value) => parseRemoteInvocationSnapshot(value), + privateRefusal, + ), + ); + }, + *frontier(): Operation { const header = answer( yield* connection.ask( diff --git a/packages/workflow/src/cloudflare/commands.ts b/packages/workflow/src/cloudflare/commands.ts index 6fa306611..0cdd1f8d9 100644 --- a/packages/workflow/src/cloudflare/commands.ts +++ b/packages/workflow/src/cloudflare/commands.ts @@ -55,6 +55,7 @@ export type CommandName = | "commit" | "retrieval" | "executions" + | "mappings" | "settle"; export type CommandRefusal = @@ -198,6 +199,11 @@ export interface ExecutionsCommand extends CommandEnvelope { readonly after: number | null; } +/** One coherent admitted state, asked for exactly once per invocation. */ +export interface MappingsCommand extends CommandEnvelope { + readonly command: "mappings"; +} + export interface SettleCommand extends CommandEnvelope { readonly command: "settle"; readonly completion: DocumentExecutionCompletion; @@ -213,6 +219,7 @@ export type RunnerCommand = | CommitCommand | RetrievalCommand | ExecutionsCommand + | MappingsCommand | SettleCommand; export type CommandResult = @@ -238,6 +245,7 @@ const MEMBERS: Record = { ], retrieval: [...ENVELOPE, "expectedWorkspaceRootId", "metadata"], executions: [...ENVELOPE, "anchor", "after"], + mappings: ENVELOPE, settle: [...ENVELOPE, "completion", "expectedWorkspaceRootId"], }; @@ -367,6 +375,7 @@ export function parseCommand(raw: string): RunnerCommand { command !== "commit" && command !== "retrieval" && command !== "executions" && + command !== "mappings" && command !== "settle" ) { throw new CommandError("unknown-command"); @@ -426,6 +435,9 @@ export function parseCommand(raw: string): RunnerCommand { metadata, }; } + if (command === "mappings") { + return { id, command }; + } if (command === "executions") { const anchor = sequence(members, "anchor"); const after = sequence(members, "after"); diff --git a/packages/workflow/src/cloudflare/dispatcher.ts b/packages/workflow/src/cloudflare/dispatcher.ts index 8ee8096f8..bf3345ec7 100644 --- a/packages/workflow/src/cloudflare/dispatcher.ts +++ b/packages/workflow/src/cloudflare/dispatcher.ts @@ -47,6 +47,7 @@ import { bytesOf, decodeBase64, sha256Hex } from "./encoding.ts"; import { readContent, readExecutions, + readInvocationSnapshot, readFrontier, readJournalPage, readRoot, @@ -139,7 +140,8 @@ function retainedDecision(command: RunnerCommand, result: CommandResult): string (command.command === "journal" || command.command === "root" || command.command === "content" || - command.command === "executions") + command.command === "executions" || + command.command === "mappings") ) { return JSON.stringify({ id: command.id, outcome: "reconstruct" }); } @@ -266,6 +268,13 @@ function perform( value: readExecutions(ctx.storage, runId, command.anchor, command.after), }; } + 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. diff --git a/packages/workflow/src/cloudflare/owner-reads.ts b/packages/workflow/src/cloudflare/owner-reads.ts index a76580432..d1ec640f5 100644 --- a/packages/workflow/src/cloudflare/owner-reads.ts +++ b/packages/workflow/src/cloudflare/owner-reads.ts @@ -28,6 +28,13 @@ 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, @@ -41,6 +48,8 @@ import { EXECUTION_PAGE_BYTES, EXECUTION_PAGE_ENTRIES, executionPageBytes, + MAX_LEDGER_BYTES, + MAX_MAPPINGS, JOURNAL_PAGE_BYTES, JOURNAL_PAGE_ENTRIES, MAX_CONTENT_BYTES, @@ -351,6 +360,114 @@ export function readFrontier(storage: OwnerStorage, runId: string): FrontierValu }; } +/** + * 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, @@ -472,6 +589,15 @@ function piece( } /** 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; 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..042c7c7ad --- /dev/null +++ b/packages/workflow/src/deno/remote-workspace-files.ts @@ -0,0 +1,181 @@ +/** + * The Workspace filesystem, over the attempt directory this invocation owns. + * + * The runner's Workspace is a real tree 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. + * + * `node:fs/promises` rather than a runtime global, for the same reason the + * materialization adapter uses it: the same code has to work wherever the + * runner runs. + */ + +import { + chmod, + link, + lstat, + mkdir, + readdir, + readFile, + readlink, + rename, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; +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"; + +/** + * Where a logical path is allowed to land. + * + * The attempt root is the whole of what this invocation may touch. A logical + * path is resolved and then held to that root, so `..`, an absolute path and a + * path that merely starts with the root's name are each refused before any + * syscall — the host path is a place to work, never a durable identity. + */ +function within(at: HostPath, root: string, path: string): string { + const host = resolve(at(path)); + const inside = relative(root, host); + // The empty relative path is the Workspace root itself, which is a directory + // this invocation owns and may read. Only leaving the tree is refused. + if (inside.startsWith("..") || isAbsolute(inside)) { + throw new WorkspacePathError("this Workspace path is outside the tree this invocation owns."); + } + return host; +} + +/** A path no Workspace operation may reach, whatever it names. */ +export class WorkspacePathError extends Error { + override name = "WorkspacePathError"; +} + +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. + */ +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(error.message, { cause: error }); + 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. Passing it separately would let the two disagree. + const resolved = resolve(at("/")); + + function* run(path: string, body: (host: string) => Promise): Operation { + authorize(); + const host = within(at, resolved, path); + try { + return yield* until(body(host)); + } 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)); + } + } + + return { + *readFile(path): Operation { + return yield* run(path, (host) => readFile(host)); + }, + + *readTextFile(path): Operation { + const bytes = yield* run(path, (host) => readFile(host)); + return new TextDecoder().decode(bytes); + }, + + *stat(path): Operation { + return described(yield* run(path, (host) => stat(host))); + }, + + *lstat(path): Operation { + return described(yield* run(path, (host) => lstat(host))); + }, + + *readlink(path): Operation { + return yield* run(path, (host) => readlink(host)); + }, + + *readdir(path): Operation { + const entries = yield* run(path, (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; + yield* run(path, (host) => writeFile(host, bytes, mode === undefined ? {} : { mode })); + }, + + *mkdir(path, options = {}): Operation { + yield* run(path, (host) => mkdir(host, options).then(() => undefined)); + }, + + *remove(path, options = {}): Operation { + yield* run(path, (host) => rm(host, options)); + }, + + *rename(from, to): Operation { + // Both ends are held to the attempt: a rename is two paths, and checking + // one of them would let the other leave the tree. + const destination = within(at, resolved, to); + yield* run(from, (host) => rename(host, destination)); + }, + + *chmod(path, mode): Operation { + yield* run(path, (host) => chmod(host, mode)); + }, + + *symlink(target, path): Operation { + // The target is not resolved here. A symbolic link's target is retained + // exactly as written, and reading through one goes back through this + // interface, where it is held to the attempt like any other path. + yield* run(path, (host) => symlink(target, host)); + }, + + *link(existingPath, newPath): Operation { + const destination = within(at, resolved, newPath); + yield* run(existingPath, (host) => link(host, destination)); + }, + }; +} diff --git a/packages/workflow/src/deno/workspace/agent-sessions.ts b/packages/workflow/src/deno/workspace/agent-sessions.ts index 1142cc92b..e3a418153 100644 --- a/packages/workflow/src/deno/workspace/agent-sessions.ts +++ b/packages/workflow/src/deno/workspace/agent-sessions.ts @@ -39,11 +39,6 @@ import type { DatabaseSync } from "node:sqlite"; -/** A retained Agent session this host will not continue under. */ -export class WorkflowAgentSessionError extends Error { - override name = "WorkflowAgentSessionError"; -} - /** * The shape and the key derivation are the shared rule, not this adapter's. * @@ -53,13 +48,18 @@ export class WorkflowAgentSessionError extends Error { * statements, and the transaction they run in. */ import { - agentSessionKey, - type AgentSessionIdentity, type AgentSessionRecord, - type ProviderAssertion, + type AgentSessions, + WorkflowAgentSessionError, } from "../../storage/agent-session.ts"; -export { agentSessionKey, parseAgentSessionRecord } 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, @@ -77,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; } @@ -162,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/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/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/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/remote/collector.ts b/packages/workflow/src/remote/collector.ts index f3f613ee8..6803ee1f2 100644 --- a/packages/workflow/src/remote/collector.ts +++ b/packages/workflow/src/remote/collector.ts @@ -153,7 +153,11 @@ export function requireNoOpenTransaction(gate: TransactionGate): void { export function transactRemotely( link: OwnerLink, gate: TransactionGate, - body: (transaction: WorkflowRunTransaction, enlist: EnlistWorkspace) => Operation, + body: ( + transaction: WorkflowRunTransaction, + enlist: EnlistWorkspace, + anchor: TransactionAnchor, + ) => Operation, ): Operation> { return call(function* (): Operation> { // Taken synchronously, before the first suspension. Checking and then @@ -238,7 +242,12 @@ export function transactRemotely( // 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)); + outcome = yield* scoped(() => + body({ journal }, enlist, { + workspaceRootId: starting.workspaceRootId, + journalEventId: starting.journalEventId, + }), + ); } finally { // The handle is closed before the commit goes out, so a retained // transaction object refuses while the handle-level gate is still held. @@ -274,6 +283,19 @@ export function transactRemotely( }); } +/** + * 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. * diff --git a/packages/workflow/src/remote/database.ts b/packages/workflow/src/remote/database.ts index 2607346f3..74282936f 100644 --- a/packages/workflow/src/remote/database.ts +++ b/packages/workflow/src/remote/database.ts @@ -49,7 +49,7 @@ import type { WorkflowRunRecord, } from "../storage/record.ts"; import { createTransactionGate, type OwnerLink, transactRemotely } from "./collector.ts"; -import type { EnlistWorkspace } from "./collector.ts"; +import type { EnlistWorkspace, TransactionAnchor } from "./collector.ts"; import type { RemoteFrontierSnapshot } from "./read.ts"; /** What a remote handle needs to answer everything the interface asks. */ @@ -105,6 +105,8 @@ export interface WorkspaceRoute { readonly database: WorkflowRunDatabase; readonly transaction: WorkflowRunTransaction; readonly enlist: EnlistWorkspace; + /** Where this transaction began, so a coordinator can prove it has not drifted. */ + readonly anchor: TransactionAnchor; } const ActiveRoute: Context = createContext( @@ -274,7 +276,7 @@ export function useRemoteRunDatabase( } return yield* turns.take(function* (): Operation> { try { - return yield* transactRemotely(link, gate, function* (transaction, enlist) { + return yield* transactRemotely(link, gate, function* (transaction, enlist, anchor) { // 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 @@ -283,7 +285,7 @@ export function useRemoteRunDatabase( handle, enclosing: yield* ActiveTransaction.get(), }); - yield* ActiveRoute.set({ database: handle, transaction, enlist }); + yield* ActiveRoute.set({ database: handle, transaction, enlist, anchor }); return yield* body(transaction); }); } catch (error) { 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/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/read.ts b/packages/workflow/src/remote/read.ts index 273bb02bd..cd2870968 100644 --- a/packages/workflow/src/remote/read.ts +++ b/packages/workflow/src/remote/read.ts @@ -19,6 +19,7 @@ */ import type { Operation } from "effection"; +import type { RemoteInvocationSnapshot } from "./records.ts"; import type { JournalEntry } from "../storage/api.ts"; import type { DefinitionRetrieval, WorkflowRunRecord } from "../storage/record.ts"; import type { WorkspaceRootManifest } from "../workspace/root-manifest.ts"; @@ -44,6 +45,8 @@ export type RemoteContentRequest = 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; } diff --git a/packages/workflow/src/remote/records.ts b/packages/workflow/src/remote/records.ts index 7897720d3..3bb3383c8 100644 --- a/packages/workflow/src/remote/records.ts +++ b/packages/workflow/src/remote/records.ts @@ -35,6 +35,14 @@ import { 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"; @@ -193,3 +201,134 @@ export function parseRemoteExecution(value: unknown): DocumentExecutionRecord { 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/workspace.ts b/packages/workflow/src/remote/workspace.ts new file mode 100644 index 000000000..b261fdc24 --- /dev/null +++ b/packages/workflow/src/remote/workspace.ts @@ -0,0 +1,366 @@ +/** + * 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, type Operation, 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 { activeWorkspaceRoute, type WorkspaceRoute } from "./database.ts"; +import { createInvocationMappings } from "./mappings.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 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, database: WorkflowRunDatabase): void { + owners.set(identity, database); + }, + get(identity: object): WorkflowRunDatabase | undefined { + return owners.get(identity); + }, + }; +})(); + +interface Registration { + open: boolean; + readonly runtime: RemoteWorkspaceRuntime; + readonly provenance: JournalProvenance; +} + +interface ProviderApi { + readonly provider: object | undefined; +} + +const RemoteWorkspaceProvider: Api = createApi( + "executablemd.workflow.remote.workspace.effect.provider", + { provider: undefined }, +); + +const registrations = (() => { + const held = new WeakMap(); + return { + register(runtime: RemoteWorkspaceRuntime, provenance: JournalProvenance) { + const selection = Object.freeze({}); + const registration: Registration = { open: true, runtime, provenance }; + 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 scope. + * + * The provenance is the one the run's journal was established with. It is held + * here rather than compared structurally, because two journals can describe the + * same events and only one of them is this run's. + */ +export function* useRemoteWorkspaceEffects( + runtime: RemoteWorkspaceRuntime, + provenance: JournalProvenance, +): Operation { + const registration = registrations.register(runtime, provenance); + yield* ensure(registration.close); + yield* RemoteWorkspaceProvider.around({ provider: () => registration.selection }, { at: "min" }); +} + +export function withRemoteWorkspaceEffects( + database: WorkflowRunDatabase, + operation: Operation, +): Operation { + return scoped(function* () { + const selection = yield* RemoteWorkspaceProvider.operations.provider; + const registration = selection === undefined ? undefined : registrations.get(selection); + if (registration === undefined) { + return unavailable("no remote Workspace coordinator is installed for this run."); + } + return yield* withWorkspaceCoordinationProvider(coordinator(database, registration), operation); + }); +} + +export function createRemoteWorkspaceEffect( + database: WorkflowRunDatabase, + description: EffectDescription, + mutate: RemoteWorkspaceMutation, +): DurableEffect { + const execute = () => WorkspaceMutation.operations.run(database, mutate); + const executionIdentity = Object.freeze({}); + workspaceEffectOwners.claim(executionIdentity, database); + return createOwnedDurableWorkspaceOperation(description, execute, executionIdentity); +} + +function coordinator( + database: WorkflowRunDatabase, + registration: Registration, +): WorkspaceCoordinationProvider { + return { + *run(authority: WorkspaceCoordinationAuthority): Operation { + let transacted; + try { + if (workspaceEffectOwners.get(authority.executionIdentity) !== database) { + unavailable( + "the live Workspace effect is missing, foreign, completed, or stale for this " + + "WorkflowRun database.", + ); + } + if ( + authority.journalProvenance === undefined || + authority.journalProvenance !== registration.provenance + ) { + unavailable( + "the live Workspace journal does not have the provenance of the selected WorkflowRun.", + ); + } + transacted = yield* invoke(database, registration, authority); + } catch (error) { + throw yield* authority.activateFailure(error); + } + if (!transacted.ok) { + throw yield* authority.activateFailure(transacted.error); + } + return transacted.value; + }, + }; +} + +function* invoke( + database: WorkflowRunDatabase, + registration: Registration, + authority: WorkspaceCoordinationAuthority, +) { + const { runtime } = registration; + 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( + database, + transaction, + route, + runtime, + snapshot, + attempt, + authority, + ); + }); +} + +function* coordinateTransaction( + database: WorkflowRunDatabase, + transaction: WorkflowRunTransaction, + route: WorkspaceRoute, + runtime: RemoteWorkspaceRuntime, + snapshot: RemoteInvocationSnapshot, + attempt: Attempt, + authority: WorkspaceCoordinationAuthority, +): Operation { + 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* () { + 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/storage/agent-session.ts b/packages/workflow/src/storage/agent-session.ts index 2bba915aa..5fd371494 100644 --- a/packages/workflow/src/storage/agent-session.ts +++ b/packages/workflow/src/storage/agent-session.ts @@ -123,3 +123,93 @@ export function parseAgentSessionRecord(value: unknown): AgentSessionRecord | un 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/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/tests/cloudflare/remote-publish.vitest.ts b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts index e93197f6a..676880fd1 100644 --- a/packages/workflow/tests/cloudflare/remote-publish.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-publish.vitest.ts @@ -215,6 +215,65 @@ describe("publishing one proposal", () => { ).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()); diff --git a/packages/workflow/tests/cloudflare/support/executor-object.ts b/packages/workflow/tests/cloudflare/support/executor-object.ts index 7a6ef6512..adc2034ec 100644 --- a/packages/workflow/tests/cloudflare/support/executor-object.ts +++ b/packages/workflow/tests/cloudflare/support/executor-object.ts @@ -382,6 +382,23 @@ export class ExecutorObject extends WorkflowOwnerObject { return row === undefined ? null : row; } + /** 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 = ?", diff --git a/packages/workflow/tests/remote-interoperability.test.ts b/packages/workflow/tests/remote-interoperability.test.ts index abe7e7a66..6c1e48f9f 100644 --- a/packages/workflow/tests/remote-interoperability.test.ts +++ b/packages/workflow/tests/remote-interoperability.test.ts @@ -20,6 +20,7 @@ * 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"; @@ -95,6 +96,11 @@ function servedBy( 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"); diff --git a/packages/workflow/tests/remote-materialization.test.ts b/packages/workflow/tests/remote-materialization.test.ts index ab68ec386..0ba0b8046 100644 --- a/packages/workflow/tests/remote-materialization.test.ts +++ b/packages/workflow/tests/remote-materialization.test.ts @@ -14,6 +14,7 @@ * 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"; @@ -65,6 +66,11 @@ function servedBy(captured: { 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"); diff --git a/packages/workflow/tests/remote-publication.test.ts b/packages/workflow/tests/remote-publication.test.ts index 72760e737..6b3464153 100644 --- a/packages/workflow/tests/remote-publication.test.ts +++ b/packages/workflow/tests/remote-publication.test.ts @@ -11,6 +11,7 @@ * 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"; @@ -213,6 +214,11 @@ function readsOf(captured: { 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 { diff --git a/packages/workflow/tests/remote-workspace.test.ts b/packages/workflow/tests/remote-workspace.test.ts new file mode 100644 index 000000000..9e1a71403 --- /dev/null +++ b/packages/workflow/tests/remote-workspace.test.ts @@ -0,0 +1,766 @@ +/** + * 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 RemoteWorkspaceMutation, + 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, + ); +} + +interface Harness { + readonly database: WorkflowRunDatabase; + readonly runtime: RemoteWorkspaceRuntime; + 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, +): Operation { + const captured = 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)}`; + const reads = cloudflareReadLink(connection, next, RUN_ID); + const link = cloudflareRunLink(connection, reads, next, RUN_ID); + const database = yield* useRemoteRunDatabase(link, yield* link.frontierSnapshot()); + const runtime: RemoteWorkspaceRuntime = { + files: runnerFiles(), + trees: yield* useRunnerTrees(), + reads, + createFilesystem: (at, authorize) => createRemoteWorkspaceFilesystem(at, authorize), + }; + return { + database, + runtime, + 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* () { + const stream = new InMemoryStream(); + const routed = routeRemoteRunJournal(held.database, stream); + yield* useRemoteWorkspaceEffects(held.runtime, establishJournalProvenance(routed)); + const effect = createRemoteWorkspaceEffect(held.database, { type: "workspace", name }, mutate); + function* workflow(): Workflow { + yield effect; + } + const raised = yield* trapped( + withRemoteWorkspaceEffects(held.database, durableRun(workflow, { stream: routed })), + ); + return { raised, events: stream.snapshot() }; + }); +} + +function yielded(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter((event) => event.type === "yield"); +} + +describe("the runner's Workspace coordinator", () => { + it("commits Files, one mapping and the filtered result as one intent", function* () { + yield* scoped(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. + expect(held.commits).toHaveLength(1); + const intent = held.commits[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* () { + yield* scoped(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. + expect(held.commits).toHaveLength(1); + const intent = held.commits[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); + + // A later invocation materializes the same root it always had: the + // discarded attempt left nothing, and nothing was promoted. + const observed = yield* invocation(held, "observe", function* (filesystem): Operation { + const names = yield* filesystem.readdir("/"); + return names.map((entry) => entry.name).toSorted(); + }); + expect(observed.raised).toBe(undefined); + const seen = held.commits[1] ?? {}; + expect(seen["expectedWorkspaceRootId"]).toBe(held.captured.root.rootId); + }); + }); + + it("prevents the document from running when the admitted root is unreachable", function* () { + yield* scoped(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* () { + yield* scoped(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* () { + yield* scoped(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(held.commits).toHaveLength(1); + }); + }); + + it("refuses an execution identity another database claimed", function* () { + yield* scoped(function* () { + const held = yield* harness(); + const other = yield* harness(); + yield* scoped(function* () { + const stream = new InMemoryStream(); + const routed = routeRemoteRunJournal(held.database, stream); + yield* useRemoteWorkspaceEffects(held.runtime, establishJournalProvenance(routed)); + // Created against one handle, coordinated under another. + const effect = createRemoteWorkspaceEffect( + other.database, + { type: "workspace", name: "foreign" }, + // deno-lint-ignore require-yield + function* (): Operation { + return "ran"; + }, + ); + function* workflow(): Workflow { + yield effect; + } + const raised = yield* trapped( + withRemoteWorkspaceEffects(held.database, durableRun(workflow, { stream: routed })), + ); + expect(String(raised)).toContain("foreign"); + }); + expect(held.commits).toEqual([]); + expect(other.commits).toEqual([]); + }); + }); + + it("refuses a journal whose provenance is not this run's", function* () { + yield* scoped(function* () { + const held = yield* harness(); + yield* scoped(function* () { + const stream = new InMemoryStream(); + const routed = routeRemoteRunJournal(held.database, stream); + yield* useRemoteWorkspaceEffects(held.runtime, establishJournalProvenance(routed)); + const effect = createRemoteWorkspaceEffect( + held.database, + { type: "workspace", name: "provenance" }, + // deno-lint-ignore require-yield + function* (): Operation { + return "ran"; + }, + ); + function* workflow(): Workflow { + yield effect; + } + // A different stream: structurally identical, and not this run's journal. + const foreign = new InMemoryStream(); + establishJournalProvenance(foreign); + const raised = yield* trapped( + withRemoteWorkspaceEffects(held.database, durableRun(workflow, { stream: foreign })), + ); + expect(String(raised)).toContain("provenance"); + }); + expect(held.commits).toEqual([]); + }); + }); + + it("refuses a Files capability kept past the invocation that owned it", function* () { + yield* scoped(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* () { + yield* scoped(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* () { + yield* scoped(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(held.commits).toHaveLength(1); + }); + }); + + it("sends nothing and keeps nothing when the invocation is cancelled", function* () { + yield* scoped(function* () { + const held = yield* harness(); + const stream = new InMemoryStream(); + const routed = routeRemoteRunJournal(held.database, stream); + yield* useRemoteWorkspaceEffects(held.runtime, establishJournalProvenance(routed)); + const effect = createRemoteWorkspaceEffect( + held.database, + { 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.database, durableRun(workflow, { stream: routed })), + ); + yield* sleep(0); + yield* task.halt(); + // Cancellation is control flow: nothing was claimed, and nothing was sent. + expect(held.commits).toEqual([]); + expect(yielded(stream.snapshot())).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; + } +} From 7c0bf30a71c733a55af9fec5b475b7e8a03b28df Mon Sep 17 00:00:00 2001 From: Min Kim Date: Fri, 4 Sep 2026 15:57:33 -0400 Subject: [PATCH 41/42] =?UTF-8?q?=F0=9F=90=9B=20Resolve=20what=20a=20Works?= =?UTF-8?q?pace=20path=20lands=20on,=20and=20bind=20one=20run=20as=20one?= =?UTF-8?q?=20thing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lexical admission compared the spelling of a path with the attempt root and then handed it to a syscall that follows symbolic links. An in-tree link read and overwrote a file outside the attempt, and `..notes.md` was refused for beginning with two dots. Every operation now resolves before it acts, on the rule `packages/runtime/host-files.ts` states: only a complete `..` segment leaves, the existing prefix is walked so a path that does not exist yet is still judged, an operation about a link does not follow it, and both ends of a rename or hardlink are admitted at the time of use. A link's target is a Workspace path, so an absolute one names the Workspace root rather than the machine's — resolution is done here rather than by the kernel. The runtime, database, owner link, journal and provenance were supplied separately and could be recombined: pair run B's handle with run A's link and journal, and if both began at the same root and anchor, one effect journalled in A and published its Workspace in B. `useRemoteRun()` now constructs them together and hands back one opaque binding, so there is nothing to recombine and nothing structural to forge. Wiring the production coordinator to a real Durable Object found a third defect the scripted owner could not: a sealed proposal listed its content manifests before its blobs, while the owner requires one sequence ordered by kind and digest. Two owners agreeing about a proposal is what that test is for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- .../src/deno/remote-workspace-files.ts | 258 ++++++++++++--- packages/workflow/src/remote/invocation.ts | 19 +- packages/workflow/src/remote/workspace.ts | 194 ++++++++---- .../cloudflare/remote-workspace.vitest.ts | 273 ++++++++++++++++ .../tests/cloudflare/support/worker-files.ts | 293 ++++++++++++++++++ .../tests/remote-workspace-files.test.ts | 181 +++++++++++ .../workflow/tests/remote-workspace.test.ts | 193 ++++++++---- packages/workflow/tsconfig.cloudflare.json | 2 +- 8 files changed, 1246 insertions(+), 167 deletions(-) create mode 100644 packages/workflow/tests/cloudflare/remote-workspace.vitest.ts create mode 100644 packages/workflow/tests/cloudflare/support/worker-files.ts create mode 100644 packages/workflow/tests/remote-workspace-files.test.ts diff --git a/packages/workflow/src/deno/remote-workspace-files.ts b/packages/workflow/src/deno/remote-workspace-files.ts index 042c7c7ad..af9a727b5 100644 --- a/packages/workflow/src/deno/remote-workspace-files.ts +++ b/packages/workflow/src/deno/remote-workspace-files.ts @@ -1,15 +1,41 @@ /** * The Workspace filesystem, over the attempt directory this invocation owns. * - * The runner's Workspace is a real tree it materialized from the owner, so the - * operations are the runtime's own asynchronous primitives adapted with + * 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. * - * `node:fs/promises` rather than a runtime global, for the same reason the - * materialization adapter uses it: the same code has to work wherever the - * runner runs. + * ## 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 { @@ -26,7 +52,7 @@ import { symlink, writeFile, } from "node:fs/promises"; -import { isAbsolute, relative, resolve } from "node:path"; +import type { Stats } from "node:fs"; import { type Operation, until } from "effection"; import type { WorkspaceEntry, @@ -36,28 +62,131 @@ import type { 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; + /** - * Where a logical path is allowed to land. + * The logical segments this path names, or `undefined` if it leaves the root. * - * The attempt root is the whole of what this invocation may touch. A logical - * path is resolved and then held to that root, so `..`, an absolute path and a - * path that merely starts with the root's name are each refused before any - * syscall — the host path is a place to work, never a durable identity. + * 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 within(at: HostPath, root: string, path: string): string { - const host = resolve(at(path)); - const inside = relative(root, host); - // The empty relative path is the Workspace root itself, which is a directory - // this invocation owns and may read. Only leaving the tree is refused. - if (inside.startsWith("..") || isAbsolute(inside)) { - throw new WorkspacePathError("this Workspace path is outside the tree this invocation owns."); +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 host; + return segments; } -/** A path no Workspace operation may reach, whatever it names. */ -export class WorkspacePathError extends Error { - override name = "WorkspacePathError"; +/** 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: { @@ -78,12 +207,14 @@ function described(value: { * * 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. + * 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(error.message, { cause: error }); + const renamed = new Error(`the Workspace operation failed (${code})`); renamed.name = "WorkspaceFsError"; Reflect.set(renamed, "code", code); return renamed; @@ -96,14 +227,21 @@ export function createRemoteWorkspaceFilesystem( authorize: () => void, ): WorkspaceFilesystem { // The attempt's own root, taken from the same resolver every other path goes - // through. Passing it separately would let the two disagree. - const resolved = resolve(at("/")); + // 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, body: (host: string) => Promise): Operation { + function* run( + path: string, + followFinal: boolean, + body: (host: string) => Promise, + ): Operation { authorize(); - const host = within(at, resolved, path); + // 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(host)); + 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 @@ -112,30 +250,52 @@ export function createRemoteWorkspaceFilesystem( } } + /** 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, (host) => readFile(host)); + return yield* run(path, true, (host) => readFile(host)); }, *readTextFile(path): Operation { - const bytes = yield* run(path, (host) => readFile(host)); + const bytes = yield* run(path, true, (host) => readFile(host)); return new TextDecoder().decode(bytes); }, *stat(path): Operation { - return described(yield* run(path, (host) => stat(host))); + return described(yield* run(path, true, (host) => stat(host))); }, *lstat(path): Operation { - return described(yield* run(path, (host) => lstat(host))); + // About the entry, so the last segment stays what it is. + return described(yield* run(path, false, (host) => lstat(host))); }, *readlink(path): Operation { - return yield* run(path, (host) => readlink(host)); + // 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, (host) => readdir(host, { withFileTypes: true })); + 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", @@ -144,38 +304,38 @@ export function createRemoteWorkspaceFilesystem( *writeFile(path, content, mode): Operation { const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content; - yield* run(path, (host) => writeFile(host, bytes, mode === undefined ? {} : { mode })); + // 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, (host) => mkdir(host, options).then(() => undefined)); + yield* run(path, true, (host) => mkdir(host, options).then(() => undefined)); }, *remove(path, options = {}): Operation { - yield* run(path, (host) => rm(host, options)); + // 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 { - // Both ends are held to the attempt: a rename is two paths, and checking - // one of them would let the other leave the tree. - const destination = within(at, resolved, to); - yield* run(from, (host) => rename(host, destination)); + yield* pair(from, to, false, (source, destination) => rename(source, destination)); }, *chmod(path, mode): Operation { - yield* run(path, (host) => chmod(host, mode)); + yield* run(path, true, (host) => chmod(host, mode)); }, *symlink(target, path): Operation { - // The target is not resolved here. A symbolic link's target is retained - // exactly as written, and reading through one goes back through this - // interface, where it is held to the attempt like any other path. - yield* run(path, (host) => symlink(target, host)); + // 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 { - const destination = within(at, resolved, newPath); - yield* run(existingPath, (host) => link(host, destination)); + yield* pair(existingPath, newPath, true, (source, destination) => link(source, destination)); }, }; } diff --git a/packages/workflow/src/remote/invocation.ts b/packages/workflow/src/remote/invocation.ts index 795d2b4c8..923df77c7 100644 --- a/packages/workflow/src/remote/invocation.ts +++ b/packages/workflow/src/remote/invocation.ts @@ -221,9 +221,17 @@ export function useAttempt( }); } -/** The exact closure a captured root names, in canonical order. */ +/** + * 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[] { - return [ + const inventory: ProposedContent[] = [ ...captured.root.manifests.map((digest) => ({ kind: "manifest" as const, digest, @@ -235,6 +243,13 @@ function inventoryOf(captured: CapturedWorkspace): ProposedContent[] { 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. */ diff --git a/packages/workflow/src/remote/workspace.ts b/packages/workflow/src/remote/workspace.ts index b261fdc24..ce11d180c 100644 --- a/packages/workflow/src/remote/workspace.ts +++ b/packages/workflow/src/remote/workspace.ts @@ -64,6 +64,11 @@ 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 { establishJournalProvenance, type DurableStream } from "@executablemd/durable-streams"; +import { useRemoteRunDatabase, type RemoteRunLink } from "./database.ts"; +import { routeRemoteRunJournal } from "./journal-route.ts"; + import type { TemporaryTrees } from "./invocation.ts"; /** @@ -123,23 +128,113 @@ const WorkspaceMutation: Api = createApi { - const owners = new WeakMap(); + const owners = new WeakMap(); return { - claim(identity: object, database: WorkflowRunDatabase): void { - owners.set(identity, database); + claim(identity: object, run: object): void { + owners.set(identity, run); }, - get(identity: object): WorkflowRunDatabase | undefined { + get(identity: object): object | undefined { return owners.get(identity); }, }; })(); -interface Registration { - open: boolean; +/** + * 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 owner link this run's database, reads and commits all go through. */ + readonly link: RemoteRunLink; + readonly reads: RemoteReadLink; + readonly files: RunnerFiles; + readonly trees: TemporaryTrees; + createFilesystem(at: HostPath, authorize: () => void): WorkspaceFilesystem; + /** The run's ordinary journal, which this routes and takes provenance over. */ + readonly journal: DurableStream; +} + +/** + * Open one remote run: its database, its routed journal and its provenance. + * + * The database is created here from the same link the runtime reads through, 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 = yield* useRemoteRunDatabase( + options.link, + yield* options.link.frontierSnapshot(), + ); + const journal = routeRemoteRunJournal(database, options.journal); + yield* provide( + bindings.bind({ + database, + journal, + provenance: establishJournalProvenance(journal), + runtime: { + files: options.files, + trees: options.trees, + reads: options.reads, + createFilesystem: options.createFilesystem, + }, + }), + ); + }); +} + interface ProviderApi { readonly provider: object | undefined; } @@ -149,12 +244,17 @@ const RemoteWorkspaceProvider: Api = createApi( { provider: undefined }, ); +interface Registration { + open: boolean; + readonly run: BoundRun; +} + const registrations = (() => { const held = new WeakMap(); return { - register(runtime: RemoteWorkspaceRuntime, provenance: JournalProvenance) { + register(run: BoundRun) { const selection = Object.freeze({}); - const registration: Registration = { open: true, runtime, provenance }; + const registration: Registration = { open: true, run }; held.set(selection, registration); return { selection, @@ -171,70 +271,72 @@ const registrations = (() => { }; })(); -/** - * Install the runner's Workspace coordination for this scope. - * - * The provenance is the one the run's journal was established with. It is held - * here rather than compared structurally, because two journals can describe the - * same events and only one of them is this run's. - */ -export function* useRemoteWorkspaceEffects( - runtime: RemoteWorkspaceRuntime, - provenance: JournalProvenance, -): Operation { - const registration = registrations.register(runtime, provenance); +/** 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( - database: WorkflowRunDatabase, + run: RemoteRun, operation: Operation, ): Operation { return scoped(function* () { const selection = yield* RemoteWorkspaceProvider.operations.provider; const registration = selection === undefined ? undefined : registrations.get(selection); - if (registration === undefined) { + // 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(database, registration), operation); + return yield* withWorkspaceCoordinationProvider(coordinator(registration.run), operation); }); } export function createRemoteWorkspaceEffect( - database: WorkflowRunDatabase, + run: RemoteRun, description: EffectDescription, mutate: RemoteWorkspaceMutation, ): DurableEffect { - const execute = () => WorkspaceMutation.operations.run(database, mutate); + 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({}); - workspaceEffectOwners.claim(executionIdentity, database); + // 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); } -function coordinator( - database: WorkflowRunDatabase, - registration: Registration, -): WorkspaceCoordinationProvider { +function coordinator(run: BoundRun): WorkspaceCoordinationProvider { return { *run(authority: WorkspaceCoordinationAuthority): Operation { let transacted; try { - if (workspaceEffectOwners.get(authority.executionIdentity) !== database) { + // 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 " + - "WorkflowRun database.", + "remote run.", ); } if ( authority.journalProvenance === undefined || - authority.journalProvenance !== registration.provenance + authority.journalProvenance !== run.provenance ) { unavailable( - "the live Workspace journal does not have the provenance of the selected WorkflowRun.", + "the live Workspace journal does not have the provenance of the selected remote run.", ); } - transacted = yield* invoke(database, registration, authority); + transacted = yield* invoke(run, authority); } catch (error) { throw yield* authority.activateFailure(error); } @@ -246,12 +348,8 @@ function coordinator( }; } -function* invoke( - database: WorkflowRunDatabase, - registration: Registration, - authority: WorkspaceCoordinationAuthority, -) { - const { runtime } = registration; +function* invoke(run: BoundRun, authority: WorkspaceCoordinationAuthority) { + const { runtime, database } = run; const reject = (reason: string): never => unavailable(reason); const snapshot = yield* runtime.reads.invocationSnapshot(); @@ -291,27 +389,19 @@ function* invoke( "this Workspace invocation was admitted from a state this run has since moved past.", ); } - return yield* coordinateTransaction( - database, - transaction, - route, - runtime, - snapshot, - attempt, - authority, - ); + return yield* coordinateTransaction(run, transaction, route, snapshot, attempt, authority); }); } function* coordinateTransaction( - database: WorkflowRunDatabase, + run: BoundRun, transaction: WorkflowRunTransaction, route: WorkspaceRoute, - runtime: RemoteWorkspaceRuntime, 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 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..9ca022cd7 --- /dev/null +++ b/packages/workflow/tests/cloudflare/remote-workspace.vitest.ts @@ -0,0 +1,273 @@ +/** + * 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 { InMemoryStream, 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 reads = cloudflareReadLink(connection, next, RUN_ID); + const host = createWorkerFiles(); + return yield* useRemoteRun({ + link: cloudflareRunLink(connection, reads, next, RUN_ID), + reads, + files: host.files, + trees: host.trees, + createFilesystem: (at) => host.workspace(at("/")), + journal: new InMemoryStream(), + }); +} + +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, so the ordinary journal never + // saw it. + expect(outcome.filter((event) => event.type === "yield")).toHaveLength(0); + + 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" }]); + expect(after["events"]).toEqual([ + 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([]); + // One row, and it names the root the run is still on. + expect(after["events"]).toEqual([ + 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/support/worker-files.ts b/packages/workflow/tests/cloudflare/support/worker-files.ts new file mode 100644 index 000000000..c694c34fa --- /dev/null +++ b/packages/workflow/tests/cloudflare/support/worker-files.ts @@ -0,0 +1,293 @@ +/** + * 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 + *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/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 index 9e1a71403..a78db7a5a 100644 --- a/packages/workflow/tests/remote-workspace.test.ts +++ b/packages/workflow/tests/remote-workspace.test.ts @@ -37,7 +37,9 @@ import type { WorkspaceFilesystem } from "../src/workspace/filesystem.ts"; import type { WorkspaceMetadata } from "../src/workspace/metadata.ts"; import { createRemoteWorkspaceEffect, + type RemoteRun, type RemoteWorkspaceMutation, + useRemoteRun, type RemoteWorkspaceRuntime, useRemoteWorkspaceEffects, withRemoteWorkspaceEffects, @@ -254,8 +256,7 @@ function* startingTree(): Operation { } interface Harness { - readonly database: WorkflowRunDatabase; - readonly runtime: RemoteWorkspaceRuntime; + readonly run: RemoteRun; readonly commits: Record[]; refuse(reason: string): void; lose(): void; @@ -272,25 +273,27 @@ interface Harness { */ function* harness( snapshot: (rootId: string) => RemoteInvocationSnapshot | { refused: string } = emptySnapshot, + shared?: CapturedWorkspace, ): Operation { - const captured = yield* startingTree(); + 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)}`; const reads = cloudflareReadLink(connection, next, RUN_ID); - const link = cloudflareRunLink(connection, reads, next, RUN_ID); - const database = yield* useRemoteRunDatabase(link, yield* link.frontierSnapshot()); - const runtime: RemoteWorkspaceRuntime = { + // The production constructor: the handle, the routed journal and the + // provenance are made together from this one link. + const run = yield* useRemoteRun({ + link: cloudflareRunLink(connection, reads, next, RUN_ID), + reads, files: runnerFiles(), trees: yield* useRunnerTrees(), - reads, createFilesystem: (at, authorize) => createRemoteWorkspaceFilesystem(at, authorize), - }; + journal: new InMemoryStream(), + }); return { - database, - runtime, + run, commits: owner.commits, refuse: owner.refuse, lose: owner.lose, @@ -315,20 +318,24 @@ function* invocation( mutate: RemoteWorkspaceMutation, ): Operation<{ raised: unknown; events: DurableEvent[] }> { return yield* scoped(function* () { - const stream = new InMemoryStream(); - const routed = routeRemoteRunJournal(held.database, stream); - yield* useRemoteWorkspaceEffects(held.runtime, establishJournalProvenance(routed)); - const effect = createRemoteWorkspaceEffect(held.database, { type: "workspace", name }, mutate); + yield* useRemoteWorkspaceEffects(held.run); + const effect = createRemoteWorkspaceEffect(held.run, { type: "workspace", name }, mutate); function* workflow(): Workflow { yield effect; } const raised = yield* trapped( - withRemoteWorkspaceEffects(held.database, durableRun(workflow, { stream: routed })), + withRemoteWorkspaceEffects(held.run, durableRun(workflow, { stream: held.run.journal })), ); - return { raised, events: stream.snapshot() }; + return { raised, events: yield* held.run.journal.readAll() }; }); } +/** 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"); } @@ -388,15 +395,11 @@ describe("the runner's Workspace coordinator", () => { expect(intent["expectedWorkspaceRootId"]).toBe(held.captured.root.rootId); expect(Array.isArray(intent["events"]) && intent["events"]).toHaveLength(1); - // A later invocation materializes the same root it always had: the - // discarded attempt left nothing, and nothing was promoted. - const observed = yield* invocation(held, "observe", function* (filesystem): Operation { - const names = yield* filesystem.readdir("/"); - return names.map((entry) => entry.name).toSorted(); - }); - expect(observed.raised).toBe(undefined); - const seen = held.commits[1] ?? {}; - expect(seen["expectedWorkspaceRootId"]).toBe(held.captured.root.rootId); + // 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. }); }); @@ -456,66 +459,132 @@ describe("the runner's Workspace coordinator", () => { }); }); - it("refuses an execution identity another database claimed", function* () { + it("cannot pair one run's handle with another run's link, journal or provenance", function* () { yield* scoped(function* () { - const held = yield* harness(); - const other = yield* harness(); + // 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* () { - const stream = new InMemoryStream(); - const routed = routeRemoteRunJournal(held.database, stream); - yield* useRemoteWorkspaceEffects(held.runtime, establishJournalProvenance(routed)); - // Created against one handle, coordinated under another. - const effect = createRemoteWorkspaceEffect( - other.database, - { type: "workspace", name: "foreign" }, - // deno-lint-ignore require-yield - function* (): Operation { - return "ran"; - }, - ); + yield* useRemoteWorkspaceEffects(b.run); + const effect = createRemoteWorkspaceEffect(a.run, { type: "workspace", name: "a" }, own); function* workflow(): Workflow { yield effect; } const raised = yield* trapped( - withRemoteWorkspaceEffects(held.database, durableRun(workflow, { stream: routed })), + withRemoteWorkspaceEffects(b.run, durableRun(workflow, { stream: b.run.journal })), ); expect(String(raised)).toContain("foreign"); }); - expect(held.commits).toEqual([]); - expect(other.commits).toEqual([]); + + // 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("refuses a journal whose provenance is not this run's", function* () { + it("refuses before the split, not after: the other run's journal stays empty", function* () { yield* scoped(function* () { - const held = yield* harness(); + // 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* () { - const stream = new InMemoryStream(); - const routed = routeRemoteRunJournal(held.database, stream); - yield* useRemoteWorkspaceEffects(held.runtime, establishJournalProvenance(routed)); + yield* useRemoteWorkspaceEffects(b.run); const effect = createRemoteWorkspaceEffect( - held.database, - { type: "workspace", name: "provenance" }, - // deno-lint-ignore require-yield - function* (): Operation { + 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; } - // A different stream: structurally identical, and not this run's journal. - const foreign = new InMemoryStream(); - establishJournalProvenance(foreign); const raised = yield* trapped( - withRemoteWorkspaceEffects(held.database, durableRun(workflow, { stream: foreign })), + withRemoteWorkspaceEffects(b.run, durableRun(workflow, { stream: a.run.journal })), ); expect(String(raised)).toContain("provenance"); }); - expect(held.commits).toEqual([]); + + // 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* () { yield* scoped(function* () { const held = yield* harness(); @@ -578,11 +647,9 @@ describe("the runner's Workspace coordinator", () => { it("sends nothing and keeps nothing when the invocation is cancelled", function* () { yield* scoped(function* () { const held = yield* harness(); - const stream = new InMemoryStream(); - const routed = routeRemoteRunJournal(held.database, stream); - yield* useRemoteWorkspaceEffects(held.runtime, establishJournalProvenance(routed)); + yield* useRemoteWorkspaceEffects(held.run); const effect = createRemoteWorkspaceEffect( - held.database, + held.run, { type: "workspace", name: "cancelled" }, function* (filesystem): Operation { yield* filesystem.writeFile("/SLOW.md", "in progress\n", 0o644); @@ -594,13 +661,13 @@ describe("the runner's Workspace coordinator", () => { yield effect; } const task = yield* spawn(() => - withRemoteWorkspaceEffects(held.database, durableRun(workflow, { stream: routed })), + 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(stream.snapshot())).toHaveLength(0); + expect(yielded(yield* held.run.journal.readAll())).toHaveLength(0); }); }); }); diff --git a/packages/workflow/tsconfig.cloudflare.json b/packages/workflow/tsconfig.cloudflare.json index d78979841..ad86bd059 100644 --- a/packages/workflow/tsconfig.cloudflare.json +++ b/packages/workflow/tsconfig.cloudflare.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022"], + "lib": ["ES2023"], "types": ["@cloudflare/workers-types", "@cloudflare/vitest-plugin/types"], "strict": true, "allowImportingTsExtensions": true, From 5b04ad217f79eff5704efabed4ef1b6f1ff724ec Mon Sep 17 00:00:00 2001 From: Min Kim Date: Fri, 4 Sep 2026 21:27:13 -0400 Subject: [PATCH 42/42] =?UTF-8?q?=F0=9F=90=9B=20Make=20one=20owner=20link?= =?UTF-8?q?=20one=20value,=20so=20a=20run=20cannot=20be=20opened=20from=20?= =?UTF-8?q?two?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useRemoteRun()` took the database/commit link and the Workspace read link as two unrelated members. A caller could pass B's link and A's reads and get a legitimate, module-created binding: the invocation would be admitted from A's retained mappings and content, and if both owners began at the same root and anchor the start-anchor check passed and the result, the Files proposal and the typed deltas committed to B. There is one member now. `cloudflareRunLink()` builds its own read link from the same connection and returns one value satisfying both halves, so the reads an invocation is admitted from and the commits it publishes are the same authority by construction rather than by a check. The runtime's read view is derived from that object — `frontier` names two different reads on the two contracts, and materialization wants the coherent one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg --- packages/workflow/src/cloudflare/client.ts | 15 ++- packages/workflow/src/remote/database.ts | 21 +++- packages/workflow/src/remote/workspace.ts | 27 ++++- .../tests/cloudflare/remote-owner.vitest.ts | 21 +--- .../cloudflare/remote-workspace.vitest.ts | 4 +- packages/workflow/tests/remote-read.test.ts | 63 ++---------- .../workflow/tests/remote-workspace.test.ts | 99 ++++++++++++++++++- 7 files changed, 161 insertions(+), 89 deletions(-) diff --git a/packages/workflow/src/cloudflare/client.ts b/packages/workflow/src/cloudflare/client.ts index f5d55a800..0ae1b4092 100644 --- a/packages/workflow/src/cloudflare/client.ts +++ b/packages/workflow/src/cloudflare/client.ts @@ -64,7 +64,7 @@ import { JOURNAL_PAGE_ENTRIES, MAX_CONTENT_BYTES, } from "./commands.ts"; -import type { RemoteRunLink } from "../remote/database.ts"; +import type { RemoteRunLink, RemoteWorkspaceLink } from "../remote/database.ts"; import { isSchemaVersion, SCHEMA_VERSION } from "../sqlite/workflow-schema.ts"; import { canonicalJson } from "../storage/record.ts"; import { @@ -631,14 +631,23 @@ export function* stageCloudflareContent( * 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. + */ export function cloudflareRunLink( connection: OwnerConnection, - reads: RemoteReadLink, nextId: () => string, expectedRunId: string, -): RemoteRunLink { +): RemoteWorkspaceLink { + const reads = cloudflareReadLink(connection, nextId, expectedRunId); const publication = cloudflareOwnerLink(connection, reads, nextId); return { + ...reads, + /** * Both halves of the publication link, translated. * diff --git a/packages/workflow/src/remote/database.ts b/packages/workflow/src/remote/database.ts index 74282936f..4967fb3d2 100644 --- a/packages/workflow/src/remote/database.ts +++ b/packages/workflow/src/remote/database.ts @@ -50,7 +50,9 @@ import type { } from "../storage/record.ts"; import { createTransactionGate, type OwnerLink, transactRemotely } from "./collector.ts"; import type { EnlistWorkspace, TransactionAnchor } from "./collector.ts"; -import type { RemoteFrontierSnapshot } from "./read.ts"; +import type { RemoteContent, RemoteContentRequest, RemoteFrontierSnapshot } from "./read.ts"; +import type { RemoteInvocationSnapshot } from "./records.ts"; +import type { WorkspaceRootManifest } from "../workspace/root-manifest.ts"; /** What a remote handle needs to answer everything the interface asks. */ export interface RemoteRunLink extends OwnerLink { @@ -65,6 +67,23 @@ export interface RemoteRunLink extends OwnerLink { 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 { + /** 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. * diff --git a/packages/workflow/src/remote/workspace.ts b/packages/workflow/src/remote/workspace.ts index ce11d180c..14f477e54 100644 --- a/packages/workflow/src/remote/workspace.ts +++ b/packages/workflow/src/remote/workspace.ts @@ -66,7 +66,7 @@ import type { RemoteInvocationSnapshot } from "./records.ts"; import { withRemoteJournalRoute } from "./journal-route.ts"; import { resource } from "effection"; import { establishJournalProvenance, type DurableStream } from "@executablemd/durable-streams"; -import { useRemoteRunDatabase, type RemoteRunLink } from "./database.ts"; +import { useRemoteRunDatabase, type RemoteWorkspaceLink } from "./database.ts"; import { routeRemoteRunJournal } from "./journal-route.ts"; import type { TemporaryTrees } from "./invocation.ts"; @@ -195,9 +195,13 @@ const bindings = (() => { /** What a host supplies to open one remote run. */ export interface RemoteRunOptions { - /** The owner link this run's database, reads and commits all go through. */ - readonly link: RemoteRunLink; - readonly reads: RemoteReadLink; + /** + * 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; readonly files: RunnerFiles; readonly trees: TemporaryTrees; createFilesystem(at: HostPath, authorize: () => void): WorkspaceFilesystem; @@ -227,7 +231,10 @@ export function useRemoteRun(options: RemoteRunOptions): Operation { runtime: { files: options.files, trees: options.trees, - reads: options.reads, + // 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, }, }), @@ -235,6 +242,16 @@ export function useRemoteRun(options: RemoteRunOptions): Operation { }); } +/** 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; } diff --git a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts index bded8699e..1138f2bab 100644 --- a/packages/workflow/tests/cloudflare/remote-owner.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-owner.vitest.ts @@ -358,12 +358,7 @@ describe("the remote owner protocol", () => { const outcome = await run(function* () { const connection = yield* useOwnerConnection(wire); const ids = () => `read-${(identifier += 1)}`; - const link = cloudflareRunLink( - connection, - cloudflareReadLink(connection, ids, RUN_ID), - ids, - RUN_ID, - ); + const link = cloudflareRunLink(connection, ids, RUN_ID); const first = yield* link.readExecutions(); return { first, second: yield* link.readExecutions() }; }); @@ -430,12 +425,7 @@ describe("the remote owner protocol", () => { const outcome = await run(function* () { const connection = yield* useOwnerConnection(wire); const ids = () => `page-${(identifier += 1)}`; - return yield* cloudflareRunLink( - connection, - cloudflareReadLink(connection, ids, RUN_ID), - ids, - RUN_ID, - ).readExecutions(); + return yield* cloudflareRunLink(connection, ids, RUN_ID).readExecutions(); }); if (!outcome.ok) { throw outcome.error; @@ -459,12 +449,7 @@ describe("the remote owner protocol", () => { const refused = await run(function* () { const connection = yield* useOwnerConnection(ownerSocket(alone)); const ids = () => `huge-${(count += 1)}`; - return yield* cloudflareRunLink( - connection, - cloudflareReadLink(connection, ids, RUN_ID), - ids, - RUN_ID, - ).readExecutions(); + return yield* cloudflareRunLink(connection, ids, RUN_ID).readExecutions(); }); expect(refused.ok).toBe(false); // Provider-neutral, with no private refusal spelling in it. diff --git a/packages/workflow/tests/cloudflare/remote-workspace.vitest.ts b/packages/workflow/tests/cloudflare/remote-workspace.vitest.ts index 9ca022cd7..433108066 100644 --- a/packages/workflow/tests/cloudflare/remote-workspace.vitest.ts +++ b/packages/workflow/tests/cloudflare/remote-workspace.vitest.ts @@ -132,11 +132,9 @@ function* opened(socket: WebSocket): Operation { const connection = yield* useOwnerConnection(ownerSocket(socket)); let identifier = 0; const next = () => `coordinated-${(identifier += 1)}`; - const reads = cloudflareReadLink(connection, next, RUN_ID); const host = createWorkerFiles(); return yield* useRemoteRun({ - link: cloudflareRunLink(connection, reads, next, RUN_ID), - reads, + link: cloudflareRunLink(connection, next, RUN_ID), files: host.files, trees: host.trees, createFilesystem: (at) => host.workspace(at("/")), diff --git a/packages/workflow/tests/remote-read.test.ts b/packages/workflow/tests/remote-read.test.ts index d4166c05d..bff7e4342 100644 --- a/packages/workflow/tests/remote-read.test.ts +++ b/packages/workflow/tests/remote-read.test.ts @@ -392,12 +392,7 @@ describe("semantic reads from a Cloudflare owner", () => { let outcome: unknown; yield* scoped(function* () { const connection = yield* useOwnerConnection(transport.socket); - const link = cloudflareRunLink( - connection, - cloudflareReadLink(connection, ids(), RUN_ID), - ids(), - RUN_ID, - ); + 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); @@ -433,12 +428,7 @@ describe("semantic reads from a Cloudflare owner", () => { // 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, - cloudflareReadLink(connection, next, RUN_ID), - next, - RUN_ID, - ); + 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; @@ -490,12 +480,7 @@ describe("semantic reads from a Cloudflare owner", () => { // 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, - cloudflareReadLink(connection, next, RUN_ID), - next, - RUN_ID, - ); + 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), @@ -526,12 +511,7 @@ describe("semantic reads from a Cloudflare owner", () => { yield* scoped(function* () { const connection = yield* useOwnerConnection(transport.socket); const next = ids(); - const link = cloudflareRunLink( - connection, - cloudflareReadLink(connection, next, RUN_ID), - next, - RUN_ID, - ); + const link = cloudflareRunLink(connection, next, RUN_ID); outcome = yield* link.readExecutions(); }); const failed = outcome as { ok: boolean; error: Error }; @@ -559,12 +539,7 @@ describe("semantic reads from a Cloudflare owner", () => { yield* scoped(function* () { const connection = yield* useOwnerConnection(transport.socket); const next = ids(); - const link = cloudflareRunLink( - connection, - cloudflareReadLink(connection, next, RUN_ID), - next, - RUN_ID, - ); + const link = cloudflareRunLink(connection, next, RUN_ID); outcome = yield* link.readExecutions(); }); const failed = outcome as { ok: boolean; error: Error }; @@ -709,12 +684,7 @@ describe("semantic reads from a Cloudflare owner", () => { let outcome: unknown; yield* scoped(function* () { const connection = yield* useOwnerConnection(transport.socket); - const link = cloudflareRunLink( - connection, - cloudflareReadLink(connection, ids(), RUN_ID), - ids(), - RUN_ID, - ); + const link = cloudflareRunLink(connection, ids(), RUN_ID); outcome = yield* link.readExecutions(); }); expect([description, (outcome as { ok: boolean }).ok]).toEqual([description, false]); @@ -756,12 +726,7 @@ describe("semantic reads from a Cloudflare owner", () => { yield* scoped(function* () { const connection = yield* useOwnerConnection(transport.socket); const next = ids(); - outcome = yield* cloudflareRunLink( - connection, - cloudflareReadLink(connection, next, RUN_ID), - next, - RUN_ID, - ).readExecutions(); + outcome = yield* cloudflareRunLink(connection, next, RUN_ID).readExecutions(); }); const found = outcome as { ok: boolean; value: { executionId: string }[] }; expect(found.ok).toBe(true); @@ -798,12 +763,7 @@ describe("semantic reads from a Cloudflare owner", () => { ); yield* scoped(function* () { const connection = yield* useOwnerConnection(transport.socket); - const link = cloudflareRunLink( - connection, - cloudflareReadLink(connection, ids(), RUN_ID), - ids(), - RUN_ID, - ); + const link = cloudflareRunLink(connection, ids(), RUN_ID); const read = yield* link.readExecutions(); expect(read.ok).toBe(true); if (read.ok) { @@ -817,12 +777,7 @@ describe("semantic reads from a Cloudflare owner", () => { })); yield* scoped(function* () { const connection = yield* useOwnerConnection(empty.socket); - const link = cloudflareRunLink( - connection, - cloudflareReadLink(connection, ids(), RUN_ID), - ids(), - RUN_ID, - ); + 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-workspace.test.ts b/packages/workflow/tests/remote-workspace.test.ts index a78db7a5a..341dcb4b6 100644 --- a/packages/workflow/tests/remote-workspace.test.ts +++ b/packages/workflow/tests/remote-workspace.test.ts @@ -38,6 +38,7 @@ import type { WorkspaceMetadata } from "../src/workspace/metadata.ts"; import { createRemoteWorkspaceEffect, type RemoteRun, + type RemoteRunOptions, type RemoteWorkspaceMutation, useRemoteRun, type RemoteWorkspaceRuntime, @@ -255,6 +256,40 @@ function* startingTree(): Operation { ); } +/** + * 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, + journal: new InMemoryStream(), + }; +} + interface Harness { readonly run: RemoteRun; readonly commits: Record[]; @@ -281,12 +316,10 @@ function* harness( const connection = yield* useOwnerConnection(transport.socket); let identifier = 0; const next = () => `request-${(identifier += 1)}`; - const reads = cloudflareReadLink(connection, next, RUN_ID); - // The production constructor: the handle, the routed journal and the - // provenance are made together from this one link. + // 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, reads, next, RUN_ID), - reads, + link: cloudflareRunLink(connection, next, RUN_ID), files: runnerFiles(), trees: yield* useRunnerTrees(), createFilesystem: (at, authorize) => createRemoteWorkspaceFilesystem(at, authorize), @@ -521,6 +554,62 @@ describe("the runner's Workspace coordinator", () => { }); }); + it("cannot be opened from one owner's reads and another owner's commits", function* () { + yield* scoped(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), + journal: new InMemoryStream(), + }; + + 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(b.commits).toHaveLength(1); + expect(yielded(yield* a.journal.readAll())).toEqual([]); + }); + }); + it("refuses before the split, not after: the other run's journal stays empty", function* () { yield* scoped(function* () { // The discriminator. Before this correction, B's transaction would enlist