diff --git a/packages/durable/README.md b/packages/durable/README.md index 553cb08e2..564a042c8 100644 --- a/packages/durable/README.md +++ b/packages/durable/README.md @@ -738,7 +738,6 @@ Examples that call OpenAI need `OPENAI_API_KEY`; most use the faux provider othe - [`docs/spec.md`](https://github.com/earendil-works/pi/blob/main/packages/durable/docs/spec.md): the normative specification - [`docs/pico-v5-handoff.md`](https://github.com/earendil-works/pi/blob/main/packages/durable/docs/pico-v5-handoff.md): the implementation plan -- [`docs/pico-v5-chord-usage.md`](https://github.com/earendil-works/pi/blob/main/packages/durable/docs/pico-v5-chord-usage.md): how the package uses Chord Benchmarks: `npm run bench:storage`, `npm run bench:storage:memory`, `npm run bench:tool-output`, and `npm run bench:nested-tools`. diff --git a/packages/durable/docs/pico-v5-chord-usage.md b/packages/durable/docs/pico-v5-chord-usage.md deleted file mode 100644 index 48b2676b9..000000000 --- a/packages/durable/docs/pico-v5-chord-usage.md +++ /dev/null @@ -1,387 +0,0 @@ -# Pico5 documents through Chord - -This guide uses the contracts in the [Pico5 specification](spec.md). Its code -compiles and runs as `test/chord-guide.test.ts`; keep the two in sync. - -- **Session:** a durable container for conversations, entries, tasks, and documents; - it serializes mutations on one commit line. -- **Facet:** a feature's setup unit in a Chord host. Setup declares services and - dependencies synchronously; `onActivate` runs after dependencies are ready. -- **Context:** explicitly passed cancellation and invocation values. Forward the caller's context. -- **Service:** a typed token and implementation acquired with `env.use`. Remote - services contain replicated state and async methods with JSON arguments plus a trailing `Context`. -- **Durable document:** JSON persisted as a complete base, Chord operations, and - checkpoints. Its definition token declares schema, version, scope, and initialization; - conversation scope also declares history and fork behavior. -- **Transaction draft:** the revocable copy-on-write object from `await tx.doc`, - valid only inside that commit callback, including all nested objects. -- **DocumentState:** a disposable read-only Chord state bound to one document incarnation. -- **ReplicatedState:** Chord's immutable complete values through `value` and - `subscribe(listener)`. `value` is `undefined` before hydration or while disconnected. - The listener receives `(value, context, delivery)`; delivery contains `kind` - (`hydrate` or `update`) and a stream `sequence`, not a Session commit number. - -Documents are scoped directly to a Session, conversation, or task. A task is -durable work attached to a conversation; its documents retire when it becomes -terminal and never participate in conversation forks. - -## Imports and the adapter boundary - -Examples build on one another. `ConversationRecord`, `EntryRecord`, and -`TaskRecord` are persisted records, while `Conversation` is the public -conversation object and `Entry`/`Task` are typed definitions. The imports: - -```ts -import { - createFacetHost, createRemoteServiceBinding, defineFacet, defineService, - type Context, type Facet, type FacetHost, type RemoteServiceTransport, type ReplicatedState, -} from "@earendil-works/chord"; -import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import { - type ConversationId, defineDoc, defineDocFamily, type DocumentObserver, - type Harness, type Session, type TaskId, type TaskRuntime, -} from "@earendil-works/pi-durable"; -``` - -A `Harness` is a `Session`, so every function below also takes an open Harness. - -`documentState()` performs Chord adoption internally: it atomically captures a -committed snapshot, registers for every later exact frame, and returns an already -hydrated read-only state. Operations covered by the snapshot are never -redelivered. Disposing the state releases observation, not the document. Pico -remains the sole mutator. - -## 1. A Session-wide canvas - -All conversations in this Session share one canvas. Session documents are -current-only and have no history or fork settings. - -```ts -type Stroke = { color: string; points: { x: number; y: number }[] }; -type CanvasState = { strokes: Stroke[] }; - -const CanvasDoc = defineDoc({ - kind: "app.canvas", version: 1, scope: "session", - initial: () => ({ strokes: [] }), - // Store a complete base after at most 99 replayed deltas. - checkpointWhen: (_value, _ops, info) => info.deltasSinceBase >= 99, -}); - -interface CanvasService { - readonly state: ReplicatedState; - addStroke(stroke: Stroke, context: Context): Promise; -} -const Canvas = defineService("app.canvas"); - -async function createCanvasFacet(session: Session, context: Context): Promise { - // Creation is explicit; observation never writes. - await session.commit(async tx => { - await tx.doc(CanvasDoc); - }, context); - const state = await session.documentState(CanvasDoc, context); - if (state === undefined) throw new Error("canvas was retired during setup"); - return defineFacet({ - id: "app.canvas/session", - setup(env) { - env.own(() => state.dispose()); - env.provide(Canvas, { - state, - async addStroke(stroke, context) { - await session.commit(async tx => { - const draft = await tx.doc(CanvasDoc); - draft.strokes.push(stroke); // Chord copies the assigned stroke by value. - }, context); - }, - }); - }, - }); -} - -const CanvasConsumer = defineFacet({ - id: "app.canvas/consumer", - setup(env) { - const canvas = env.use(Canvas); // Declare now; access only after activation. - env.onActivate(() => { - env.own(canvas.state.subscribe((value, _context, delivery) => { - console.log(delivery.kind, delivery.sequence, value?.strokes.length ?? "retired"); - })); // subscribe delivers the current hydrated value, then updates. - }); - }, -}); - -async function runCanvasExample(session: Session): Promise { - const context = BACKGROUND_CONTEXT; - const provider = await createCanvasFacet(session, context); - const host = await createFacetHost({ facets: [provider, CanvasConsumer] }); - try { - await host.services.use(Canvas).addStroke({ - color: "black", points: [{ x: 10, y: 20 }, { x: 30, y: 40 }], - }, context); - } finally { - await host.dispose(); // Unsubscribes; does not delete the canvas or close Session. - } -} -``` - -The application owns the open Session. Acquire the document state asynchronously -before synchronous `setup`, then transfer its disposal to the facet; -`env.provide` cannot run in `onActivate`. Install one provider per -Session host. Chord may reload presentation facets independently. Session-side -tools, hooks, tasks, and prompt sections reload through the Harness -registry: install the replacement extension under the same name (spec section 7.5). -Work already running keeps the old code, so the facet manages its old resources' -lifetime itself. - -Remote clients supply a `RemoteServiceTransport` connected to the host's `services` -provider; Chord prescribes no socket protocol. `CanvasConsumer` also works unchanged -in a UI host configured with a remote service source. - -```ts -async function connectCanvas(transport: RemoteServiceTransport, context: Context) { - const services = createRemoteServiceBinding({ services: [Canvas], transport }); - const canvas = services.use(Canvas); - const stop = canvas.state.subscribe((value, _context, delivery) => { - console.log(delivery.kind, delivery.sequence, value?.strokes ?? "retired"); - }); - try { - await services.ready(context); // Initial snapshot installed; not all future updates. - } catch (error) { - stop(); - await services.dispose(BACKGROUND_CONTEXT); - throw error; - } - return async () => { - stop(); - await services.dispose(BACKGROUND_CONTEXT); - }; -} -``` - -```text -explicit first tx.doc -> commit base { strokes: [] } -addStroke(A) -> commit A -> local and remote subscribers see A -worker restarts -> open same durable storage; install canvas facet again -acquire source -> load base + committed deltas, without rerunning initial() -late client -> hydrate complete canvas including A, then ordered updates -conversation fork -> still uses this same Session canvas -``` - -Use persistent storage for restart survival. The memory backend is not persistent. -A method's successful commit does not promise every remote callback has run yet. - -Shut down in reverse: detach clients and dispose the facet host, which withdraws -its services and disposes the document states it owns, then close the Harness. -Closing the Harness first would end the states under still-connected clients: -they keep the last value and never update again. - -```ts -async function shutdown(host: FacetHost, detachClients: () => Promise, harness: Harness, context: Context) { - await detachClients(); - await host.dispose(); - await harness.close(context); -} -``` - -## 2. Conversation-scoped diff reviews - -A **document family** uses one definition for many instances. The logical key is -`(kind, conversationId, key)`; the persisted numeric document ID identifies an -incarnation and is never reused. The creation seed is not part of the key. - -```ts -type ReviewInput = { path: string; patch: string }; -type ReviewComment = { id: string; line: number; text: string }; -type ReviewState = ReviewInput & { comments: ReviewComment[] }; -const ReviewDoc = defineDocFamily({ - kind: "app.diff-review", version: 1, family: true, scope: "conversation", - history: "latest", fork: "current", - initial: seed => ({ path: seed.path, patch: seed.patch, comments: [] }), - checkpointWhen: (_value, _ops, info) => info.deltasSinceBase >= 49, -}); -interface DiffReviewService { - readonly state: ReplicatedState; - identity(context: Context): Promise<{ conversationId: ConversationId; key: string }>; - addComment(comment: ReviewComment, context: Context): Promise; -} -const DiffReviews = defineService("app.diff-reviews"); - -function reviewFacet( - session: Session, conversationId: ConversationId, - reviews: readonly { key: string; seed: ReviewInput }[], context: Context, -): Facet { - return defineFacet({ - id: "app.diff-reviews/session", - setup(env) { - const instances = env.provideMany(DiffReviews); - env.onActivate(async () => { - for (const review of reviews) { - await session.commit(async tx => { - await tx.doc(ReviewDoc, conversationId, review.key, review.seed); - }, context); - const state = await session.documentState(ReviewDoc, conversationId, review.key, context); - if (state === undefined) throw new Error("review was retired during setup"); - env.own(() => state.dispose()); - // Chord instance keys route services; they are not numeric document incarnation IDs. - instances.spawn(JSON.stringify([conversationId, review.key]), { - state, - async identity() { return { conversationId, key: review.key }; }, - async addComment(comment, context) { - await session.commit(async tx => { - const draft = await tx.doc(ReviewDoc, conversationId, review.key, review.seed); - draft.comments.push(comment); // Chord copies the assigned comment by value. - }, context); - }, - }); // The facet owns spawned service lifetimes automatically. - } - }); - }, - }); -} -``` - -Example input: `[{ key: "review-7", seed: { path: "a.ts", patch: "-old\n+new" } }]`. -Pass distinct family keys and a real conversation ID; install the facet as above. -Keyed consumers use `env.observe(DiffReviews, handler)`, not `env.use`. The handler -receives `(review, context)`; call `review.identity(context)` to identify it and -`review.state.subscribe` to observe comments. - -These comments are conversation-scoped: ending the generating task or unloading -the facet does not retire them. `current` copies every logically -present review at fork-commit time into independent child instances with new -numeric IDs, retaining family keys. Parent and child then diverge. - -```text -parent review-7: comment A -> transcript entry E -> comment B -fork at E with current: child review-7 contains A and B -child adds C: parent still contains only A and B -``` - -With `fork: "initial"`, no instance copies; the first child `tx.doc()` uses its supplied seed. -`latest` cannot read history or fork `asOf`; use `history: "rewindable"` with `asOf` -to reflect E's commit. Reaccess ignores later seeds; updating the patch requires -an explicit mutation or a distinct review key. - -## 3. Task-scoped output and a tool/task watch - -This is a bounded progress window, not the permanent result. Scope itself gives -it task lifetime; no history, fork, owner, or conversation setting is needed. - -```ts -type JobInput = { command: string }; -type JobOutput = { stdout: string; chunks: number }; -const JobOutputDoc = defineDoc({ - kind: "app.job-output", version: 1, scope: "task", - initial: () => ({ stdout: "", chunks: 0 }), - checkpointWhen: (_value, _ops, info) => info.deltasSinceBase >= 99, -}); -async function appendJobOutput( - runtime: TaskRuntime, - chunk: string, context: Context, -): Promise { - // Read process output outside this callback. The runtime gates the live task. - await runtime.commit(async tx => { - const draft = await tx.doc(JobOutputDoc, runtime.taskId); - draft.stdout = (draft.stdout + chunk).slice(-50_000); - draft.chunks += 1; - }, context); -} -async function observeJob( - api: DocumentObserver, producerTaskId: TaskId, - finished: Promise, context: Context, -): Promise { - const watch = await api.watchDoc(JobOutputDoc, producerTaskId, context); - if (watch === undefined) return; - console.log(watch.value === null ? "retired" : watch.value.stdout); - try { - watch.start(async (value, ops, _context) => { - await sendCommittedFrame(value, ops); - await render(value === null ? "retired" : value.stdout); - }); // Serialized callbacks never overlap. - await finished; // Caller-supplied observation lifetime; outside any commit. - } finally { - await watch.stop(); // Idempotent; prevents another callback from starting. - } -} -``` - -The running phase calls `appendJobOutput` and must also commit its next checkpoint -or terminal outcome. `api` is the invocation's `DocumentObserver`; `TaskRuntime` -includes that interface. `tx.doc()` creation validates that the target task is live and derives its -conversation from the task record. A later non-creating watch lookup returns -`undefined` after the task is terminal and its document has retired. - -```text -watchDoc: capture immutable V0 + register for later exact committed frames -producer commits D1 and D2 before start: buffer (V1, D1), (V2, D2) -start: caller has initialized from V0; deliver D1, then D2 serially -producer commits D3 while listener awaits: buffer D3; never overlap callbacks -101 pending frames: replace the pending suffix with [["r", newestValue]] -producer becomes terminal: deliver [["r", null]], then close as retired -``` - -Watches automatically stop when their invocation ends. A Session-acquired watch -is caller-owned and stops on Session close. Previously delivered snapshots never -mutate. Slow or unstarted delivery retains up to 100 exact frames, then replaces -the undelivered suffix with a full-value root replacement. A watch is convergent -state observation rather than a transition journal. Retirement ends the -incarnation's stream; a service exposing that state must withdraw or remain -terminal at `null`. Recreation requires a new watch/state and numeric document ID. A terminal task cannot -create more output. Task-scoped documents -never copy into forks. Preserve required output in result entries or Session- or -conversation-scoped documents in the terminal commit before retirement. Use a -task-scoped family only when one task needs several independently keyed documents. - -## 4. Built-in conversation views and commit delivery - -`ConversationView` contains `conversation`, raw transcript `entries`, and -`docs: Readonly>`. Selected built-in singletons mount -under `docs` by stable document kind, not numeric incarnation ID. The spec's -illustrative path mapping is: - -```text -document ["s", ["generation", "message"], value] - -> view ["s", ["docs", "pi.live", "generation", "message"], value] -``` - -`pi.live` is specified in `spec.md` section 8.2. -The mount publishes one batch per complete Session commit: entry/head changes and -changed mounted documents together, without a tracker or semantic projection. -Third-party documents are **not automatically mounted**; use their own Chord -services or trusted invocation watches. A retired source publishes `null`; a -dynamic service should then withdraw its instance rather than expose stale data. - -```text -addStroke -> hold Session mutation line -> await tx.doc -> mutate tracker change draft -callback succeeds -> tracker prepare: immutable candidate + immutable ops -Session checkpoint predicate selects a base or delta exactly once -atomic storage commit: persist selected document and record writes -storage succeeds -> adopt candidate + enqueue candidate/ops, still on line -release line -> deliver committed source ops -> adapter -> local/remote Chord consumers -late subscriber -> atomically capture committed value + adapter sequence + subscription -``` - -No visible-undurable path exists. Callback, tracker preparation, and checkpoint -failure abort normally before Storage admission. An uncertain Storage failure -publishes nothing and poisons the open Session; close and reopen it instead of -continuing. - -## Footguns - -- Never retain drafts, nested proxies, or bound array methods across commits; - they are revoked. Assigned containers are copied by value. -- Async commit holds the line through storage settlement and baseline adoption. - Await document access there, not models, processes, network calls, or humans. -- Only `tx.doc()` creates. `snapshot`, `documentState`, and `watchDoc` return - `undefined` when absent and never write. Family seeds are used only when the - first `tx.doc()` creates an incarnation; later seeds are ignored. -- Initialize from the fixed `watch.value` before `start()`. Slow or unstarted - delivery retains up to 100 exact frames, then replaces the pending suffix with - one full-value root replacement. No serialized-byte accounting is performed. - Do not use a watch as an audit log. -- `stop()` prevents future callbacks but does not abort or join one already running. -- Definitions own checkpoints, not storage heuristics. Keep document kinds, versions, fork policies, and - public paths stable; schema changes require migration, not a source-only rename. - -Chord sources: [types](../../chord/src/types.ts), [facet examples](../../chord/test/facets.test.ts), -[state](../../chord/src/services/state.ts), [provider](../../chord/src/services/provider.ts), -[Context](../../chord/src/context/index.ts), [Delta](../../chord/src/delta/README.md). diff --git a/packages/durable/docs/pico-v5-handoff.md b/packages/durable/docs/pico-v5-handoff.md index 5f37c24c1..3bd371376 100644 --- a/packages/durable/docs/pico-v5-handoff.md +++ b/packages/durable/docs/pico-v5-handoff.md @@ -868,8 +868,6 @@ seal (§2.2 says states close after the join); cancelling `close()` cancels only that wait and a second `close()` awaits the same shutdown; add `Conversation.abort()` to the progress calls; "old and new Harness generations" means that after `await close()` no invocation code of the old Harness runs; -"service withdrawal and client detach" is proposed as a runnable Chord guide -test (facet host, in-process remote binding, host dispose before Harness close); "normative usage sequences" are the spec's example code blocks, copied into one compile-checked test. @@ -885,8 +883,7 @@ does not; registry change between open and resume; handover keeps memos; compile tests for every §3.3/§9.1 overload (including `snapshotAsOf` rejecting a non-rewindable token, token overloads of `tx.entry`/`appendEntry`), the `SubmissionDraft` never-fields, `waitForTask`, `compact()` result type, and -an erased extension/task with narrowed input, several phases, and custom hooks; -the Chord guide compiles (it uses a bare `Id` today) and runs. +an erased extension/task with narrowed input, several phases, and custom hooks. Already covered, rerun only: root-replacement delta versus checkpoint, blocked tasks surviving open, root identity across reopen, handover cases, a reopened @@ -897,8 +894,7 @@ Harness, then stop for final review. Done. The spec wording is settled as proposed (§2.2 close and progress calls, §5.1, §7.5, §9.1, §12). Close ends states and watches at the seal; a new Harness may open the Storage once the old `close()` resolved. The lifecycle tests are in -`harness-lifecycle.test.ts`, the Chord guide runs as `chord-guide.test.ts`, and -the spec's usage examples compile in `spec-usage.test.ts`. +`harness-lifecycle.test.ts`, and the spec's usage examples compile in `spec-usage.test.ts`. ## 23. Task graph view diff --git a/packages/durable/docs/spec.md b/packages/durable/docs/spec.md index 3c8a2d470..cc5e77aba 100644 --- a/packages/durable/docs/spec.md +++ b/packages/durable/docs/spec.md @@ -1918,8 +1918,8 @@ caller-owned. Later runtime commits reject, and close writes no task outcome. Cl starts no fresh phase or abort invocation. It does not set abort marks, terminalize tasks, retire task documents, or publish document retirement. The hosting layer withdraws services and -detaches clients before it closes the Harness, for example by disposing its Chord -facet host (see the Chord usage guide); reconnecting to a reopened Session +detaches clients before it closes the Harness, for example by closing its Chord +host; reconnecting to a reopened Session hydrates the last committed state and resumes recovery from its durable checkpoints. diff --git a/packages/durable/test/chord-guide.test.ts b/packages/durable/test/chord-guide.test.ts deleted file mode 100644 index 4d4aa8455..000000000 --- a/packages/durable/test/chord-guide.test.ts +++ /dev/null @@ -1,394 +0,0 @@ -/** - * The code of docs/pico-v5-chord-usage.md, compiled and run against a real Harness. Keep the two in sync: the guide's - * blocks are copied here verbatim apart from formatting and the output sinks, which record instead of printing. - */ -import { - type Context, - createFacetHost, - createRemoteServiceBinding, - defineFacet, - defineService, - type Facet, - type FacetHost, - type RemoteServiceTransport, - type ReplicatedState, -} from "@earendil-works/chord"; -import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import type { Op } from "@earendil-works/chord/delta"; -import { - type ConversationId, - type DocumentObserver, - defineDoc, - defineDocFamily, - defineTask, - type Harness, - MemoryStorage, - type Session, - type TaskId, - type TaskRuntime, -} from "@earendil-works/pi-durable"; -import { describe, expect, it } from "vitest"; -import { context } from "./session-support.ts"; -import { completed, deferred, eventually, openTasks } from "./task-support.ts"; - -const logged: unknown[][] = []; -const console = { log: (...values: unknown[]) => void logged.push(values) }; - -// ─── 1. A Session-wide canvas ──────────────────────────────────────────────── - -type Stroke = { color: string; points: { x: number; y: number }[] }; -type CanvasState = { strokes: Stroke[] }; - -const CanvasDoc = defineDoc({ - kind: "app.canvas", - version: 1, - scope: "session", - initial: () => ({ strokes: [] }), - // Store a complete base after at most 99 replayed deltas. - checkpointWhen: (_value, _ops, info) => info.deltasSinceBase >= 99, -}); - -interface CanvasService { - readonly state: ReplicatedState; - addStroke(stroke: Stroke, context: Context): Promise; -} -const Canvas = defineService("app.canvas"); - -async function createCanvasFacet(session: Session, context: Context): Promise { - // Creation is explicit; observation never writes. - await session.commit(async (tx) => { - await tx.doc(CanvasDoc); - }, context); - const state = await session.documentState(CanvasDoc, context); - if (state === undefined) throw new Error("canvas was retired during setup"); - return defineFacet({ - id: "app.canvas/session", - setup(env) { - env.own(() => state.dispose()); - env.provide(Canvas, { - state, - async addStroke(stroke, context) { - await session.commit(async (tx) => { - const draft = await tx.doc(CanvasDoc); - draft.strokes.push(stroke); // Chord copies the assigned stroke by value. - }, context); - }, - }); - }, - }); -} - -const CanvasConsumer = defineFacet({ - id: "app.canvas/consumer", - setup(env) { - const canvas = env.use(Canvas); // Declare now; access only after activation. - env.onActivate(() => { - env.own( - canvas.state.subscribe((value, _context, delivery) => { - console.log(delivery.kind, delivery.sequence, value?.strokes.length ?? "retired"); - }), - ); // subscribe delivers the current hydrated value, then updates. - }); - }, -}); - -async function runCanvasExample(session: Session): Promise { - const context = BACKGROUND_CONTEXT; - const provider = await createCanvasFacet(session, context); - const host = await createFacetHost({ facets: [provider, CanvasConsumer] }); - try { - await host.services.use(Canvas).addStroke( - { - color: "black", - points: [ - { x: 10, y: 20 }, - { x: 30, y: 40 }, - ], - }, - context, - ); - } finally { - await host.dispose(); // Unsubscribes; does not delete the canvas or close Session. - } -} - -async function connectCanvas(transport: RemoteServiceTransport, context: Context) { - const services = createRemoteServiceBinding({ services: [Canvas], transport }); - const canvas = services.use(Canvas); - const stop = canvas.state.subscribe((value, _context, delivery) => { - console.log(delivery.kind, delivery.sequence, value?.strokes ?? "retired"); - }); - try { - await services.ready(context); // Initial snapshot installed; not all future updates. - } catch (error) { - stop(); - await services.dispose(BACKGROUND_CONTEXT); - throw error; - } - return async () => { - stop(); - await services.dispose(BACKGROUND_CONTEXT); - }; -} - -async function shutdown(host: FacetHost, detachClients: () => Promise, harness: Harness, context: Context) { - await detachClients(); - await host.dispose(); - await harness.close(context); -} - -// ─── 2. Conversation-scoped diff reviews ───────────────────────────────────── - -type ReviewInput = { path: string; patch: string }; -type ReviewComment = { id: string; line: number; text: string }; -type ReviewState = ReviewInput & { comments: ReviewComment[] }; -const ReviewDoc = defineDocFamily({ - kind: "app.diff-review", - version: 1, - family: true, - scope: "conversation", - history: "latest", - fork: "current", - initial: (seed) => ({ path: seed.path, patch: seed.patch, comments: [] }), - checkpointWhen: (_value, _ops, info) => info.deltasSinceBase >= 49, -}); -interface DiffReviewService { - readonly state: ReplicatedState; - identity(context: Context): Promise<{ conversationId: ConversationId; key: string }>; - addComment(comment: ReviewComment, context: Context): Promise; -} -const DiffReviews = defineService("app.diff-reviews"); - -function reviewFacet( - session: Session, - conversationId: ConversationId, - reviews: readonly { key: string; seed: ReviewInput }[], - context: Context, -): Facet { - return defineFacet({ - id: "app.diff-reviews/session", - setup(env) { - const instances = env.provideMany(DiffReviews); - env.onActivate(async () => { - for (const review of reviews) { - await session.commit(async (tx) => { - await tx.doc(ReviewDoc, conversationId, review.key, review.seed); - }, context); - const state = await session.documentState(ReviewDoc, conversationId, review.key, context); - if (state === undefined) throw new Error("review was retired during setup"); - env.own(() => state.dispose()); - // Chord instance keys route services; they are not numeric document incarnation IDs. - instances.spawn(JSON.stringify([conversationId, review.key]), { - state, - async identity() { - return { conversationId, key: review.key }; - }, - async addComment(comment, context) { - await session.commit(async (tx) => { - const draft = await tx.doc(ReviewDoc, conversationId, review.key, review.seed); - draft.comments.push(comment); // Chord copies the assigned comment by value. - }, context); - }, - }); // The facet owns spawned service lifetimes automatically. - } - }); - }, - }); -} - -// ─── 3. Task-scoped output and a tool/task watch ───────────────────────────── - -const sent: unknown[] = []; -const rendered: string[] = []; -async function sendCommittedFrame(value: JobOutput | null, ops: readonly Op[]): Promise { - sent.push({ value, ops }); -} -async function render(text: string): Promise { - rendered.push(text); -} - -type JobInput = { command: string }; -type JobOutput = { stdout: string; chunks: number }; -const JobOutputDoc = defineDoc({ - kind: "app.job-output", - version: 1, - scope: "task", - initial: () => ({ stdout: "", chunks: 0 }), - checkpointWhen: (_value, _ops, info) => info.deltasSinceBase >= 99, -}); -async function appendJobOutput( - runtime: TaskRuntime, - chunk: string, - context: Context, -): Promise { - // Read process output outside this callback. The runtime gates the live task. - await runtime.commit(async (tx) => { - const draft = await tx.doc(JobOutputDoc, runtime.taskId); - draft.stdout = (draft.stdout + chunk).slice(-50_000); - draft.chunks += 1; - }, context); -} -async function observeJob( - api: DocumentObserver, - producerTaskId: TaskId, - finished: Promise, - context: Context, -): Promise { - const watch = await api.watchDoc(JobOutputDoc, producerTaskId, context); - if (watch === undefined) return; - console.log(watch.value === null ? "retired" : watch.value.stdout); - try { - watch.start(async (value, ops, _context) => { - await sendCommittedFrame(value, ops); - await render(value === null ? "retired" : value.stdout); - }); // Serialized callbacks never overlap. - await finished; // Caller-supplied observation lifetime; outside any commit. - } finally { - await watch.stop(); // Idempotent; prevents another callback from starting. - } -} - -// ─── Runs ──────────────────────────────────────────────────────────────────── - -/** In-process transport to a host's service provider; a real client puts a socket between the two. */ -function inProcess(host: FacetHost): RemoteServiceTransport { - return { - invoke: (call, callContext) => host.services.invoke(call, callContext), - subscribe: async (serviceId, mode, listener) => { - const subscription = host.services.subscribe(serviceId, mode, listener); - return { - snapshot: subscription.snapshot, - activate: () => subscription.activate(), - close: () => subscription.close(), - }; - }, - }; -} - -describe("Chord usage guide", () => { - it("runs the canvas: facet host, a remote client, withdrawal and detach before Harness close", async () => { - logged.length = 0; - const { harness } = await openTasks(new MemoryStorage(), []); - await runCanvasExample(harness); - expect(logged).toEqual([ - ["hydrate", 0, 0], - ["update", 1, 1], - ]); - expect((await harness.snapshot(CanvasDoc, context))?.strokes).toHaveLength(1); - - // A worker installs the canvas facet again; a late remote client hydrates the stroke, then follows updates. - logged.length = 0; - const host = await createFacetHost({ facets: [await createCanvasFacet(harness, context)] }); - const detach = await connectCanvas(inProcess(host), context); - await host.services.use(Canvas).addStroke({ color: "red", points: [] }, context); - await eventually(() => logged.length === 2); - expect(logged.map(([kind, , strokes]) => [kind, (strokes as Stroke[]).length])).toEqual([ - ["hydrate", 1], - ["update", 2], - ]); - // Record the shutdown order: clients detach and services withdraw while the Harness is still open. - const order: string[] = []; - const trackedHarness = new Proxy(harness, { - get: (target, key) => { - const value = Reflect.get(target, key, target); - if (typeof value !== "function") return value; - if (key !== "close") return value.bind(target); - return async (closeContext: Context) => { - order.push("close start"); - await target.close(closeContext); - order.push("close end"); - }; - }, - }); - const trackedHost: FacetHost = { - services: host.services, - reload: (facets) => host.reload(facets), - dispose: async () => { - order.push("dispose start"); - await host.dispose(); - order.push("dispose end"); - }, - }; - await shutdown( - trackedHost, - async () => { - order.push("detach start"); - await detach(); - order.push("detach end"); - }, - trackedHarness, - context, - ); - expect(order).toEqual(["detach start", "detach end", "dispose start", "dispose end", "close start", "close end"]); - expect(() => host.services.use(Canvas)).toThrow("disposed"); - await expect(harness.commit(() => {}, context)).rejects.toThrow("closed"); - }); - - it("runs the diff reviews with a keyed consumer", async () => { - const { harness } = await openTasks(new MemoryStorage(), []); - const root = await harness.root(context); - const reviews = [{ key: "review-7", seed: { path: "a.ts", patch: "-old\n+new" } }]; - const seen: { key: string; comments: number }[] = []; - const consumer = defineFacet({ - id: "app.diff-reviews/consumer", - setup(env) { - env.observe(DiffReviews, async (review, callContext) => { - const { key } = await review.identity(callContext); - review.state.subscribe((value) => void seen.push({ key, comments: value?.comments.length ?? -1 })); - await review.addComment({ id: "c1", line: 1, text: "why?" }, callContext); - }); - }, - }); - const host = await createFacetHost({ facets: [reviewFacet(harness, root.id, reviews, context), consumer] }); - await eventually(() => seen.some((frame) => frame.comments === 1)); - expect(seen).toEqual([ - { key: "review-7", comments: 0 }, - { key: "review-7", comments: 1 }, - ]); - expect(await harness.snapshot(ReviewDoc, root.id, "review-7", context)).toMatchObject({ - path: "a.ts", - comments: [{ id: "c1" }], - }); - await host.dispose(); - await harness.close(context); - }); - - it("runs the job output watch until the producer retires its document", async () => { - logged.length = 0; - sent.length = 0; - rendered.length = 0; - const gate = deferred(); - const Job = defineTask({ - name: "app.job", - version: 1, - initial: () => ({ phase: "running" }), - phases: { - running: async (task, runtime, ctx) => { - await appendJobOutput(runtime, `$ ${task.input.command}\n`, ctx); - await gate.promise; - await appendJobOutput(runtime, "ok\n", ctx); - await runtime.commit(() => completed(null), ctx); - }, - }, - abort: async () => {}, - }); - const { harness } = await openTasks(new MemoryStorage(), [Job]); - const root = await harness.root(context); - const id = await root.commit( - (tx) => tx.createTask(Job, { command: "make" }, { ownership: { kind: "conversation" } }), - context, - ); - harness.resume(); - await eventually(async () => (await harness.snapshot(JobOutputDoc, id, context))?.chunks === 1); - const finished = harness.waitForTask(id, context).then(() => {}); - const observing = observeJob(harness, id, finished, context); - await eventually(() => logged.length === 1); - gate.resolve(); - await observing; - expect(logged).toEqual([["$ make\n"]]); - // Stopped once the task finished; the retirement frame may or may not have been delivered before. - expect(rendered.slice(0, 1)).toEqual(["$ make\nok\n"]); - expect(await harness.watchDoc(JobOutputDoc, id, context)).toBeUndefined(); - await harness.close(context); - }); -});