From bd3c9b823762b7383954aa49e13ee350471015fe Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 21 Oct 2026 16:38:53 +0200 Subject: [PATCH 01/30] fix(coding-agent): activate extension tools registered after the branch recorded its loadout Resume and /tree restored exactly the tools the branch recorded, so extension tools registered later stayed inactive. Registered extension tools that the branch never added or removed are now activated unless they use defaultActive: false. The whole branch is searched, so tools removed before a compaction stay inactive. Also fixes the /tree case from #11075. closes #11062 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/agent-session.ts | 29 +++++- .../test/default-tools-setting.test.ts | 96 ++++++++++++++++--- 3 files changed, 108 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 31ada19d9..87e490f88 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -25,6 +25,7 @@ - Fixed extensions failing to load when importing `@earendil-works/pi-ai/models` (e.g. for `clampThinkingLevel`) by exposing the subpath through the extension loader aliases and virtual modules ([#11156](https://github.com/earendil-works/pi/issues/11156)) - Fixed `mcp.json` files that start with a UTF-8 BOM failing to parse, which skipped all their MCP servers and made `pi mcp add`/`pi mcp remove` fail ([#11191](https://github.com/earendil-works/pi/issues/11191)) - Fixed `/compact ` ignoring the instructions when summarizing the start of a turn that compaction splits. When the split turn was the only new content since the last compaction, the instructions were ignored entirely ([#11058](https://github.com/earendil-works/pi/issues/11058)) +- Fixed resumed sessions and `/tree` navigation leaving extension tools inactive when the branch recorded its tools before they were registered, such as tools from extensions installed after the session started. Extension tools the branch never added or removed are now active unless they are registered with `defaultActive: false` ([#11062](https://github.com/earendil-works/pi/issues/11062), [#11075](https://github.com/earendil-works/pi/issues/11075)) ## [1.3.0] - 2026-10-15 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 8b330d555..12397c167 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -31,7 +31,7 @@ import { runToolCall, type ThinkingLevel, } from "@earendil-works/pi-agent-core"; -import { contentText, getCurrentSystemMessage, retryDelayMs } from "@earendil-works/pi-ai"; +import { contentText, getCurrentSystemMessage, getCurrentTools, retryDelayMs } from "@earendil-works/pi-ai"; import type { AssistantMessage, AuthResult, @@ -1814,13 +1814,34 @@ export class AgentSession { */ private _restoreToolsFromTranscript(modifiers: readonly string[] = []): void { this._pendingToolNames.clear(); - const current = getCurrentSystemMessage(this.sessionManager.buildSessionContext().messages); - if (!current) return; + // All system messages on the branch, including those before a compaction. Compaction snapshots + // are derived from these, so replaying them gives the current loadout. + const systemMessages = this.sessionManager + .getBranch() + .flatMap((entry) => (entry.type === "message" && entry.message.role === "system" ? [entry.message] : [])); + // No recorded loadout: keep the current tools. + if (systemMessages.length === 0) return; + // Tools the branch ever added or removed. Removed tools stay inactive. + const mentioned = new Set( + systemMessages + .flatMap((message) => [...(message.toolsAdded ?? []), ...(message.toolsRemoved ?? [])]) + .map((tool) => tool.name), + ); + // Extension tools the branch never mentioned were registered after it recorded its loadout, so + // they are activated like newly registered tools. Built-in tools follow `defaultTools` instead. + const newTools = [...this._toolRegistry.keys()].filter( + (name) => + !mentioned.has(name) && !this._baseToolDefinitions.has(name) && this._isActivatedOnRegistration(name), + ); + // The recorded loadout plus the new tools, with `--tools +name`/`-name` applied. const names = applyToolModifiers( - (current.toolsAdded ?? []).map((tool) => tool.name), + [...getCurrentTools(systemMessages).map((tool) => tool.name), ...newTools], modifiers, ); + // Recorded tools that are not registered yet, such as MCP tools of servers still connecting, are + // activated when they register. Excluded tools never register, so they are not kept pending. this._pendingToolNames = new Set(names.filter((name) => this._isAllowedTool(name))); + // Ignores names that are not registered. this._setActiveTools(names); } diff --git a/packages/coding-agent/test/default-tools-setting.test.ts b/packages/coding-agent/test/default-tools-setting.test.ts index 3ab70eaa9..9ae2a709e 100644 --- a/packages/coding-agent/test/default-tools-setting.test.ts +++ b/packages/coding-agent/test/default-tools-setting.test.ts @@ -309,15 +309,31 @@ describe("defaultTools setting", () => { }); }; + const toolReferences = (names: string[]) => + names.map((name) => ({ name, description: name, parameters: Type.Object({}) })); + + /** Append a system message that adds and removes tools, as a run records loadout changes. */ + function recordLoadoutChange(sessionManager: SessionManager, added: string[], removed: string[] = []): string { + return sessionManager.appendMessage({ + role: "system", + content: "", + ...(added.length > 0 ? { toolsAdded: toolReferences(added) } : {}), + ...(removed.length > 0 ? { toolsRemoved: toolReferences(removed) } : {}), + timestamp: Date.now(), + }); + } + /** A session whose transcript records the loadout `toolNames`, as a run records it. */ function createRecordedSession(toolNames: string[]): SessionManager { const sessionManager = SessionManager.inMemory(tempDir); - sessionManager.appendMessage({ - role: "system", - content: "", - toolsAdded: toolNames.map((name) => ({ name, description: name, parameters: Type.Object({}) })), - timestamp: Date.now(), - }); + recordLoadoutChange(sessionManager, toolNames); + return sessionManager; + } + + /** A session that recorded `read`, `grep`, and `inactive_tool`, and deactivated `active_tool`. */ + function createChangedSession(): SessionManager { + const sessionManager = createRecordedSession(["read", "grep", "active_tool"]); + recordLoadoutChange(sessionManager, ["inactive_tool"], ["active_tool"]); return sessionManager; } @@ -344,14 +360,68 @@ describe("defaultTools setting", () => { } it("restores the loadout the transcript records", async () => { - // active_tool was deactivated during the session, inactive_tool activated. - const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"])); + const session = await resume(createChangedSession()); expect(session.getActiveToolNames().sort()).toEqual(["grep", "inactive_tool", "read"]); session.dispose(); }); + // #11062 + it("activates extension tools the transcript never added or removed", async () => { + // Built-in tools and tools registered inactive stay inactive. + const session = await resume(createRecordedSession(["read", "grep"])); + expect(session.getActiveToolNames().sort()).toEqual(["active_tool", "grep", "read"]); + session.dispose(); + }); + + it("keeps tools removed before a compaction inactive", async () => { + const sessionManager = createChangedSession(); + const kept = sessionManager.appendMessage({ role: "user", content: "hi", timestamp: Date.now() }); + sessionManager.appendCompaction("summary", kept, 1000); + const session = await resume(sessionManager); + expect(session.getActiveToolNames().sort()).toEqual(["grep", "inactive_tool", "read"]); + session.dispose(); + }); + + // #11075 + it("restores the target branch's loadout on tree navigation", async () => { + // The target branch never mentions active_tool, so it is active there. It removed grep. + const sessionManager = createRecordedSession(["read", "grep"]); + recordLoadoutChange(sessionManager, [], ["grep"]); + const target = sessionManager.appendMessage({ role: "user", content: "first", timestamp: Date.now() }); + recordLoadoutChange(sessionManager, ["grep"], ["active_tool"]); + const session = await resume(sessionManager); + expect(session.getActiveToolNames().sort()).toEqual(["grep", "read"]); + + await session.navigateTree(target, { summarize: false }); + expect(session.getActiveToolNames().sort()).toEqual(["active_tool", "read"]); + session.dispose(); + }); + + it("keeps registered tools outside the tools allowlist inactive on tree navigation", async () => { + // An allowlist without mcp__ entries keeps MCP tools registered but inactive. + const sessionManager = createRecordedSession(["read"]); + const target = sessionManager.appendMessage({ role: "user", content: "first", timestamp: Date.now() }); + const session = await resume(sessionManager, { + tools: ["read"], + customTools: [ + { + name: "mcp__server__lookup", + label: "Lookup", + description: "MCP tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }, + ], + }); + expect(session.getActiveToolNames()).toEqual(["read"]); + + await session.navigateTree(target, { summarize: false }); + expect(session.getActiveToolNames()).toEqual(["read"]); + session.dispose(); + }); + it("does not restore excluded tools", async () => { - const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"]), { + const session = await resume(createChangedSession(), { excludeTools: ["inactive_tool"], }); expect(session.getActiveToolNames().sort()).toEqual(["grep", "read"]); @@ -359,19 +429,17 @@ describe("defaultTools setting", () => { }); it("uses explicit tool options instead of the recorded loadout", async () => { - const recorded = ["read", "grep", "inactive_tool"]; - - const allowlisted = await resume(createRecordedSession(recorded), { tools: ["read", "write"] }); + const allowlisted = await resume(createChangedSession(), { tools: ["read", "write"] }); expect(allowlisted.getActiveToolNames()).toEqual(["read", "write"]); allowlisted.dispose(); - const builtinless = await resume(createRecordedSession(recorded), { noTools: "builtin" }); + const builtinless = await resume(createChangedSession(), { noTools: "builtin" }); expect(builtinless.getActiveToolNames()).toEqual(["active_tool"]); builtinless.dispose(); }); it("applies +name and -name tool options to the recorded loadout", async () => { - const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"]), { + const session = await resume(createChangedSession(), { tools: ["-grep", "+write"], }); expect(session.getActiveToolNames().sort()).toEqual(["inactive_tool", "read", "write"]); From 8c4e873899b97f6abd14bc45928ccb465f83010c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Wed, 21 Oct 2026 21:25:32 +0200 Subject: [PATCH 02/30] fix(coding-agent): grep tool ignores ripgrep config via --no-config The built-in grep tool spawned rg with the inherited environment, so RIPGREP_CONFIG_PATH (e.g. a config containing --fixed-strings) silently changed tool semantics: literal: false searches became literal and the agent got incomplete results with no error. Pass --no-config so the tool contract is deterministic. Users who want custom rg behavior can use the bash tool or wrap grep in an extension. closes #11203 --- packages/coding-agent/src/core/tools/grep.ts | 4 +++- packages/coding-agent/test/tools.test.ts | 25 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 44f7e212b..a9562c69f 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -159,7 +159,9 @@ export function createGrepToolDefinition( return lines; }; - const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"]; + // --no-config: the tool contract (literal, glob, limit, ...) must not depend on + // ambient user config like RIPGREP_CONFIG_PATH. Users can customize via bash or extensions. + const args: string[] = ["--no-config", "--json", "--line-number", "--color=never", "--hidden"]; if (ignoreCase) args.push("--ignore-case"); if (literal) args.push("--fixed-strings"); if (glob) args.push("--glob", glob); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index ae1644396..91432623f 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -908,6 +908,31 @@ describe("Coding Agent Tools", () => { expect(output).not.toContain("match two"); }); + // https://github.com/earendil-works/pi/issues/11203 + it("should ignore RIPGREP_CONFIG_PATH", async () => { + const configFile = join(testDir, "rg-config"); + const testFile = join(testDir, "fixture.txt"); + writeFileSync(configFile, "--fixed-strings\n"); + writeFileSync(testFile, "aXb\na.b\n"); + + const previous = process.env.RIPGREP_CONFIG_PATH; + process.env.RIPGREP_CONFIG_PATH = configFile; + try { + const result = await grepTool.execute("test-call-rg-config", { + pattern: "a.b", + path: testFile, + literal: false, + }); + + const output = getTextOutput(result); + expect(output).toContain("fixture.txt:1: aXb"); + expect(output).toContain("fixture.txt:2: a.b"); + } finally { + if (previous === undefined) delete process.env.RIPGREP_CONFIG_PATH; + else process.env.RIPGREP_CONFIG_PATH = previous; + } + }); + it("should treat flag-like patterns as search text", async () => { const marker = join(testDir, "grep-injection-marker"); const payload = join(testDir, "payload.sh"); From 02726086d11c2911aa872959e308b90d69182392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Wed, 21 Oct 2026 22:34:04 +0200 Subject: [PATCH 03/30] fix(coding-agent): recovery omits tool results with the truncated tool call Pre-prompt recovery of a recoverable length stop omitted the assistant message but not the tool results that answered its calls, because only the end-of-run caller passed them. The orphaned results were persisted and providers rejected every later request. _omitRecoveryAttempt now finds the matching tool results in the projected context itself, so every caller omits the pair together. closes #11227 --- .../coding-agent/src/core/agent-session.ts | 33 +++++--- .../11227-recovery-omits-tool-results.test.ts | 79 +++++++++++++++++++ 2 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/11227-recovery-omits-tool-results.test.ts diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 12397c167..3a9174cf6 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -427,7 +427,6 @@ export class AgentSession { private readonly _entryIdsByMessage = new WeakMap(); private readonly _boundaryDispatchedMessages = new WeakSet(); private _lastAssistantMessage: AssistantMessage | undefined; - private _lastAssistantToolResults: AgentMessage[] = []; private _lastActivityOutcome: AgentActivityOutcome = "completed"; private _isBeforeSettle = false; private _abortDuringBeforeSettle = false; @@ -1194,7 +1193,6 @@ export class AgentSession { // extension and listener dispatch above also picks up messages that turn_end // handlers queued. if (event.type === "turn_end") { - this._lastAssistantToolResults = event.toolResults; this._flushPendingCustomMessages(); } }; @@ -1238,7 +1236,24 @@ export class AgentSession { return undefined; } - private _omitRecoveryAttempt(message: AssistantMessage, toolResults: AgentMessage[] = []): void { + /** + * Persistently omit a failed attempt and its tool results from model context. Results are + * found in the projected context, so the pair stays together whether the attempt comes from + * the current run or a resumed session. An unpaired result fails the next request (#11227). + */ + private _omitRecoveryAttempt(message: AssistantMessage): void { + const toolCallIds = new Set(message.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : []))); + // A context edit replaces the projected copy, so match it by source entry as well. + const entryId = this._findPersistedMessageEntryId(message); + const messages = this.agent.state.messages; + const index = messages.findIndex( + (m) => m === message || (entryId !== undefined && this._entryIdsByMessage.get(m) === entryId), + ); + const toolResults: AgentMessage[] = []; + for (const m of index < 0 ? [] : messages.slice(index + 1)) { + if (m.role === "assistant") break; + if (m.role === "toolResult" && toolCallIds.has(m.toolCallId)) toolResults.push(m); + } const targets = [message, ...toolResults]; const targetIds = targets.map((target) => this._findPersistedMessageEntryId(target)); const unresolvedProjectedTarget = targets.some( @@ -1963,9 +1978,7 @@ export class AgentSession { private async _handlePostAgentRun(): Promise { const message = this._lastAssistantMessage; - const toolResults = this._lastAssistantToolResults; this._lastAssistantMessage = undefined; - this._lastAssistantToolResults = []; if (this._agentRunAbortRequested) { this._finishCancelledRetry(); return false; @@ -1992,7 +2005,7 @@ export class AgentSession { this._retryAttempt = 0; } - if (await this._checkCompaction(message, true, toolResults)) { + if (await this._checkCompaction(message)) { return !this._agentRunAbortRequested; } @@ -2995,11 +3008,7 @@ export class AgentSession { * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true * @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages */ - private async _checkCompaction( - assistantMessage: AssistantMessage, - skipAbortedCheck = true, - toolResults: AgentMessage[] = [], - ): Promise { + private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise { const settings = this.settingsManager.getCompactionSettings(this.model); if (!settings.enabled) return false; @@ -3091,7 +3100,7 @@ export class AgentSession { // Persistently omit the selected final attempt before post-run recovery compaction. this._overflowRecoveryAttempted = true; - this._omitRecoveryAttempt(assistantMessage, toolResults); + this._omitRecoveryAttempt(assistantMessage); const retry = await this._runAutoCompaction("overflow", willRetry); if (retry) this._failedResponse = assistantMessage; return retry; diff --git a/packages/coding-agent/test/suite/regressions/11227-recovery-omits-tool-results.test.ts b/packages/coding-agent/test/suite/regressions/11227-recovery-omits-tool-results.test.ts new file mode 100644 index 000000000..3fa03d4bc --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/11227-recovery-omits-tool-results.test.ts @@ -0,0 +1,79 @@ +import { type AssistantMessage, fauxAssistantMessage, fauxToolCall, type Message } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { createHarness, type Harness } from "../harness.ts"; + +// https://github.com/earendil-works/pi/issues/11227 +describe("issue #11227 recovery omits tool results with their tool call", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("pre-prompt recovery of a resumed length stop omits the truncated call and its result", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 100_000, maxTokens: 1_000 }], + settings: { compaction: { enabled: true } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => ({ + compaction: { + summary: "summary", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + })); + }, + ], + }); + harnesses.push(harness); + + const now = Date.now(); + const model = harness.getModel(); + harness.sessionManager.appendMessage({ role: "user", content: "check status", timestamp: now - 1000 }); + const truncated: AssistantMessage = { + ...fauxAssistantMessage([fauxToolCall("bash", { command: "echo" }, { id: "call_truncated" })], { + stopReason: "length", + timestamp: now - 500, + }), + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 100, + output: 16, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 116, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; + harness.sessionManager.appendMessage(truncated); + harness.sessionManager.appendMessage({ + role: "toolResult", + toolCallId: "call_truncated", + toolName: "bash", + isError: true, + content: [{ type: "text", text: "Not executed: output token limit reached." }], + timestamp: now - 400, + }); + harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; + + let requestMessages: Message[] = []; + harness.setResponses([ + (context) => { + requestMessages = context.messages; + return fauxAssistantMessage("done"); + }, + ]); + + await harness.session.prompt("continue"); + + const requestRoles = requestMessages.map((message) => message.role); + expect(requestRoles).not.toContain("toolResult"); + expect(requestMessages.some((m) => m.role === "assistant" && m.stopReason === "length")).toBe(false); + }); +}); From 7f225f25d8a961f7db50fec50124c15d11013f93 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 21 Oct 2026 23:53:14 +0200 Subject: [PATCH 04/30] feat(ai,coding-agent): image input for System One classifiers Jev (TypeSafe, Workers AI), Clef (Workers AI), and OpenRouter decision models now accept ClassifierContext.images. Each service takes its own wire form: TypeSafe assets with the opt-in header, Clef images data URLs, or OpenRouter image_url parts in the state. A new SystemOneCompat (imageInput, sendImageOptInHeader) selects the form; catalog entries set it explicitly and models.json provider compat now applies to classifiers. Adds Clef Omni and Jev 1.14 on Workers AI and fixes Clef Flash and Workers AI Jev catalog pricing. --- packages/ai/CHANGELOG.md | 4 + packages/ai/README.md | 16 +- packages/ai/scripts/generate-models.ts | 62 ++++++-- packages/ai/scripts/openrouter-catalog.ts | 2 + .../api/cloudflare-workers-ai-system-one.ts | 8 + packages/ai/src/api/system-one-shared.ts | 110 ++++++++++++- packages/ai/src/api/typesafe-system-one.ts | 11 ++ packages/ai/src/providers/compat-schema.ts | 20 +++ packages/ai/src/types.ts | 4 + .../ai/test/classifier-images-live.test.ts | 86 ++++++++++ packages/ai/test/classifier-models.test.ts | 7 +- .../cloudflare-workers-ai-system-one.test.ts | 101 +++++++++++- packages/ai/test/typesafe-system-one.test.ts | 149 +++++++++++++++++- packages/coding-agent/CHANGELOG.md | 2 + .../coding-agent/docs/models-configuration.md | 9 ++ packages/coding-agent/docs/models.md | 12 +- .../coding-agent/schemas/models.schema.json | 21 +++ .../src/core/provider-composer.ts | 11 +- .../test/model-runtime-classifiers.test.ts | 88 ++++++++++- 19 files changed, 687 insertions(+), 36 deletions(-) create mode 100644 packages/ai/test/classifier-images-live.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c9caf6a17..8f4ae87aa 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -17,6 +17,9 @@ - Failed model request spans report `provider_error` status with up to 500 error-message characters; cancelled requests keep `ok` status with `stop: "aborted"`. - Added `MiniMax-M3.1-Flash` to the `minimax` and `minimax-cn` providers, with thinking levels low through max sent as `output_config.effort`. Thinking cannot be turned off for this model. New MiniMax models from models.dev now appear without a manual allowlist entry; only the older M2, M2.1, M2.5, and M2.5-highspeed models stay hidden ([#11121](https://github.com/earendil-works/pi/issues/11121)) - Sign in with ChatGPT: ChatGPT Business workspace usage-limit errors are not retried and tell the user to ask their workspace admin to increase the limit. +- Added image input to System One classifiers: `ClassifierContext.images` now works with Jev on `typesafe` (experimental there, opted in with the `TypeSafe-Danger-Zone-Images` header) and `cloudflare-workers-ai`, with Clef on `cloudflare-workers-ai`, and with OpenRouter decision models whose catalog lists image input. Each service gets its own wire form: TypeSafe `assets`, Clef `images` data URLs, or OpenRouter `image_url` parts in the state. +- Added `compat` to System One classifier models (`SystemOneCompat`, with `imageInput` and `sendImageOptInHeader`). Built-in catalog entries set it explicitly; models without it detect the form from the provider, `baseUrl`, and model ID. +- Added the `@cf/cloudflare/clef-omni` and `typesafe/jev-1.14.0` classifier models to `cloudflare-workers-ai`. ### Changed @@ -24,6 +27,7 @@ ### Fixed +- Fixed Cloudflare Workers AI classifier catalog data: Clef Flash now costs $0.038 per million input tokens with a 24,576-token context window, and Jev costs $0.042 per million input tokens instead of being listed as free. - Fixed whitespace-only tool output (for example a bare `\r\n` from bash) being sent as-is to OpenAI Completions, OpenAI Responses, and Google providers; it now uses the same placeholder as empty output, since some providers reject whitespace-only tool content with a 400 ([#8720](https://github.com/earendil-works/pi/issues/8720)) - Fixed automatic retry not triggering for gateway upstream failures such as `upstream_error: Upstream request failed`, `upstream_stream_read_error`, and `Upstream HTTP/2 stream failed` ([#10920](https://github.com/earendil-works/pi/issues/10920)) - Fixed automatic retry not triggering for `stream disconnected before completion` errors relayed by proxies that wrap the Codex CLI ([#11057](https://github.com/earendil-works/pi/issues/11057)) diff --git a/packages/ai/README.md b/packages/ai/README.md index 88a76366e..7728ba6a4 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -895,8 +895,8 @@ Classifier models consume structured JSON state, and optionally images, and answ | Provider | Model IDs | Auth | | --- | --- | --- | | `typesafe` | `jev-latest` | `TYPESAFE_API_KEY` | -| `openrouter` | `typesafe/jev-1.13`, `~typesafe/jev-latest` | `OPENROUTER_API_KEY` or OpenRouter OAuth | -| `cloudflare-workers-ai` | `typesafe/jev`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` | +| `openrouter` | `typesafe/jev-1.13`, `typesafe/jev-1.14`, `~typesafe/jev-latest`, `cloudflare/clef`, `cloudflare/clef-flash`, `cloudflare/clef-omni` | `OPENROUTER_API_KEY` or OpenRouter OAuth | +| `cloudflare-workers-ai` | `typesafe/jev`, `typesafe/jev-1.14.0`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash`, `@cf/cloudflare/clef-omni` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` | | `vercel-ai-gateway` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY` | | `opencode` | `jev-1.13`, `jev-1.13-free` | `OPENCODE_API_KEY` | | `openai` | `gpt-6-luna` | `OPENAI_API_KEY` | @@ -941,6 +941,18 @@ When the service reports token counts, `result.usage` carries them with their co `ClassifierContext.images` holds image blocks (`{ type: "image", data, mimeType }`) judged together with the state. Only models whose `input` includes `"image"` accept them; `classify()` returns an error result for other models. +### System One images + +System One services take images in three forms. The model's `compat.imageInput` chooses the form (`"assets"`, `"images"`, or `"state-image-urls"`), and `compat.sendImageOptInHeader` controls TypeSafe's opt-in header: + +- **TypeSafe** (`typesafe`, and Jev on Workers AI) takes PNG and JPEG images as `assets`, `{ "": { type: "image", format: "png", data_base64 } }`. Every asset must be referenced from the request, so the state is sent as `["", ..., state]`. Images are experimental on TypeSafe's own API and need the `TypeSafe-Danger-Zone-Images: true` header, which the API sends with requests that carry images. +- **Clef on Workers AI** takes up to four PNG, JPEG, or WebP images as an `images` list of data URLs, next to the unchanged state. +- **OpenRouter** takes images as `{ type: "image_url", image_url: { url } }` parts before the state: `[part, ..., state]`. It answers requests with `assets` but drops the images, without an error. + +Built-in catalog entries set `compat` explicitly, so they keep their form when `baseUrl` points at a proxy. For models without it, the APIs detect the form: OpenRouter by provider `openrouter` or a `baseUrl` containing `openrouter.ai`, Clef by a model ID starting with `@cf/cloudflare/clef`, and TypeSafe's form with the opt-in header otherwise. A custom provider that reaches OpenRouter through a proxy matches neither check and must set `compat.imageInput: "state-image-urls"` and `compat.sendImageOptInHeader: false`; otherwise OpenRouter drops its images. + +Vercel AI Gateway and OpenCode list their Jev models with text input only. + ### OpenAI Decisions The `openai-decisions` API calls OpenAI's [Decisions API](https://developers.openai.com/api/docs/guides/decisions) (`POST /v1/decisions`). GPT-6 Luna is its only model. The state is sent as JSON text; with images, the input is one user message with the state followed by the images as data URLs (at most 128). `choice` and `score` questions map to the Decisions types of the same name. `bool` questions become `predicate` questions; predicates have no criteria field, so the meanings of true and false are appended to the instructions. If OpenAI refuses to answer a question, the whole result is an error. Usage reports input tokens only, priced at the catalog input rate with OpenAI's long-context multiplier. diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index e6527b4b7..f5efb94bf 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -2631,11 +2631,15 @@ async function loadModelsDevClassifierModels(): Promise[] = [ }, ]; +// Workers AI has no unauthenticated catalog, so its System One models are listed here from +// Cloudflare's model pages. const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-workers-ai-system-one">[] = [ - // Cloudflare-hosted Clef decision models. They accept images, but classifier - // contexts carry text or JSON state only, so the catalog advertises text. - // Pricing: https://developers.cloudflare.com/workers-ai/models/clef/ - // and https://developers.cloudflare.com/workers-ai/models/clef-flash/ + // Cloudflare-hosted Clef decision models. They take up to four PNG, JPEG, or WebP images as data URLs. + // Clef Omni also takes audio and video, which classifier contexts do not carry. + // Pricing and context: https://developers.cloudflare.com/workers-ai/models/clef/, + // https://developers.cloudflare.com/workers-ai/models/clef-flash/, + // and https://developers.cloudflare.com/workers-ai/models/clef-omni/ { type: "classifier", id: "@cf/cloudflare/clef", @@ -2688,9 +2692,10 @@ const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-worke api: "cloudflare-workers-ai-system-one", provider: "cloudflare-workers-ai", baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL, - input: ["text"], + input: ["text", "image"], cost: { input: 0.24, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 65536, + compat: { imageInput: "images" }, }, { type: "classifier", @@ -2699,10 +2704,26 @@ const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-worke api: "cloudflare-workers-ai-system-one", provider: "cloudflare-workers-ai", baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL, - input: ["text"], - cost: { input: 0.09, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 65536, + input: ["text", "image"], + cost: { input: 0.038, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 24576, + compat: { imageInput: "images" }, }, + { + type: "classifier", + id: "@cf/cloudflare/clef-omni", + name: "Clef Omni", + api: "cloudflare-workers-ai-system-one", + provider: "cloudflare-workers-ai", + baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL, + input: ["text", "image"], + cost: { input: 0.15, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 64000, + compat: { imageInput: "images" }, + }, + // TypeSafe's Jev models take PNG and JPEG images as System One assets. + // https://developers.cloudflare.com/ai/models/typesafe/jev/ (jev-latest) + // and https://developers.cloudflare.com/ai/models/typesafe/jev-1.14.0/ { type: "classifier", id: "typesafe/jev", @@ -2710,9 +2731,22 @@ const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-worke api: "cloudflare-workers-ai-system-one", provider: "cloudflare-workers-ai", baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + input: ["text", "image"], + cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 32000, + compat: { imageInput: "assets" }, + }, + { + type: "classifier", + id: "typesafe/jev-1.14.0", + name: "Jev 1.14", + api: "cloudflare-workers-ai-system-one", + provider: "cloudflare-workers-ai", + baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL, + input: ["text", "image"], + cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32000, + compat: { imageInput: "assets" }, }, ]; diff --git a/packages/ai/scripts/openrouter-catalog.ts b/packages/ai/scripts/openrouter-catalog.ts index 0517e98f6..8f01a14e6 100644 --- a/packages/ai/scripts/openrouter-catalog.ts +++ b/packages/ai/scripts/openrouter-catalog.ts @@ -98,6 +98,8 @@ export function buildOpenRouterCatalog( input: input.length > 0 ? input : ["text"], cost: openRouterCost(model.pricing), contextWindow: model.top_provider?.context_length || model.context_length || 4096, + // OpenRouter answers TypeSafe `assets` with 200 but drops the images. + compat: { imageInput: "state-image-urls", sendImageOptInHeader: false }, }); } diff --git a/packages/ai/src/api/cloudflare-workers-ai-system-one.ts b/packages/ai/src/api/cloudflare-workers-ai-system-one.ts index 1f08d3f5e..ec1aefc4a 100644 --- a/packages/ai/src/api/cloudflare-workers-ai-system-one.ts +++ b/packages/ai/src/api/cloudflare-workers-ai-system-one.ts @@ -23,10 +23,18 @@ function cloudflareErrorMessage(errors: unknown): string { * Cloudflare-hosted models such as `@cf/cloudflare/clef` return the output directly: * `{ success, result: { model, answers, usage } }`. * https://developers.cloudflare.com/workers-ai/models/clef/ + * + * Clef models take images as an `images` list of data URLs. TypeSafe's models take them as + * System One `assets`, without TypeSafe's opt-in header. The catalog sets `compat.imageInput`; + * the model ID check only covers models without it. */ const transport: SystemOneTransport = { api: "cloudflare-workers-ai-system-one", label: LABEL, + detectCompat: (model) => ({ + imageInput: model.id.startsWith("@cf/cloudflare/clef") ? "images" : "assets", + sendImageOptInHeader: false, + }), url: (model) => new URL("run", `${model.baseUrl.replace(/\/+$/u, "")}/`), payload: (model, request) => ({ model: model.id, input: request }), output: (body) => { diff --git a/packages/ai/src/api/system-one-shared.ts b/packages/ai/src/api/system-one-shared.ts index 6f15f044f..96abf494c 100644 --- a/packages/ai/src/api/system-one-shared.ts +++ b/packages/ai/src/api/system-one-shared.ts @@ -5,22 +5,62 @@ import type { ClassifierModel, ClassifierOptions, ClassifierResult, + ImageContent, + SystemOneCompat, } from "../types.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { isRecord, parseClassifierUsage, postClassifierRequest, requiredNumber } from "./classifier-shared.ts"; +/** A System One image asset: base64 file bytes, referenced by its name from the state. */ +interface SystemOneImageAsset { + type: "image"; + format: "png" | "jpeg"; + data_base64: string; +} + +/** An image in the state, OpenRouter's form. */ +interface SystemOneImageUrlPart { + type: "image_url"; + image_url: { url: string }; +} + /** TypeSafe System One request body without the transport-specific envelope. */ export interface SystemOneWireRequest { - state: ClassifierContext["state"]; + /** The context state, or with images a list of the images or their asset names followed by the state. */ + state: unknown; questions: Record; + /** Images as System One assets, keyed by the names the state references. */ + assets?: Record; + /** Images as data URLs, Clef's extension to System One. */ + images?: string[]; } +/** + * How a service takes images (`SystemOneCompat.imageInput`): + * - `assets`: TypeSafe's `assets` map, `{ "": { type: "image", format, data_base64 } }`, + * with the names placed before the state: `state: ["", state]`. PNG and JPEG only. + * - `images`: Clef's `images` list of data URLs, which the model reads before the state. + * PNG, JPEG, and WebP, at most four. + * - `state-image-urls`: OpenRouter's form, data URL parts placed before the state: + * `state: [{ type: "image_url", image_url: { url } }, state]`. OpenRouter accepts this for every + * model, and drops `assets` silently. + */ +export type SystemOneImageInput = NonNullable; + +/** System One compatibility settings with every field resolved. */ +export type ResolvedSystemOneCompat = Required; + +/** TypeSafe's opt-in header for its experimental image input. */ +const IMAGE_OPT_IN_HEADERS = { "TypeSafe-Danger-Zone-Images": "true" }; + /** Differences between services that serve System One models. */ export interface SystemOneTransport { /** Classifier API implemented by this transport. */ api: ClassifierApi; /** Service name used in error messages. */ label: string; + /** Compatibility settings for models that do not set them in `compat`. */ + detectCompat(model: ClassifierModel): ResolvedSystemOneCompat; /** Absolute request URL. */ url(model: ClassifierModel): URL; /** Wraps the System One request in the service's request envelope. */ @@ -82,6 +122,56 @@ function parseAnswers(label: string, value: unknown, context: ClassifierContext) return Object.fromEntries(answers); } +const ASSET_FORMATS: Record = { "image/png": "png", "image/jpeg": "jpeg" }; +const DATA_URL_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); +/** Clef accepts at most this many images per request. */ +const MAX_DATA_URL_IMAGES = 4; + +/** Adds the context images to `request` in the service's form. */ +function addImages( + label: string, + model: ClassifierModel, + input: SystemOneImageInput, + images: ImageContent[], + request: SystemOneWireRequest, +): void { + if (input === "images") { + if (images.length > MAX_DATA_URL_IMAGES) { + throw new Error( + `${label} accepts at most ${MAX_DATA_URL_IMAGES} images for ${model.id}, got ${images.length}`, + ); + } + request.images = images.map((image) => { + if (!DATA_URL_TYPES.has(image.mimeType)) { + throw new Error(`${label} accepts PNG, JPEG, and WebP images for ${model.id}, got ${image.mimeType}`); + } + return `data:${image.mimeType};base64,${image.data}`; + }); + return; + } + if (input === "state-image-urls") { + // OpenRouter validates the images against the model that serves the request. + const parts = images.map( + (image): SystemOneImageUrlPart => ({ + type: "image_url", + image_url: { url: `data:${image.mimeType};base64,${image.data}` }, + }), + ); + request.state = [...parts, request.state]; + return; + } + const assets: Record = {}; + images.forEach((image, index) => { + const format = ASSET_FORMATS[image.mimeType]; + if (!format) throw new Error(`${label} accepts PNG and JPEG images for ${model.id}, got ${image.mimeType}`); + assets[``] = { type: "image", format, data_base64: image.data }; + }); + request.assets = assets; + // Every asset must be referenced from the request. The names go before the state, where Clef + // also places its images. + request.state = [...Object.keys(assets), request.state]; +} + /** Maps public `bool` questions to TypeSafe's wire-level `noul` type. */ function wireRequest(context: ClassifierContext): SystemOneWireRequest { return { @@ -113,13 +203,25 @@ export async function classifySystemOne( try { if (model.api !== transport.api) throw new Error(`Unsupported classifier API: ${model.api}`); - if (context.images?.length) throw new Error(`${transport.label} does not support image input`); + const request = wireRequest(context); + let requestOptions = options; + if (context.images?.length) { + if (!model.input.includes("image")) { + throw new Error(`${transport.label} does not support image input for ${model.id}`); + } + const detected = transport.detectCompat(model); + const imageInput = model.compat?.imageInput ?? detected.imageInput; + addImages(transport.label, model, imageInput, context.images, request); + if (model.compat?.sendImageOptInHeader ?? detected.sendImageOptInHeader) { + requestOptions = { ...options, headers: { ...IMAGE_OPT_IN_HEADERS, ...options?.headers } }; + } + } const body = await postClassifierRequest( transport.label, transport.url(model), model, - transport.payload(model, wireRequest(context)), - options, + transport.payload(model, request), + requestOptions, ); const result = transport.output(body); // Set before parsing answers: a request with malformed answers was still billed. diff --git a/packages/ai/src/api/typesafe-system-one.ts b/packages/ai/src/api/typesafe-system-one.ts index 542fc476b..e9fa13c28 100644 --- a/packages/ai/src/api/typesafe-system-one.ts +++ b/packages/ai/src/api/typesafe-system-one.ts @@ -5,10 +5,21 @@ import { classifySystemOne, type SystemOneTransport } from "./system-one-shared. /** * TypeSafe's native System One protocol. OpenRouter serves the same protocol, * so both providers use this API with different base URLs. + * + * TypeSafe takes images as `assets`. They are experimental and need the + * `TypeSafe-Danger-Zone-Images` opt-in header; without it the API answers 400. + * OpenRouter drops `assets` silently and takes images as `image_url` parts in the state instead. + * Models behind a proxy that hides both the provider ID and the OpenRouter host must set + * `compat.imageInput`. */ const transport: SystemOneTransport = { api: "typesafe-system-one", label: "System One API", + // OpenRouter by provider ID, which survives baseUrl overrides, or by host, which covers regional endpoints. + detectCompat: (model) => + model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai") + ? { imageInput: "state-image-urls", sendImageOptInHeader: false } + : { imageInput: "assets", sendImageOptInHeader: true }, url: (model) => new URL("systemone", `${model.baseUrl.replace(/\/+$/u, "")}/`), payload: (model, request) => ({ model: model.id, ...request }), output: (body) => { diff --git a/packages/ai/src/providers/compat-schema.ts b/packages/ai/src/providers/compat-schema.ts index ca54cc054..b3ba8198e 100644 --- a/packages/ai/src/providers/compat-schema.ts +++ b/packages/ai/src/providers/compat-schema.ts @@ -521,6 +521,22 @@ export const MistralConversationsCompatSchema = Type.Object( { description: "Compatibility settings for the Mistral chat API.", additionalProperties: true }, ); +export const SystemOneCompatSchema = Type.Object( + { + imageInput: Type.Optional( + Type.Union([Type.Literal("assets"), Type.Literal("images"), Type.Literal("state-image-urls")], { + description: + 'How the service takes classifier images. "assets" sends TypeSafe\'s assets map (PNG and JPEG) and references the images from the state, as TypeSafe and Jev on Workers AI expect. "images" sends Clef\'s images list of data URLs (PNG, JPEG, and WebP, at most four), as Clef on Workers AI expects. "state-image-urls" places image_url parts before the state, as OpenRouter expects; OpenRouter drops assets without an error. Auto-detected from the provider, baseUrl, and model ID. Classifier models read it from the provider-level compat; models and modelOverrides entries apply to chat models only.', + }), + ), + sendImageOptInHeader: optionalCompatBoolean({ + description: + "Whether requests with images send the TypeSafe-Danger-Zone-Images: true header, which TypeSafe's own API requires for its experimental image input. Defaults to true for the typesafe-system-one API unless the model is detected as OpenRouter.", + }), + }, + { description: "Compatibility settings for System One classifier APIs.", additionalProperties: true }, +); + const ProviderCompatPropertyOverrides = { supportsDeveloperRole: optionalCompatBoolean({ description: "Whether the provider supports the developer role instead of system. Defaults are API-specific.", @@ -609,6 +625,8 @@ export const CompatSchemasByApi = { "anthropic-messages": AnthropicMessagesCompatSchema, "bedrock-converse-stream": BedrockCompatSchema, "mistral-conversations": MistralConversationsCompatSchema, + "typesafe-system-one": SystemOneCompatSchema, + "cloudflare-workers-ai-system-one": SystemOneCompatSchema, } as const; const ProviderCompatProperties = mergeCompatProperties( @@ -642,3 +660,5 @@ export interface AnthropicMessagesCompat extends Static {} /** Compatibility settings for the Mistral chat API. */ export interface MistralConversationsCompat extends Static {} +/** Compatibility settings for System One classifier APIs. */ +export interface SystemOneCompat extends Static {} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index a04b9d212..c0f3b5627 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -15,6 +15,7 @@ import type { MistralConversationsCompat, OpenAICompletionsCompat, OpenAIResponsesCompat, + SystemOneCompat, } from "./providers/compat-schema.ts"; import type { CacheRetention, @@ -40,6 +41,7 @@ export type { OpenAIResponsesCompat, OpenRouterRouting, SessionAffinityFormat, + SystemOneCompat, ThinkingTokenBudgetField, VercelGatewayRouting, } from "./providers/compat-schema.ts"; @@ -888,6 +890,8 @@ export interface ImageModel extends BaseModel { export interface ClassifierModel extends BaseModel { type: "classifier"; contextWindow: number; + /** Compatibility overrides for System One APIs. If not set, auto-detected from provider, baseUrl, and id. */ + compat?: TApi extends "typesafe-system-one" | "cloudflare-workers-ai-system-one" ? SystemOneCompat : never; } /** Model shape for each model type. */ diff --git a/packages/ai/test/classifier-images-live.test.ts b/packages/ai/test/classifier-images-live.test.ts new file mode 100644 index 000000000..b806e9fa4 --- /dev/null +++ b/packages/ai/test/classifier-images-live.test.ts @@ -0,0 +1,86 @@ +/** + * Live image classification through System One services. Each provider's block runs only when its + * credentials are set. Each case sends a solid red and a solid blue PNG in separate requests, so a + * service that drops the image cannot pass by chance. + */ +import { describe, expect, it } from "vitest"; +import { createModels, type ModelsClassifierOptions } from "../src/models.ts"; +import { cloudflareWorkersAIProvider } from "../src/providers/cloudflare-workers-ai.ts"; +import { openrouterProvider } from "../src/providers/openrouter.ts"; +import { typesafeProvider } from "../src/providers/typesafe.ts"; +import type { ClassifierContext } from "../src/types.ts"; +import { hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts"; + +// 512x512 solid PNGs from the TypeSafe SDK's live image tests. +const RED = + "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIAAQMAAADOtka5AAAABlBMVEX/AAAAAABBoxIDAAAANklEQVR42u3BAQEAAACCIP+vbkhAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8G4IAAAFjdVCkAAAAAElFTkSuQmCC"; +const BLUE = + "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIAAQMAAADOtka5AAAABlBMVEUAAP8AAAB7Yr8+AAAANklEQVR42u3BAQEAAACCIP+vbkhAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8G4IAAAFjdVCkAAAAAElFTkSuQmCC"; + +function colorContext(data: string): ClassifierContext { + return { + state: { task: "Name the color of the attached image" }, + images: [{ type: "image", data, mimeType: "image/png" }], + questions: { + color: { + type: "choice", + instructions: "What color is the attached image?", + criteria: { red: "Red", blue: "Blue", green: "Green" }, + }, + }, + }; +} + +const models = createModels(); +models.setProvider(typesafeProvider()); +models.setProvider(cloudflareWorkersAIProvider()); +models.setProvider(openrouterProvider()); + +async function expectColors(provider: string, id: string, options: ModelsClassifierOptions): Promise { + const model = models.getModelOfType("classifier", provider, id); + if (!model) throw new Error(`missing ${provider}/${id}`); + expect(model.input).toContain("image"); + for (const [data, color] of [ + [RED, "red"], + [BLUE, "blue"], + ] as const) { + const result = await models.classify(model, colorContext(data), options); + expect(result.errorMessage).toBeUndefined(); + expect(result.answers.color).toMatchObject({ type: "choice", choice: color }); + } +} + +describe.skipIf(!process.env.TYPESAFE_API_KEY)("TypeSafe image classification", () => { + it("classifies images with jev-latest", async () => { + await expectColors("typesafe", "jev-latest", { apiKey: process.env.TYPESAFE_API_KEY }); + }, 60_000); +}); + +describe.skipIf(!hasCloudflareWorkersAICredentials())("Cloudflare Workers AI image classification", () => { + it.each([ + "@cf/cloudflare/clef", + "@cf/cloudflare/clef-flash", + "@cf/cloudflare/clef-omni", + "typesafe/jev", + "typesafe/jev-1.14.0", + ])( + "classifies images with %s", + async (id) => { + await expectColors("cloudflare-workers-ai", id, { + apiKey: process.env.CLOUDFLARE_API_KEY, + env: { CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID! }, + }); + }, + 60_000, + ); +}); + +describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter image classification", () => { + it.each(["typesafe/jev-1.14", "cloudflare/clef-flash"])( + "classifies images with %s", + async (id) => { + await expectColors("openrouter", id, { apiKey: process.env.OPENROUTER_API_KEY }); + }, + 60_000, + ); +}); diff --git a/packages/ai/test/classifier-models.test.ts b/packages/ai/test/classifier-models.test.ts index 5f0cb2cd5..4a5592501 100644 --- a/packages/ai/test/classifier-models.test.ts +++ b/packages/ai/test/classifier-models.test.ts @@ -248,7 +248,12 @@ describe("Models with classifier models", () => { it("routes OpenRouter classifier models through the System One API", () => { const models = builtinModels(); for (const model of getBuiltinClassifierModels("openrouter")) { - expect(model).toMatchObject({ api: "typesafe-system-one", baseUrl: "https://openrouter.ai/api/v1" }); + expect(model).toMatchObject({ + api: "typesafe-system-one", + baseUrl: "https://openrouter.ai/api/v1", + // Explicit, so images keep OpenRouter's form when the baseUrl is overridden. + compat: { imageInput: "state-image-urls", sendImageOptInHeader: false }, + }); expect(models.getModel("openrouter", model.id)).toBeUndefined(); expect(models.getModelOfType("classifier", "openrouter", model.id)).toEqual(model); } diff --git a/packages/ai/test/cloudflare-workers-ai-system-one.test.ts b/packages/ai/test/cloudflare-workers-ai-system-one.test.ts index d7ec82e49..eb338c083 100644 --- a/packages/ai/test/cloudflare-workers-ai-system-one.test.ts +++ b/packages/ai/test/cloudflare-workers-ai-system-one.test.ts @@ -105,7 +105,8 @@ describe("Cloudflare Workers AI System One", () => { it.each([ ["@cf/cloudflare/clef", 0.24], - ["@cf/cloudflare/clef-flash", 0.09], + ["@cf/cloudflare/clef-flash", 0.038], + ["@cf/cloudflare/clef-omni", 0.15], ])("runs %s through /ai/run and parses its direct output", async (id, inputPrice) => { const { models } = setup(); const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", id); @@ -131,6 +132,104 @@ describe("Cloudflare Workers AI System One", () => { expect(result.usage?.cost.input).toBeCloseTo((222 * inputPrice) / 1_000_000); }); + it("sends images to Clef as data URLs", async () => { + const { models } = setup(); + const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", "@cf/cloudflare/clef"); + if (!clef) throw new Error("missing Cloudflare Clef model"); + const bodies: Array<{ model: string; input: Record }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as { model: string; input: Record }); + return Response.json({ result: clefOutput, success: true, errors: [], messages: [] }); + }); + + const result = await models.classify( + clef, + { + ...context, + images: [ + { type: "image", data: "cG5n", mimeType: "image/png" }, + { type: "image", data: "d2VicA==", mimeType: "image/webp" }, + ], + }, + { ...auth, fetch }, + ); + + expect(result.stopReason).toBe("stop"); + // Clef rejects `assets` with a 422, so the state stays as is. + expect(bodies[0]?.input).toEqual({ + state: context.state, + questions: expect.any(Object), + images: ["data:image/png;base64,cG5n", "data:image/webp;base64,d2VicA=="], + }); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).has("typesafe-danger-zone-images")).toBe(false); + }); + + it("reads the image form from compat before the model ID", async () => { + const { models, jev } = setup(); + const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", "@cf/cloudflare/clef"); + expect(clef?.compat).toEqual({ imageInput: "images" }); + expect(jev.compat).toEqual({ imageInput: "assets" }); + const bodies: Array<{ input: Record }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as { input: Record }); + return Response.json({ result: clefOutput, success: true, errors: [], messages: [] }); + }); + + // A Clef deployment under an ID the prefix check does not recognize. + await models.classify( + { ...jev, id: "@cf/acme/clef-finetune", input: ["text", "image"], compat: { imageInput: "images" } }, + { ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] }, + { ...auth, fetch }, + ); + + expect(bodies[0]?.input.images).toEqual(["data:image/png;base64,cG5n"]); + expect(bodies[0]?.input).not.toHaveProperty("assets"); + }); + + it("rejects more than four images for Clef before sending", async () => { + const { models } = setup(); + const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", "@cf/cloudflare/clef-flash"); + if (!clef) throw new Error("missing Cloudflare Clef Flash model"); + const fetch = vi.fn(async () => Response.json({ result: clefOutput, success: true })); + const image = { type: "image" as const, data: "cG5n", mimeType: "image/png" }; + + const result = await models.classify(clef, { ...context, images: Array(5).fill(image) }, { ...auth, fetch }); + + expect(fetch).not.toHaveBeenCalled(); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe( + "Cloudflare Workers AI accepts at most 4 images for @cf/cloudflare/clef-flash, got 5", + ); + }); + + it.each(["typesafe/jev", "typesafe/jev-1.14.0"])("sends images to %s as System One assets", async (id) => { + const { models } = setup(); + const jev = models.getModelOfType("classifier", "cloudflare-workers-ai", id); + if (!jev) throw new Error(`missing Cloudflare ${id} model`); + const bodies: Array<{ model: string; input: Record }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as { model: string; input: Record }); + return Response.json(restResponse("Completed")); + }); + + const result = await models.classify( + jev, + { ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] }, + { ...auth, fetch }, + ); + + expect(result.stopReason).toBe("stop"); + expect(bodies[0]).toMatchObject({ + model: id, + input: { + state: ["", context.state], + assets: { "": { type: "image", format: "png", data_base64: "cG5n" } }, + }, + }); + // Workers AI needs no TypeSafe opt-in header. + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).has("typesafe-danger-zone-images")).toBe(false); + }); + it("reports runs that did not complete", async () => { const { models, jev } = setup(); const result = await models.classify(jev, context, { diff --git a/packages/ai/test/typesafe-system-one.test.ts b/packages/ai/test/typesafe-system-one.test.ts index 97e56f0aa..a35908884 100644 --- a/packages/ai/test/typesafe-system-one.test.ts +++ b/packages/ai/test/typesafe-system-one.test.ts @@ -120,17 +120,160 @@ describe("TypeSafe System One", () => { expect(result.errorMessage).toContain("Unsupported classifier API: cloudflare-workers-ai-system-one"); }); - it("rejects image input before sending", async () => { + it("rejects image input for text-only models before sending", async () => { const fetch = vi.fn(async () => Response.json({ answers: wireAnswers })); const result = await classify( - { ...model, input: ["text", "image"] }, + model, { ...context, images: [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }] }, { apiKey: "secret", fetch }, ); expect(fetch).not.toHaveBeenCalled(); expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toBe("System One API does not support image input"); + expect(result.errorMessage).toBe("System One API does not support image input for jev-latest"); + }); + + it("sends images to TypeSafe as assets with the opt-in header", async () => { + const requests: Array<{ body: Record; headers: Headers }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + requests.push({ + body: JSON.parse(String(init?.body)) as Record, + headers: new Headers(init?.headers), + }); + return Response.json({ answers: wireAnswers }); + }); + const imageModel = { ...model, input: ["text", "image"] as ("text" | "image")[] }; + + const result = await classify( + imageModel, + { + ...context, + images: [ + { type: "image", data: "cG5n", mimeType: "image/png" }, + { type: "image", data: "anBlZw==", mimeType: "image/jpeg" }, + ], + }, + { apiKey: "secret", fetch }, + ); + const withoutImages = await classify(imageModel, context, { apiKey: "secret", fetch }); + + expect(result.stopReason).toBe("stop"); + expect(requests[0]?.body).toMatchObject({ + state: ["", "", context.state], + assets: { + "": { type: "image", format: "png", data_base64: "cG5n" }, + "": { type: "image", format: "jpeg", data_base64: "anBlZw==" }, + }, + }); + expect(requests[0]?.headers.get("typesafe-danger-zone-images")).toBe("true"); + // Requests without images keep the plain state and send no opt-in header. + expect(withoutImages.stopReason).toBe("stop"); + expect(requests[1]?.body.state).toEqual(context.state); + expect(requests[1]?.body).not.toHaveProperty("assets"); + expect(requests[1]?.headers.has("typesafe-danger-zone-images")).toBe(false); + }); + + it("rejects image formats TypeSafe does not accept before sending", async () => { + const fetch = vi.fn(async () => Response.json({ answers: wireAnswers })); + const result = await classify( + { ...model, input: ["text", "image"] }, + { ...context, images: [{ type: "image", data: "d2VicA==", mimeType: "image/webp" }] }, + { apiKey: "secret", fetch }, + ); + + expect(fetch).not.toHaveBeenCalled(); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("System One API accepts PNG and JPEG images for jev-latest, got image/webp"); + }); + + it("sends images to OpenRouter as image_url parts in the state", async () => { + const requests: Array<{ body: Record; headers: Headers }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + requests.push({ + body: JSON.parse(String(init?.body)) as Record, + headers: new Headers(init?.headers), + }); + return Response.json({ answers: wireAnswers }); + }); + const openRouterModel: ClassifierModel<"typesafe-system-one"> = { + ...model, + id: "typesafe/jev-1.14", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + }; + + const result = await classify( + openRouterModel, + { ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] }, + { apiKey: "secret", fetch }, + ); + + expect(result.stopReason).toBe("stop"); + // OpenRouter answers 200 for `assets` but drops them, and rejects a top-level `images` list. + expect(requests[0]?.body).toEqual({ + model: "typesafe/jev-1.14", + state: [{ type: "image_url", image_url: { url: "data:image/png;base64,cG5n" } }, context.state], + questions: expect.any(Object), + }); + expect(requests[0]?.headers.has("typesafe-danger-zone-images")).toBe(false); + }); + + it.each([ + ["OpenRouter behind a proxy", { provider: "openrouter", baseUrl: "https://proxy.example.com/v1" }], + ["a regional OpenRouter endpoint", { provider: "custom", baseUrl: "https://us.openrouter.ai/api/v1" }], + [ + "a proxy declared through compat", + { + provider: "custom", + baseUrl: "https://proxy.example.com/v1", + compat: { imageInput: "state-image-urls" as const, sendImageOptInHeader: false }, + }, + ], + ])("sends OpenRouter's image form to %s", async (_name, overrides) => { + const requests: Array<{ body: Record; headers: Headers }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + requests.push({ + body: JSON.parse(String(init?.body)) as Record, + headers: new Headers(init?.headers), + }); + return Response.json({ answers: wireAnswers }); + }); + + const result = await classify( + { ...model, id: "typesafe/jev-1.14", input: ["text", "image"], ...overrides }, + { ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] }, + { apiKey: "secret", fetch }, + ); + + expect(result.stopReason).toBe("stop"); + expect(requests[0]?.body.state).toEqual([ + { type: "image_url", image_url: { url: "data:image/png;base64,cG5n" } }, + context.state, + ]); + expect(requests[0]?.body).not.toHaveProperty("assets"); + expect(requests[0]?.headers.has("typesafe-danger-zone-images")).toBe(false); + }); + + it("lets compat override detected image settings per field", async () => { + const requests: Array<{ body: Record; headers: Headers }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + requests.push({ + body: JSON.parse(String(init?.body)) as Record, + headers: new Headers(init?.headers), + }); + return Response.json({ answers: wireAnswers }); + }); + + // Only the header is overridden; the detected `assets` form stays. + await classify( + { ...model, input: ["text", "image"], compat: { sendImageOptInHeader: false } }, + { ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] }, + { apiKey: "secret", fetch }, + ); + + expect(requests[0]?.body).toHaveProperty("assets"); + expect(requests[0]?.headers.has("typesafe-danger-zone-images")).toBe(false); }); it("merges headers case-insensitively and supports null suppression", async () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 87e490f88..d7a680829 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added - Added a Models Configuration reference generated from the `models.json` schema, listing all provider, model, model override, and compatibility fields ([#10790](https://github.com/earendil-works/pi/issues/10790)) +- Codemode `models.classify()` now accepts `images` for TypeSafe's Jev and Cloudflare's Clef models on `typesafe`, `cloudflare-workers-ai`, and OpenRouter, and lists Clef Omni and Jev 1.14 on Workers AI. +- Provider-level `compat` in `models.json` now also applies to classifier models, with the System One fields `imageInput` and `sendImageOptInHeader` for proxies that serve another service's classifiers. Extension classifier models accept `compat` too. ### Changed diff --git a/packages/coding-agent/docs/models-configuration.md b/packages/coding-agent/docs/models-configuration.md index 04787a77c..31bf4b871 100644 --- a/packages/coding-agent/docs/models-configuration.md +++ b/packages/coding-agent/docs/models-configuration.md @@ -185,3 +185,12 @@ Also applies to `azure-openai-responses` and `openai-codex-responses`. | Field | Type | Default | Description | |---|---|---|---| | `supportsMidConvoSystemMessages` | boolean | `false` | Whether the exact model accepts system messages after the conversation has started. When false, later system messages are folded into the leading system message. | + +### `typesafe-system-one` + +Also applies to `cloudflare-workers-ai-system-one`. + +| Field | Type | Default | Description | +|---|---|---|---| +| `imageInput` | `"assets" \| "images" \| "state-image-urls"` | | How the service takes classifier images. "assets" sends TypeSafe's assets map (PNG and JPEG) and references the images from the state, as TypeSafe and Jev on Workers AI expect. "images" sends Clef's images list of data URLs (PNG, JPEG, and WebP, at most four), as Clef on Workers AI expects. "state-image-urls" places image_url parts before the state, as OpenRouter expects; OpenRouter drops assets without an error. Auto-detected from the provider, baseUrl, and model ID. Classifier models read it from the provider-level compat; models and modelOverrides entries apply to chat models only. | +| `sendImageOptInHeader` | boolean | | Whether requests with images send the TypeSafe-Danger-Zone-Images: true header, which TypeSafe's own API requires for its experimental image input. Defaults to true for the typesafe-system-one API unless the model is detected as OpenRouter. | diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 2014aee50..e076ac139 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -141,20 +141,20 @@ Compatibility settings should describe verified differences in the endpoint's re ## Use classifier models -Classifier models do not chat. They answer typed questions about JSON state: pick one of several choices, answer yes or no, or give a score, each with probabilities. Pi includes TypeSafe's Jev model from these providers, Cloudflare's Clef and Clef Flash models from Workers AI, and OpenAI's GPT-6 Luna through the [Decisions API](https://developers.openai.com/api/docs/guides/decisions): +Classifier models do not chat. They answer typed questions about JSON state: pick one of several choices, answer yes or no, or give a score, each with probabilities. Pi includes TypeSafe's Jev model from these providers, Cloudflare's Clef, Clef Flash, and Clef Omni models from Workers AI and OpenRouter, and OpenAI's GPT-6 Luna through the [Decisions API](https://developers.openai.com/api/docs/guides/decisions): | Provider | Model IDs | Authentication | |---|---|---| | `typesafe` | `jev-latest` | `TYPESAFE_API_KEY` | -| `openrouter` | `typesafe/jev-1.13`, `~typesafe/jev-latest` | `OPENROUTER_API_KEY` or `/login` | -| `cloudflare-workers-ai` | `typesafe/jev`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` | +| `openrouter` | `typesafe/jev-1.13`, `typesafe/jev-1.14`, `~typesafe/jev-latest`, `cloudflare/clef`, `cloudflare/clef-flash`, `cloudflare/clef-omni` | `OPENROUTER_API_KEY` or `/login` | +| `cloudflare-workers-ai` | `typesafe/jev`, `typesafe/jev-1.14.0`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash`, `@cf/cloudflare/clef-omni` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` | | `vercel-ai-gateway` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY` | | `opencode` | `jev-1.13`, `jev-1.13-free` | `OPENCODE_API_KEY` | | `openai` | `gpt-6-luna` | `OPENAI_API_KEY` | Chat models on a [llama.cpp router](llama-cpp.md#classification) are also listed as classifier models. -OpenAI's Decisions API needs an API key. Sign in with ChatGPT credentials do not work with it, so `gpt-6-luna` is not listed as available while `openai` is logged in through `/login`, even when `OPENAI_API_KEY` is set; log out of `openai` to use the key. GPT-6 Luna also judges images passed in `images` (see [Codemode](codemode.md#classify)); other classifier models return an error for them. The API rejects inputs above 922K tokens, but requests that run longer than about five seconds, currently above roughly 600K input tokens, fail with a gateway timeout. +OpenAI's Decisions API needs an API key. Sign in with ChatGPT credentials do not work with it, so `gpt-6-luna` is not listed as available while `openai` is logged in through `/login`, even when `OPENAI_API_KEY` is set; log out of `openai` to use the key. The API rejects inputs above 922K tokens, but requests that run longer than about five seconds, currently above roughly 600K input tokens, fail with a gateway timeout. Classifier models do not appear in `/model`. The model reaches them through the [`codemode`](cli.md#enable-codemode) tool, which is off unless an MCP server turned it on. Enable it with `"defaultTools": ["+codemode"]` in [settings](settings.md#tools). Scripts then list classifier models with `models.getAvailableOfType("classifier")` and call `models.classify(model, { state, questions })`: @@ -175,6 +175,10 @@ return result.answers; [Codemode](codemode.md#classify) describes the question and answer types. +Classifier models whose `input` includes `"image"` also judge images passed in `images`; other classifier models return an error for them. Image input works with GPT-6 Luna, with Jev from `typesafe` and `cloudflare-workers-ai`, with Clef from `cloudflare-workers-ai`, and with the OpenRouter models whose catalog lists image input. Jev takes PNG and JPEG images, and image input is experimental on TypeSafe's own API. Clef takes PNG, JPEG, and WebP images, at most four per request. Jev from `vercel-ai-gateway` and `opencode` takes text only. + +Each System One service takes images in its own request format, set by the model's `compat.imageInput`. Built-in models set it, so overriding a provider's `baseUrl` keeps it. A custom provider that proxies another service's classifiers sets it in its provider-level `compat` in `models.json` (see [System One compatibility](models-configuration.md#typesafe-system-one)). OpenRouter in particular answers requests in TypeSafe's format without an error but drops the images. + When the service reports token counts, as all System One services do, `result.usage` carries them with their cost. Pi adds the usage of a script's classifier calls to the `codemode` tool result, so it counts toward the session cost in the footer and `/session`. The cost uses the model's catalog price; models without one, such as TypeSafe's direct `jev-latest`, report tokens at no cost. Extensions call classifiers through `ctx.modelRegistry.classify()`, without codemode. [Virtual models](virtual-models.md#route-requests) can use them to route requests; see the `jev-router.ts` example. diff --git a/packages/coding-agent/schemas/models.schema.json b/packages/coding-agent/schemas/models.schema.json index 322602d32..39ec3d63e 100644 --- a/packages/coding-agent/schemas/models.schema.json +++ b/packages/coding-agent/schemas/models.schema.json @@ -1107,6 +1107,27 @@ }, "maxItems": 3, "description": "Models Anthropic accepts for server-side refusal fallback, with local pricing metadata for returned fallback responses. When absent or empty, callers must omit fallbacks; Anthropic rejects the field for models with no permitted fallback targets." + }, + "imageInput": { + "anyOf": [ + { + "type": "string", + "const": "assets" + }, + { + "type": "string", + "const": "images" + }, + { + "type": "string", + "const": "state-image-urls" + } + ], + "description": "How the service takes classifier images. \"assets\" sends TypeSafe's assets map (PNG and JPEG) and references the images from the state, as TypeSafe and Jev on Workers AI expect. \"images\" sends Clef's images list of data URLs (PNG, JPEG, and WebP, at most four), as Clef on Workers AI expects. \"state-image-urls\" places image_url parts before the state, as OpenRouter expects; OpenRouter drops assets without an error. Auto-detected from the provider, baseUrl, and model ID. Classifier models read it from the provider-level compat; models and modelOverrides entries apply to chat models only." + }, + "sendImageOptInHeader": { + "type": "boolean", + "description": "Whether requests with images send the TypeSafe-Danger-Zone-Images: true header, which TypeSafe's own API requires for its experimental image input. Defaults to true for the typesafe-system-one API unless the model is detected as OpenRouter." } }, "additionalProperties": true diff --git a/packages/coding-agent/src/core/provider-composer.ts b/packages/coding-agent/src/core/provider-composer.ts index 94bd2ba3e..f7f5c4c59 100644 --- a/packages/coding-agent/src/core/provider-composer.ts +++ b/packages/coding-agent/src/core/provider-composer.ts @@ -7,6 +7,7 @@ import { type AuthInteraction, type AuthResult, type ClassifierApi, + type ClassifierModel, type Credential, type ImageApi, isModelType, @@ -83,6 +84,7 @@ export interface ProviderClassifierModelConfig extends ProviderModelConfigBase { type: "classifier"; api?: ClassifierApi; contextWindow: number; + compat?: ClassifierModel["compat"]; } export type ProviderModelConfig = ProviderChatModelConfig | ProviderImageModelConfig | ProviderClassifierModelConfig; @@ -324,9 +326,12 @@ function applyModelsJson( const models: AnyModel[] = baseModels.map((model) => { const baseUrl = config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl); - return isModelType(model, "chat") - ? { ...model, baseUrl, compat: mergeCompat(model.compat, config.compat) } - : { ...model, baseUrl }; + if (isModelType(model, "chat")) return { ...model, baseUrl, compat: mergeCompat(model.compat, config.compat) }; + // Classifier APIs read their own compat fields, such as how a System One service takes images. + if (isModelType(model, "classifier") && config.compat) { + return { ...model, baseUrl, compat: { ...model.compat, ...config.compat } }; + } + return { ...model, baseUrl }; }); for (const definition of config.models ?? []) { const existingIndex = models.findIndex((model) => isModelType(model, "chat") && model.id === definition.id); diff --git a/packages/coding-agent/test/model-runtime-classifiers.test.ts b/packages/coding-agent/test/model-runtime-classifiers.test.ts index 7f199a016..6038fd628 100644 --- a/packages/coding-agent/test/model-runtime-classifiers.test.ts +++ b/packages/coding-agent/test/model-runtime-classifiers.test.ts @@ -1,5 +1,8 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { InMemoryModelsStore } from "@earendil-works/pi-ai"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; @@ -14,14 +17,56 @@ const context = { }, }; +const image = { type: "image" as const, data: "cG5n", mimeType: "image/png" }; + describe("ModelRuntime classifiers", () => { - it("lists Jev separately and classifies with runtime-resolved auth", async () => { - const runtime = await ModelRuntime.create({ + const tempDirs: string[] = []; + afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + async function createRuntime(modelsJson?: object): Promise { + let modelsPath: string | null = null; + if (modelsJson) { + const dir = mkdtempSync(join(tmpdir(), "pi-classifiers-")); + tempDirs.push(dir); + modelsPath = join(dir, "models.json"); + writeFileSync(modelsPath, JSON.stringify(modelsJson)); + } + return ModelRuntime.create({ credentials: AuthStorage.inMemory(), modelsStore: new InMemoryModelsStore(), - modelsPath: null, + modelsPath, allowModelNetwork: false, }); + } + + /** Classifies one image and returns the request body and headers the service received. */ + async function captureImageRequest(runtime: ModelRuntime, provider: string, id: string) { + await runtime.setRuntimeApiKey(provider, "secret"); + const model = runtime.getModelOfType("classifier", provider, id)!; + const requests: Array<{ url: string; body: Record; headers: Headers }> = []; + const result = await runtime.classify( + model, + { ...context, images: [image] }, + { + fetch: async (input, init) => { + requests.push({ + url: String(input), + body: JSON.parse(String(init?.body)) as Record, + headers: new Headers(init?.headers), + }); + return Response.json({ answers: { approved: { type: "noul", noul: 0.8 } } }); + }, + }, + ); + expect(result.stopReason).toBe("stop"); + expect(requests).toHaveLength(1); + return requests[0]!; + } + + it("lists Jev separately and classifies with runtime-resolved auth", async () => { + const runtime = await createRuntime(); const jev = runtime.getModelOfType("classifier", "typesafe", "jev-latest")!; expect(jev.type).toBe("classifier"); expect(runtime.getModel("typesafe", "jev-latest")).toBeUndefined(); @@ -39,4 +84,39 @@ describe("ModelRuntime classifiers", () => { const result = await runtime.classify(jev, context, { fetch }); expect(result.answers.approved).toEqual({ type: "bool", probability: 0.8 }); }); + + it("keeps OpenRouter's image form when models.json routes OpenRouter through a proxy", async () => { + const runtime = await createRuntime({ providers: { openrouter: { baseUrl: "https://proxy.example.com/v1" } } }); + + const request = await captureImageRequest(runtime, "openrouter", "typesafe/jev-1.14"); + + // OpenRouter answers TypeSafe `assets` with 200 but drops the images. + expect(request.url).toBe("https://proxy.example.com/v1/systemone"); + expect(request.body.state).toEqual([ + { type: "image_url", image_url: { url: "data:image/png;base64,cG5n" } }, + context.state, + ]); + expect(request.body).not.toHaveProperty("assets"); + expect(request.headers.has("typesafe-danger-zone-images")).toBe(false); + }); + + it("applies provider compat from models.json to classifier models", async () => { + const runtime = await createRuntime({ + providers: { + typesafe: { + baseUrl: "https://proxy.example.com/v1", + compat: { imageInput: "state-image-urls", sendImageOptInHeader: false }, + }, + }, + }); + + const request = await captureImageRequest(runtime, "typesafe", "jev-latest"); + + expect(request.url).toBe("https://proxy.example.com/v1/systemone"); + expect(request.body.state).toEqual([ + { type: "image_url", image_url: { url: "data:image/png;base64,cG5n" } }, + context.state, + ]); + expect(request.headers.has("typesafe-danger-zone-images")).toBe(false); + }); }); From f76b248caecafa8f641476b6eb1202c61a0994a8 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 22 Oct 2026 00:16:31 +0200 Subject: [PATCH 05/30] docs(ai,tui,coding-agent): audit changelogs since v1.3.0 Move two post-release entries out of the 1.3.0 sections, add missing entries, duplicate user-facing ai and tui changes into coding-agent, and add New Features. --- packages/ai/CHANGELOG.md | 4 +++- packages/coding-agent/CHANGELOG.md | 31 ++++++++++++++++++++++++++---- packages/durable/CHANGELOG.md | 2 +- packages/tui/CHANGELOG.md | 4 ++++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8f4ae87aa..ba6bb8573 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -30,9 +30,12 @@ - Fixed Cloudflare Workers AI classifier catalog data: Clef Flash now costs $0.038 per million input tokens with a 24,576-token context window, and Jev costs $0.042 per million input tokens instead of being listed as free. - Fixed whitespace-only tool output (for example a bare `\r\n` from bash) being sent as-is to OpenAI Completions, OpenAI Responses, and Google providers; it now uses the same placeholder as empty output, since some providers reject whitespace-only tool content with a 400 ([#8720](https://github.com/earendil-works/pi/issues/8720)) - Fixed automatic retry not triggering for gateway upstream failures such as `upstream_error: Upstream request failed`, `upstream_stream_read_error`, and `Upstream HTTP/2 stream failed` ([#10920](https://github.com/earendil-works/pi/issues/10920)) +- Fixed automatic retry not triggering when the Google Vertex OAuth token refresh fails with a transient network error such as `request to https://oauth2.googleapis.com/token failed, reason:` ([#10996](https://github.com/earendil-works/pi/issues/10996)) - Fixed automatic retry not triggering for `stream disconnected before completion` errors relayed by proxies that wrap the Codex CLI ([#11057](https://github.com/earendil-works/pi/issues/11057)) - Fixed automatic retry treating digits inside request ids or other numbers (for example `req_011CU503xyz` or `5242880 bytes`) as retryable HTTP status codes ([#11057](https://github.com/earendil-works/pi/issues/11057)) - Fixed OpenAI Responses deferred tool loads rewriting the top-level tool list, and losing the prompt cache, on every load after a tool removal. Models with `additional_tools` now declare all tools in `additional_tools` items, starting with one at the head of the input, so removals (announced as text) and redefinitions no longer rewrite the prefix. Tool search and Kimi tool messages rewrite the top-level tools once per removal or redeclaration and keep later loads anchored ([#10940](https://github.com/earendil-works/pi/issues/10940)) +- Fixed GitHub Copilot requests whose body exceeds Copilot's size limit (HTTP 413 `failed to parse request`, for example sessions with many large images) failing instead of being detected as context overflow. Any HTTP 413 now counts as context overflow, so callers can compact and retry ([#11016](https://github.com/earendil-works/pi/pull/11016) by [@petrroll](https://github.com/petrroll)) +- Fixed the `openai-codex` provider listing `gpt-5.5` and `gpt-5.3-codex-spark`, which the Codex backend rejects for ChatGPT accounts. Both models are removed ([#11173](https://github.com/earendil-works/pi/pull/11173) by [@lucasmeijer](https://github.com/lucasmeijer)) ## [1.3.0] - 2026-10-15 @@ -57,7 +60,6 @@ - Fixed OpenCode Go MiniMax M2.7 failing with "Model does not support this protocol" by using the Anthropic Messages API - Fixed automatic retry not triggering for gateway `Unable to forward upstream stream` errors ([#10920](https://github.com/earendil-works/pi/issues/10920)) - Fixed automatic retry not triggering for `The service is temporarily unavailable` errors, which also made compaction fail on the first attempt ([#10965](https://github.com/earendil-works/pi/issues/10965)) -- Fixed automatic retry not triggering when the Google Vertex OAuth token refresh fails with a transient network error such as `request to https://oauth2.googleapis.com/token failed, reason:` ([#10996](https://github.com/earendil-works/pi/issues/10996)) - Fixed OpenAI-compatible responses showing thinking after the answer when a proxy such as LiteLLM sends reasoning and content in the same stream chunk ([#10926](https://github.com/earendil-works/pi/issues/10926)) - Fixed Gemini 4 Argon requests on Google Vertex failing with "Thinking budget is not supported", and its thinking level being ignored on the Gemini API, by sending `thinkingLevel` for Gemini 4 and later models ([#10890](https://github.com/earendil-works/pi/issues/10890)) - Fixed Xiaomi MiMo models failing with a 400 at the `minimal` thinking level by sending only the documented thinking on/off toggle and exposing only the `off` and `high` levels ([#10818](https://github.com/earendil-works/pi/issues/10818)) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d7a680829..3e57fe346 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,17 +2,31 @@ ## [Unreleased] +### New Features + +- **Image input for classifiers**: codemode `models.classify()` accepts `images` for TypeSafe's Jev and Cloudflare's Clef models on `typesafe`, `cloudflare-workers-ai`, and OpenRouter. See [Use classifier models](docs/models.md#use-classifier-models) and [Codemode](docs/codemode.md#models). +- **Models Configuration reference**: every provider, model, model override, and compatibility field of `models.json`, generated from its schema. See [Models Configuration](docs/models-configuration.md). +- **Summaries without a fixed output cap**: compaction and branch summaries use the model's output limit, so long reasoning no longer makes them fail. See [Compaction](docs/compaction.md#compaction). +- **Codemode waits for a free worker**: parallel scripts and in-process subagents queue instead of failing, and `PI_CODEMODE_MAX_WORKERS` raises the limit. See [Codemode limits](docs/codemode.md#limits). +- **Rex terminal support**: OSC 8 hyperlinks, truecolor, and inline images. + ### Added - Added a Models Configuration reference generated from the `models.json` schema, listing all provider, model, model override, and compatibility fields ([#10790](https://github.com/earendil-works/pi/issues/10790)) - Codemode `models.classify()` now accepts `images` for TypeSafe's Jev and Cloudflare's Clef models on `typesafe`, `cloudflare-workers-ai`, and OpenRouter, and lists Clef Omni and Jev 1.14 on Workers AI. - Provider-level `compat` in `models.json` now also applies to classifier models, with the System One fields `imageInput` and `sendImageOptInHeader` for proxies that serve another service's classifiers. Extension classifier models accept `compat` too. +- Added `MiniMax-M3.1-Flash` to the `minimax` and `minimax-cn` providers, with thinking levels low through max ([#11121](https://github.com/earendil-works/pi/issues/11121)) +- Added support for the Rex terminal: OSC 8 hyperlinks, truecolor, and inline images ([#11131](https://github.com/earendil-works/pi/issues/11131), [#11150](https://github.com/earendil-works/pi/pull/11150) by [@petrroll](https://github.com/petrroll), [#11183](https://github.com/earendil-works/pi/pull/11183) by [@nihaals](https://github.com/nihaals)) ### Changed - Provider attribution headers now come from pi-ai's client identity. Extensions' `before_provider_headers` handlers no longer see the OpenRouter, NVIDIA NIM, and OpenCode attribution headers; returning `null` for one still removes it. Cloudflare requests send pi's detailed `User-Agent` instead of `pi-coding-agent`, as do GitHub release checks for tool downloads, and OpenCode requests send `x-opencode-client` whenever attribution is enabled, not only with a session. - `generateSummary()` and `generateSummaryWithUsage()` now ignore their `reserveTokens` parameter. Summary requests use the model's output limit instead. - Changed the default GitHub Copilot model from `gpt-5.4`, which Copilot retired, to `gpt-6.1-sol`. +- Changed the default NVIDIA model to `nvidia/nemotron-3.5-super-vl-120b-a12b` ([#10910](https://github.com/earendil-works/pi/pull/10910) by [@cv](https://github.com/cv)) +- Changed the default Xiaomi model to `mimo-v2.6-pro` for the `xiaomi` and Xiaomi Token Plan providers, since `mimo-v2.5-pro` left the catalog. +- Grok 4.3 and Grok 4.5 on the `xai` provider now offer the `xhigh` thinking level. +- Sign in with ChatGPT: ChatGPT Business workspace usage-limit errors are not retried and tell the user to ask their workspace admin to increase the limit. ### Fixed @@ -28,6 +42,19 @@ - Fixed `mcp.json` files that start with a UTF-8 BOM failing to parse, which skipped all their MCP servers and made `pi mcp add`/`pi mcp remove` fail ([#11191](https://github.com/earendil-works/pi/issues/11191)) - Fixed `/compact ` ignoring the instructions when summarizing the start of a turn that compaction splits. When the split turn was the only new content since the last compaction, the instructions were ignored entirely ([#11058](https://github.com/earendil-works/pi/issues/11058)) - Fixed resumed sessions and `/tree` navigation leaving extension tools inactive when the branch recorded its tools before they were registered, such as tools from extensions installed after the session started. Extension tools the branch never added or removed are now active unless they are registered with `defaultActive: false` ([#11062](https://github.com/earendil-works/pi/issues/11062), [#11075](https://github.com/earendil-works/pi/issues/11075)) +- Fixed sessions breaking after recovery from a response cut off at the output limit: the truncated tool call was omitted but its tool results were kept, so providers rejected every later request ([#11227](https://github.com/earendil-works/pi/issues/11227), [#11233](https://github.com/earendil-works/pi/pull/11233) by [@petrroll](https://github.com/petrroll)) +- Fixed the `grep` tool following `RIPGREP_CONFIG_PATH`, where options such as `--fixed-strings` silently changed regex searches into literal ones. The tool now runs `rg --no-config` ([#11203](https://github.com/earendil-works/pi/issues/11203), [#11230](https://github.com/earendil-works/pi/pull/11230) by [@petrroll](https://github.com/petrroll)) +- Fixed Windows shell detection rejecting runnable Microsoft Store shell aliases ([#9504](https://github.com/earendil-works/pi/pull/9504) by [@petrroll](https://github.com/petrroll)) +- Fixed Windows shell detection missing PowerShell outside `PATH` and the legacy WSL `bash.exe` launcher when Windows is not installed in `C:\Windows`. pi now checks `%ProgramFiles%\PowerShell\7`, `%SystemRoot%\System32\WindowsPowerShell\v1.0`, and `%SystemRoot%` ([#9501](https://github.com/earendil-works/pi/pull/9501) by [@petrroll](https://github.com/petrroll)) +- Fixed GitHub Copilot sessions with large request bodies, such as many images, failing with HTTP 413 `failed to parse request` instead of compacting and retrying ([#11016](https://github.com/earendil-works/pi/pull/11016) by [@petrroll](https://github.com/petrroll)) +- Fixed the `openai-codex` provider offering `gpt-5.5` and `gpt-5.3-codex-spark`, which always fail for ChatGPT accounts ([#11173](https://github.com/earendil-works/pi/pull/11173) by [@lucasmeijer](https://github.com/lucasmeijer)) +- Fixed whitespace-only tool output (for example a bare `\r\n` from bash) making OpenAI Completions, OpenAI Responses, and Google requests fail with a 400 ([#8720](https://github.com/earendil-works/pi/issues/8720)) +- Fixed automatic retry not triggering for more gateway upstream failures, `stream disconnected before completion` errors relayed by Codex CLI proxies, and transient Google Vertex OAuth token refresh failures, and retrying errors because of digits inside request ids or byte counts ([#10920](https://github.com/earendil-works/pi/issues/10920), [#11057](https://github.com/earendil-works/pi/issues/11057), [#10996](https://github.com/earendil-works/pi/issues/10996)) +- Fixed OpenAI Responses models losing the prompt cache on every deferred tool load after a tool was removed ([#10940](https://github.com/earendil-works/pi/issues/10940)) +- Fixed Cloudflare Workers AI classifier pricing: Clef Flash costs $0.038 and Jev $0.042 per million input tokens instead of Jev being listed as free. +- Fixed an unclosed `$$` display-math block swallowing or corrupting every Markdown table after it ([#11159](https://github.com/earendil-works/pi/issues/11159)) +- Fixed Markdown bold and italic not rendering next to CJK punctuation ([#10730](https://github.com/earendil-works/pi/pull/10730) by [@theBucky](https://github.com/theBucky)) +- Fixed `@` file autocomplete returning no results for queries containing regex metacharacters such as `[` or `(` ([#10972](https://github.com/earendil-works/pi/issues/10972)) ## [1.3.0] - 2026-10-15 @@ -49,10 +76,6 @@ - Changed the `kimi-coding` provider to use OpenAI Chat Completions at `https://api.kimi.com/coding/v1`, the protocol used by the official Kimi Code client. K3, K3-256K, and Kimi For Coding now accept tools added mid-conversation without invalidating the prompt cache. `models.json` overrides of the `kimi-coding` base URL must now include `/v1` ([#10720](https://github.com/earendil-works/pi/issues/10720)) - Changed model switching to drop thinking blocks from earlier assistant messages produced by a different model, instead of sending them as plain text. The new model previously saw the old model's reasoning as part of its replies and could start writing its own reasoning into the visible answer ([#10995](https://github.com/earendil-works/pi/issues/10995)) -### Changed - -- Changed the default NVIDIA model to `nvidia/nemotron-3.5-super-vl-120b-a12b` ([#10910](https://github.com/earendil-works/pi/pull/10910) by [@cv](https://github.com/cv)) - ### Fixed - Pinned internal Pi dependencies to exact release versions to prevent fresh installs from mixing incompatible packages ([#10865](https://github.com/earendil-works/pi/issues/10865)) diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 900351d0b..22b781327 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -33,13 +33,13 @@ - Generation retry backoffs, deferred-response polls, and compaction retry backoffs sleep instead of waiting in a running phase, so a process can stop or hibernate during them. A record an earlier version stored mid-backoff sleeps again when it resumes before its time. A deferred poll whose model is gone after a reopen now fails `no_model` once the poll is due, not at once. - Code mode measures script deadlines and wall time on the Harness clock (`HarnessOptions.now`) instead of `Date.now()`. - SQLite drivers, portable interfaces, and the generic migration runner now come from `@earendil-works/chord/sqlite`. Existing Durable SQLite entry points keep re-exporting their public names; storage helpers and Durable schema migrations stay here. - - `ToolControl.terminate` is now typed `boolean` instead of `true`, matching `pi-agent-core` and `pi-coding-agent`; `false` does not end the run ([#10858](https://github.com/earendil-works/pi/issues/10858)). ### Fixed - In Cloudflare Workers with `nodejs_compat`, the timers that evict idle documents and contexts were armed, since timers there have an `unref()`; a pending one kept a Durable Object from hibernating and blocked its eviction. They are now declined in Workers, and memory is freed at later commits. - The README's Errors section now limits the `StorageRequestError` exemption to reads: a `StorageRequestError` thrown by `commit()` or `mintId()` fails the Harness like any other storage error ([#11014](https://github.com/earendil-works/pi/issues/11014)). +- The Node environment now detects the legacy WSL `bash.exe` launcher from `%SystemRoot%` instead of assuming `C:\Windows` ([#9501](https://github.com/earendil-works/pi/pull/9501) by [@petrroll](https://github.com/petrroll)). ## [1.3.0] - 2026-10-15 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 9864a3d98..1fa23b66a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added detection of the Rex terminal (`TERM_PROGRAM=rex`) with OSC 8 hyperlinks, truecolor, and Kitty images. Links previously fell back to the `text (url)` format ([#11131](https://github.com/earendil-works/pi/issues/11131), [#11150](https://github.com/earendil-works/pi/pull/11150) by [@petrroll](https://github.com/petrroll), [#11183](https://github.com/earendil-works/pi/pull/11183) by [@nihaals](https://github.com/nihaals)) + ### Fixed - Fixed an unclosed `$$` display-math block swallowing or corrupting every Markdown table after it. A block-math token no longer spans a GFM table, and the streaming pending-math fallback no longer treats a following table as pending math ([#11159](https://github.com/earendil-works/pi/issues/11159)) From 18e56a069a88dab35137abd2249c2a7c4286f8fd Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 22 Oct 2026 00:20:24 +0200 Subject: [PATCH 06/30] fix(ai): expect compat on OpenRouter decision models in catalog test --- packages/ai/test/image-model-data.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ai/test/image-model-data.test.ts b/packages/ai/test/image-model-data.test.ts index 9b40486bb..21545fe8f 100644 --- a/packages/ai/test/image-model-data.test.ts +++ b/packages/ai/test/image-model-data.test.ts @@ -93,6 +93,7 @@ describe("OpenRouter catalog parsing", () => { input: ["text"], cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 32000, + compat: { imageInput: "state-image-urls", sendImageOptInHeader: false }, }, ]); }); From 4a080252f06caf63a525d7873c5d94d3c23887e0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 22 Oct 2026 00:29:13 +0200 Subject: [PATCH 07/30] Release v1.4.0 --- nix/model-catalog.json | 2 +- package-lock.json | 73 ++++++++----------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 4 +- packages/ai/src/utils/version.ts | 2 +- packages/chord/CHANGELOG.md | 2 +- packages/chord/package.json | 4 +- packages/codemode/CHANGELOG.md | 2 +- packages/codemode/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/gondolin/package-lock.json | 4 +- .../examples/extensions/gondolin/package.json | 2 +- .../extensions/sandbox/package-lock.json | 4 +- .../examples/extensions/sandbox/package.json | 2 +- .../extensions/with-deps/package-lock.json | 4 +- .../extensions/with-deps/package.json | 2 +- .../install-lock/package-lock.json | 48 ++++++------ .../coding-agent/install-lock/package.json | 4 +- packages/coding-agent/package.json | 12 +-- packages/durable/CHANGELOG.md | 2 +- packages/durable/package.json | 10 +-- packages/env/CHANGELOG.md | 2 +- packages/env/package.json | 6 +- packages/evals/package.json | 6 +- packages/mcp/CHANGELOG.md | 2 +- packages/mcp/package.json | 2 +- packages/telemetry/CHANGELOG.md | 2 +- packages/telemetry/package.json | 2 +- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 35 files changed, 110 insertions(+), 119 deletions(-) diff --git a/nix/model-catalog.json b/nix/model-catalog.json index a30c08655..3c31c2cca 100644 --- a/nix/model-catalog.json +++ b/nix/model-catalog.json @@ -1,3 +1,3 @@ { - "revision": "sha256-ffa3e98626230f378f80200debb736c4a2238b5f2ba246a92cfc7d8e19604678" + "revision": "sha256-7fe1b41feaf43a7aad4bc832e6b306c7ced56e6d41bbc724b74e91eeacff6edf" } diff --git a/package-lock.json b/package-lock.json index 51984d649..898afd386 100644 --- a/package-lock.json +++ b/package-lock.json @@ -731,15 +731,6 @@ "node": ">=16" } }, - "node_modules/@cloudflare/workers-types": { - "version": "5.20261016.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20261016.1.tgz", - "integrity": "sha512-I8OJdPl3M6TIl9ksU3wtEyWVhSE8E8txJ/vGOv3SkhjcvN8uMjyFmMvRsNOZgzMe5xYFQ0LctmiGqTwR7DbZ6w==", - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "peer": true - }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -6092,10 +6083,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "1.3.0", + "@earendil-works/pi-ai": "1.4.0", "typebox": "1.3.27" }, "devDependencies": { @@ -6115,12 +6106,12 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/pi-telemetry": "1.4.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", @@ -6203,10 +6194,10 @@ }, "packages/chord": { "name": "@earendil-works/chord", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/pi-telemetry": "1.4.0", "typebox": "1.3.27" }, "devDependencies": { @@ -6221,7 +6212,7 @@ }, "packages/codemode": { "name": "@earendil-works/pi-codemode", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "quickjs-wasi": "3.6.2" @@ -6236,14 +6227,14 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "1.3.0", - "@earendil-works/pi-ai": "1.3.0", - "@earendil-works/pi-codemode": "1.3.0", - "@earendil-works/pi-mcp": "1.3.0", - "@earendil-works/pi-tui": "1.3.0", + "@earendil-works/pi-agent-core": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-codemode": "1.4.0", + "@earendil-works/pi-mcp": "1.4.0", + "@earendil-works/pi-tui": "1.4.0", "@silvia-odwyer/photon-node": "0.3.4", "brace-expansion": "5.0.12", "chalk": "6.0.0", @@ -6281,32 +6272,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "1.3.0" + "version": "1.4.0" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "ms": "2.1.3" }, @@ -6360,13 +6351,13 @@ }, "packages/durable": { "name": "@earendil-works/pi-durable", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@earendil-works/chord": "1.3.0", - "@earendil-works/pi-ai": "1.3.0", - "@earendil-works/pi-codemode": "1.3.0", - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/chord": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-codemode": "1.4.0", + "@earendil-works/pi-telemetry": "1.4.0", "@silvia-odwyer/photon": "0.3.3", "diff": "8.0.4", "typebox": "1.3.27" @@ -6383,11 +6374,11 @@ }, "packages/env": { "name": "@earendil-works/pi-env", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@earendil-works/chord": "1.3.0", - "@earendil-works/pi-durable": "1.3.0" + "@earendil-works/chord": "1.4.0", + "@earendil-works/pi-durable": "1.4.0" }, "devDependencies": { "shx": "0.3.4", @@ -6399,10 +6390,10 @@ }, "packages/evals": { "name": "@earendil-works/pi-evals", - "version": "1.3.0", + "version": "1.4.0", "devDependencies": { - "@earendil-works/pi-ai": "1.3.0", - "@earendil-works/pi-coding-agent": "1.3.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-coding-agent": "1.4.0", "@types/node": "22.19.19", "@vitest-evals/core": "0.15.0", "autoevals": "0.3.0", @@ -6413,7 +6404,7 @@ }, "packages/mcp": { "name": "@earendil-works/pi-mcp", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -6429,7 +6420,7 @@ }, "packages/telemetry": { "name": "@earendil-works/pi-telemetry", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "devDependencies": { "@types/node": "22.19.19", @@ -6441,7 +6432,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 0cf8c6a48..5b970f8d0 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.4.0] - 2026-10-21 ### Added diff --git a/packages/agent/package.json b/packages/agent/package.json index 30943697d..ec1838680 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "1.3.0", + "version": "1.4.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -23,7 +23,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "1.3.0", + "@earendil-works/pi-ai": "1.4.0", "typebox": "1.3.27" }, "keywords": [ diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ba6bb8573..41016e4f0 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/ai/package.json b/packages/ai/package.json index 07f546f97..debb553dd 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "1.3.0", + "version": "1.4.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", @@ -69,7 +69,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/pi-telemetry": "1.4.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", diff --git a/packages/ai/src/utils/version.ts b/packages/ai/src/utils/version.ts index e2741c484..18fe5a582 100644 --- a/packages/ai/src/utils/version.ts +++ b/packages/ai/src/utils/version.ts @@ -1,2 +1,2 @@ // The @earendil-works/pi-ai package version. scripts/sync-versions.js keeps it in sync with package.json. -export const PI_AI_VERSION = "1.3.0"; +export const PI_AI_VERSION = "1.4.0"; diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 59ea796d3..0eeae64b0 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/chord/package.json b/packages/chord/package.json index 99878e741..9845c7594 100644 --- a/packages/chord/package.json +++ b/packages/chord/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/chord", - "version": "1.3.0", + "version": "1.4.0", "description": "Typed host runtime for plugins, contracts, replicated state, and RPC over JSON links", "type": "module", "main": "./dist/index.js", @@ -85,7 +85,7 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/pi-telemetry": "1.4.0", "typebox": "1.3.27" }, "devDependencies": { diff --git a/packages/codemode/CHANGELOG.md b/packages/codemode/CHANGELOG.md index cc5703ddc..a24c61c09 100644 --- a/packages/codemode/CHANGELOG.md +++ b/packages/codemode/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.4.0] - 2026-10-21 ## [1.3.0] - 2026-10-15 diff --git a/packages/codemode/package.json b/packages/codemode/package.json index dd1c22fd5..b653b4fcd 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-codemode", - "version": "1.3.0", + "version": "1.4.0", "description": "Sandboxed JavaScript execution where the only capability is calling injected tools", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3e57fe346..eaa3076af 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.4.0] - 2026-10-21 ### New Features diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 7a9cc6902..b7d2ebab0 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index 4f3d296a5..b22368863 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index f6fff9cf9..dbdc63c72 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index 9ad047db2..cacaab7b9 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@earendil-works/gondolin": "0.12.0" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index 76826f308..813068135 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index fbe7ec7c6..90ef1c2f5 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index 5d81664e1..8059d577d 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index ba9a0ce51..90c4e6339 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 846881008..f22d436c7 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index e43090842..5d242776c 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent-install", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { - "@earendil-works/pi-coding-agent": "1.3.0" + "@earendil-works/pi-coding-agent": "1.4.0" }, "engines": { "node": ">=22.19.0" @@ -386,11 +386,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.4.0.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "1.3.0", + "@earendil-works/pi-ai": "1.4.0", "typebox": "1.3.27" }, "engines": { @@ -398,13 +398,13 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.4.0.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/pi-telemetry": "1.4.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", @@ -481,8 +481,8 @@ } }, "node_modules/@earendil-works/pi-codemode": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.4.0.tgz", "license": "MIT", "dependencies": { "quickjs-wasi": "3.6.2" @@ -492,15 +492,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.4.0.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "1.3.0", - "@earendil-works/pi-ai": "1.3.0", - "@earendil-works/pi-codemode": "1.3.0", - "@earendil-works/pi-mcp": "1.3.0", - "@earendil-works/pi-tui": "1.3.0", + "@earendil-works/pi-agent-core": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-codemode": "1.4.0", + "@earendil-works/pi-mcp": "1.4.0", + "@earendil-works/pi-tui": "1.4.0", "@silvia-odwyer/photon-node": "0.3.4", "brace-expansion": "5.0.12", "chalk": "6.0.0", @@ -527,8 +527,8 @@ } }, "node_modules/@earendil-works/pi-mcp": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.4.0.tgz", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -538,16 +538,16 @@ } }, "node_modules/@earendil-works/pi-telemetry": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.4.0.tgz", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.4.0.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index 4f9d4f9aa..004cf3c90 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "1.3.0", + "version": "1.4.0", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { - "@earendil-works/pi-coding-agent": "1.3.0" + "@earendil-works/pi-coding-agent": "1.4.0" }, "overrides": { "protobufjs": "7.6.6" diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 33ac1bcfd..e0955b0e7 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "1.3.0", + "version": "1.4.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -41,11 +41,11 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-agent-core": "1.3.0", - "@earendil-works/pi-ai": "1.3.0", - "@earendil-works/pi-codemode": "1.3.0", - "@earendil-works/pi-mcp": "1.3.0", - "@earendil-works/pi-tui": "1.3.0", + "@earendil-works/pi-agent-core": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-codemode": "1.4.0", + "@earendil-works/pi-mcp": "1.4.0", + "@earendil-works/pi-tui": "1.4.0", "@silvia-odwyer/photon-node": "0.3.4", "brace-expansion": "5.0.12", "chalk": "6.0.0", diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 22b781327..689b8f2ec 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/durable/package.json b/packages/durable/package.json index c15e96ad4..f5756989c 100644 --- a/packages/durable/package.json +++ b/packages/durable/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-durable", - "version": "1.3.0", + "version": "1.4.0", "description": "Durable conversation, task, and document runtime for Pi", "type": "module", "main": "./dist/index.js", @@ -150,10 +150,10 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/chord": "1.3.0", - "@earendil-works/pi-ai": "1.3.0", - "@earendil-works/pi-codemode": "1.3.0", - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/chord": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-codemode": "1.4.0", + "@earendil-works/pi-telemetry": "1.4.0", "@silvia-odwyer/photon": "0.3.3", "diff": "8.0.4", "typebox": "1.3.27" diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index ea7328e7c..7792c0248 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.4.0] - 2026-10-21 ## [1.3.0] - 2026-10-15 diff --git a/packages/env/package.json b/packages/env/package.json index f86276de2..2ed4d5499 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-env", - "version": "1.3.0", + "version": "1.4.0", "description": "Remote execution environments for Pi Durable: a small daemon deployed over SSH and its ExecutionEnv client", "type": "module", "main": "./dist/index.js", @@ -45,8 +45,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/chord": "1.3.0", - "@earendil-works/pi-durable": "1.3.0" + "@earendil-works/chord": "1.4.0", + "@earendil-works/pi-durable": "1.4.0" }, "devDependencies": { "shx": "0.3.4", diff --git a/packages/evals/package.json b/packages/evals/package.json index 50554274b..843677914 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-evals", - "version": "1.3.0", + "version": "1.4.0", "private": true, "type": "module", "scripts": { @@ -11,8 +11,8 @@ "test": "vitest run --config vitest.test.config.ts" }, "devDependencies": { - "@earendil-works/pi-ai": "1.3.0", - "@earendil-works/pi-coding-agent": "1.3.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-coding-agent": "1.4.0", "@types/node": "22.19.19", "@vitest-evals/core": "0.15.0", "autoevals": "0.3.0", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 27b3b21b5..47fe44917 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.4.0] - 2026-10-21 ## [1.3.0] - 2026-10-15 diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 7eca8849a..518515ea4 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-mcp", - "version": "1.3.0", + "version": "1.4.0", "description": "Standalone Model Context Protocol client for pi and other applications", "type": "module", "main": "./dist/index.js", diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index 47c7bdc48..38ab8df3b 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 1fb122650..e9eee2555 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-telemetry", - "version": "1.3.0", + "version": "1.4.0", "description": "Vendor-neutral telemetry contracts and typed schema utilities for pi", "type": "module", "main": "./dist/index.js", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1fa23b66a..196238dec 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.4.0] - 2026-10-21 ### Added diff --git a/packages/tui/package.json b/packages/tui/package.json index 6ae4a20f4..31dc4744a 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "1.3.0", + "version": "1.4.0", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 58c0b02ce552adab76014e7ed712ab7981359aa8 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 22 Oct 2026 00:29:24 +0200 Subject: [PATCH 08/30] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/chord/CHANGELOG.md | 2 ++ packages/codemode/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/durable/CHANGELOG.md | 2 ++ packages/env/CHANGELOG.md | 2 ++ packages/mcp/CHANGELOG.md | 2 ++ packages/telemetry/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 10 files changed, 20 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 5b970f8d0..b5367030f 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [1.4.0] - 2026-10-21 ### Added diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 41016e4f0..0c9fcee1d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 0eeae64b0..5a2aca4b6 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/codemode/CHANGELOG.md b/packages/codemode/CHANGELOG.md index a24c61c09..decd59f9e 100644 --- a/packages/codemode/CHANGELOG.md +++ b/packages/codemode/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [1.4.0] - 2026-10-21 ## [1.3.0] - 2026-10-15 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eaa3076af..564f8b0b9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [1.4.0] - 2026-10-21 ### New Features diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 689b8f2ec..f764683f0 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index 7792c0248..4d2a1c0e1 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [1.4.0] - 2026-10-21 ## [1.3.0] - 2026-10-15 diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 47fe44917..da6dedffd 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [1.4.0] - 2026-10-21 ## [1.3.0] - 2026-10-15 diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index 38ab8df3b..b71b6c86e 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 196238dec..da21edae9 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [1.4.0] - 2026-10-21 ### Added From 7c22a93d0ed02092142fa63823e63d76aa4e2406 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 22 Oct 2026 09:02:01 +0200 Subject: [PATCH 09/30] feat(ai): add totalFromProvider flag to usage cost (#11178) Add Usage.cost.totalFromProvider to record whether cost.total is the billed cost reported by the provider or an estimate from catalog rates. Catalog-derived totals set it to false; a missing value also means false. combineUsage marks a sum as provider-reported only when both parts are. --- packages/ai/README.md | 2 ++ packages/ai/src/api/openrouter-images.ts | 1 + packages/ai/src/models.ts | 1 + packages/ai/src/types.ts | 2 ++ packages/ai/test/models-runtime.test.ts | 13 ++++++++ .../coding-agent/src/core/usage-totals.ts | 2 ++ .../test/compaction-summary-reasoning.test.ts | 2 +- .../coding-agent/test/usage-totals.test.ts | 30 +++++++++++++++++++ 8 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/usage-totals.test.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index 7728ba6a4..88b961cee 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -233,6 +233,8 @@ for (const block of response.content) { } ``` +`usage.cost.totalFromProvider` is `true` when `usage.cost.total` is the billed cost reported by the provider and `false` when it is estimated from the model's catalog rates. A missing value, as in sessions recorded before this field existed, also means `false`. + Snippets in the rest of this README assume a `models` collection set up like this (with the relevant providers registered). ## Providers and Models diff --git a/packages/ai/src/api/openrouter-images.ts b/packages/ai/src/api/openrouter-images.ts index be9c37007..6054c7238 100644 --- a/packages/ai/src/api/openrouter-images.ts +++ b/packages/ai/src/api/openrouter-images.ts @@ -193,6 +193,7 @@ function parseUsage( cacheRead: (model.cost.cacheRead / 1000000) * cacheReadTokens, cacheWrite: (model.cost.cacheWrite / 1000000) * cacheWriteTokens, total: 0, + totalFromProvider: false, }, }; usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index ac0396f90..3d26dcb5f 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -1252,6 +1252,7 @@ export function calculateCost(model: AnyModel, usage: Usage): Usage["cost"] { usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead; usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000; usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; + usage.cost.totalFromProvider = false; return usage.cost; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index c0f3b5627..3470ef327 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -476,6 +476,8 @@ export interface Usage { cacheRead: number; cacheWrite: number; total: number; + /** True when `total` is the billed cost reported by the provider. False when it is a catalog estimate; absent also means false. */ + totalFromProvider?: boolean; }; } diff --git a/packages/ai/test/models-runtime.test.ts b/packages/ai/test/models-runtime.test.ts index 0789f940c..fab8d11f4 100644 --- a/packages/ai/test/models-runtime.test.ts +++ b/packages/ai/test/models-runtime.test.ts @@ -159,6 +159,19 @@ describe("Models runtime", () => { expect(long.cacheWrite).toBe(0.0000125); }); + it("marks catalog-derived totals as not provider-reported", () => { + const usage: Usage = { + input: 1_000_000, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1_000_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 9, totalFromProvider: true }, + }; + calculateCost(testModel("openai", "gpt-5.6-sol"), usage); + expect(usage.cost.totalFromProvider).toBe(false); + }); + it("registers, replaces, and deletes providers", () => { const models = createModels(); models.setProvider(testProvider({ id: "p1" })); diff --git a/packages/coding-agent/src/core/usage-totals.ts b/packages/coding-agent/src/core/usage-totals.ts index 6bcafbd47..fdc406bcb 100644 --- a/packages/coding-agent/src/core/usage-totals.ts +++ b/packages/coding-agent/src/core/usage-totals.ts @@ -47,6 +47,8 @@ export function combineUsage(first: Usage, second: Usage): Usage { cacheRead: first.cost.cacheRead + second.cost.cacheRead, cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite, total: first.cost.total + second.cost.total, + // A sum is provider-reported only when every part is. + totalFromProvider: first.cost.totalFromProvider === true && second.cost.totalFromProvider === true, }, }; } diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index cf3aca03a..a18c5ace2 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -314,7 +314,7 @@ describe("generateSummary reasoning options", () => { input: 20, output: 20, totalTokens: 40, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, totalFromProvider: false }, }); }); }); diff --git a/packages/coding-agent/test/usage-totals.test.ts b/packages/coding-agent/test/usage-totals.test.ts new file mode 100644 index 000000000..d759bf9ba --- /dev/null +++ b/packages/coding-agent/test/usage-totals.test.ts @@ -0,0 +1,30 @@ +import type { Usage } from "@earendil-works/pi-ai/compat"; +import { describe, expect, it } from "vitest"; +import { combineUsage } from "../src/core/usage-totals.ts"; + +function usage(total: number, totalFromProvider?: boolean): Usage { + return { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total, + ...(totalFromProvider === undefined ? {} : { totalFromProvider }), + }, + }; +} + +describe("combineUsage", () => { + it("marks the combined total as provider-reported only when both totals are", () => { + expect(combineUsage(usage(1, true), usage(2, true)).cost).toMatchObject({ total: 3, totalFromProvider: true }); + expect(combineUsage(usage(1, true), usage(2)).cost.totalFromProvider).toBe(false); + expect(combineUsage(usage(1), usage(2, true)).cost.totalFromProvider).toBe(false); + expect(combineUsage(usage(1), usage(2)).cost.totalFromProvider).toBe(false); + }); +}); From 5884ee6816e7a89cf93c3b67b235c605352d6ae7 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Thu, 22 Oct 2026 10:58:13 +0200 Subject: [PATCH 10/30] feat(coding-agent): let /ir import a session file from a .jsonl URL --- .pi/extensions/import-repro.ts | 38 +++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/.pi/extensions/import-repro.ts b/.pi/extensions/import-repro.ts index cb11ca049..94dc4197f 100644 --- a/.pi/extensions/import-repro.ts +++ b/.pi/extensions/import-repro.ts @@ -1,16 +1,18 @@ /** * Import a pi session shared as a gist by the issue-analysis CI workflow - * (.github/workflows/issue-analysis.yml) and switch to it. + * (.github/workflows/issue-analysis.yml), or a .jsonl session file served at a URL, + * and switch to it. * - * The CI job runs in a high-entropy checkout directory; this command rewrites - * the recorded cwd to the local checkout, installs the session file into the - * current session directory, and switches to it. + * The session ran in another checkout (the CI job's high-entropy directory, or a + * research VM); this command rewrites the recorded cwd to the local checkout, + * installs the session file into the current session directory, and switches to it. * * Usage: * /ir b4d100022aefb12f25dd2d8485e0a82a * /ir https://gist.github.com/mitsuhiko/b4d100022aefb12f25dd2d8485e0a82a * /ir https://pi.dev/session/#b4d100022aefb12f25dd2d8485e0a82a * /ir https://github.com/earendil-works/pi/issues/123 + * /ir https://example.com/path/session.jsonl * * pi "/ir " */ @@ -58,11 +60,11 @@ interface IssueComment { function parseRef( ref: string, cwd: string, -): { type: "gist"; id: string } | { type: "file"; path: string } | { type: "issue"; owner: string; repo: string; issue: string } { - if (ref.endsWith(".html") || ref.endsWith(".jsonl")) { - return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) }; - } - +): + | { type: "gist"; id: string } + | { type: "file"; path: string } + | { type: "issue"; owner: string; repo: string; issue: string } + | { type: "url"; url: URL } { const shareMatch = ref.match(SHARE_URL_RE); if (shareMatch) return { type: "gist", id: shareMatch[1] }; @@ -72,9 +74,17 @@ function parseRef( const issueMatch = ref.match(ISSUE_URL_RE); if (issueMatch) return { type: "issue", owner: issueMatch[1], repo: issueMatch[2], issue: issueMatch[3] }; + if (/^https?:\/\/.*\.jsonl$/.test(ref)) return { type: "url", url: new URL(ref) }; + + if (ref.endsWith(".html") || ref.endsWith(".jsonl")) { + return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) }; + } + if (GIST_ID_RE.test(ref)) return { type: "gist", id: ref }; - throw new Error(`expected a gist ID, gist URL, pi.dev share URL, issue URL, .html file, or .jsonl file: ${ref}`); + throw new Error( + `expected a gist ID, gist URL, pi.dev share URL, issue URL, .jsonl URL, .html file, or .jsonl file: ${ref}`, + ); } function parseSessionJsonl(raw: string): { header: SessionHeader; jsonl: string } { @@ -282,11 +292,12 @@ async function fetchGistSession(gistId: string): Promise<{ header: SessionHeader export default function (pi: ExtensionAPI) { pi.registerCommand("ir", { - description: "Import a CI issue-analysis session from a gist ID, share URL, or issue URL and switch to it", + description: + "Import a CI issue-analysis session from a gist ID, share URL, issue URL, or .jsonl URL and switch to it", handler: async (args: string, ctx: ExtensionCommandContext) => { const ref = args.trim(); if (!ref) { - ctx.ui.notify("Usage: /ir ", "error"); + ctx.ui.notify("Usage: /ir ", "error"); return; } @@ -306,6 +317,9 @@ export default function (pi: ExtensionAPI) { const gistId = await findIssueGistId(parsedRef.owner, parsedRef.repo, parsedRef.issue); decoded = await fetchGistSession(gistId); sourceName = `${gistId}.jsonl`; + } else if (parsedRef.type === "url") { + decoded = parseSessionJsonl(await fetchText(parsedRef.url.href)); + sourceName = basename(parsedRef.url.pathname); } else { if (!existsSync(parsedRef.path)) throw new Error(`session file not found: ${parsedRef.path}`); const raw = readFileSync(parsedRef.path, "utf8"); From cae8d69e572e038d584c881efd1818e40571fb7c Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 22 Oct 2026 11:01:39 +0200 Subject: [PATCH 11/30] fix(ai): use OpenRouter-reported total cost (#10286) Use authoritative OpenRouter billing for Chat Completions and Anthropic Messages while retaining catalog component estimates and fallback behavior. Fixes #9980 --- packages/ai/README.md | 2 + packages/ai/src/api/anthropic-messages.ts | 13 ++++ packages/ai/src/api/openai-completions.ts | 10 +++ packages/ai/src/types.ts | 5 ++ packages/ai/src/utils/openrouter-cost.ts | 55 +++++++++++++ .../ai/test/anthropic-sse-parsing.test.ts | 36 +++++++++ ...-completions-provider-stream-event.test.ts | 65 +++++++++++++++- packages/ai/test/openrouter-cost.test.ts | 78 +++++++++++++++++++ .../src/core/export-html/template.js | 12 ++- .../test/export-html-cost.test.ts | 52 +++++++++++++ 10 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 packages/ai/src/utils/openrouter-cost.ts create mode 100644 packages/ai/test/openrouter-cost.test.ts create mode 100644 packages/coding-agent/test/export-html-cost.test.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index 88b961cee..61038c7cf 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -237,6 +237,8 @@ for (const block of response.content) { Snippets in the rest of this README assume a `models` collection set up like this (with the relevant providers registered). +`usage.cost.total` is the best available total cost. For OpenRouter chat responses, it uses the cost reported by OpenRouter, including both the OpenRouter charge and upstream inference cost for BYOK requests. When provider accounting is unavailable or invalid, it falls back to the model's catalog rates. The `input`, `output`, `cacheRead`, and `cacheWrite` cost components remain catalog estimates and may not sum to a provider-reported total. + ## Providers and Models A **provider** is the runtime unit: it owns its model catalog, its auth (API key resolution, OAuth flows), and its stream behavior. A `Models` collection holds providers and routes every request to the provider that owns the model. diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 2754794c3..761ccd1e9 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -42,6 +42,11 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts"; import { parseJsonWithRepair, parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts"; +import { + applyOpenRouterReportedCost, + isOpenRouterModel, + type OpenRouterUsageAccounting, +} from "../utils/openrouter-cost.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -888,6 +893,14 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost(usageModel, output.usage); + if (isOpenRouterModel(model) && event.usage) { + // OpenRouter adds billing fields to streamed message_delta usage at runtime, + // although its OpenAPI schema and the Anthropic SDK do not type those fields. + applyOpenRouterReportedCost( + output.usage, + event.usage as typeof event.usage & OpenRouterUsageAccounting, + ); + } } } diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 083b6dc8f..a752d304e 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -41,6 +41,11 @@ import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts"; import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts"; +import { + applyOpenRouterReportedCost, + isOpenRouterModel, + type OpenRouterUsageAccounting, +} from "../utils/openrouter-cost.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -1527,6 +1532,8 @@ function convertTools( } function parseChunkUsage( + // OpenRouter adds billing fields to streamed usage at runtime, + // although the OpenAI SDK does not type those fields. rawUsage: { prompt_tokens?: number; completion_tokens?: number; @@ -1566,6 +1573,9 @@ function parseChunkUsage( cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; calculateCost(model, usage); + if (isOpenRouterModel(model)) { + applyOpenRouterReportedCost(usage, rawUsage as typeof rawUsage & OpenRouterUsageAccounting); + } return usage; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 3470ef327..d1fcee314 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -471,10 +471,15 @@ export interface Usage { reasoning?: number; totalTokens: number; cost: { + /** Catalog-derived input cost. */ input: number; + /** Catalog-derived output cost. */ output: number; + /** Catalog-derived cache-read cost. */ cacheRead: number; + /** Catalog-derived cache-write cost. */ cacheWrite: number; + /** Best available total: provider-reported billed cost when supported, otherwise the catalog estimate. */ total: number; /** True when `total` is the billed cost reported by the provider. False when it is a catalog estimate; absent also means false. */ totalFromProvider?: boolean; diff --git a/packages/ai/src/utils/openrouter-cost.ts b/packages/ai/src/utils/openrouter-cost.ts new file mode 100644 index 000000000..0eb12613e --- /dev/null +++ b/packages/ai/src/utils/openrouter-cost.ts @@ -0,0 +1,55 @@ +import type { Usage } from "../types.ts"; + +export interface OpenRouterUsageAccounting { + /** Amount charged to the user's OpenRouter account. */ + cost: number | null; + /** Whether OpenRouter used the user's upstream provider key. */ + is_byok: boolean; + cost_details: { + /** Total inference cost billed through the user's upstream provider account, when available. */ + upstream_inference_cost: number | null; + /** Upstream cost for prompt processing, including any provider-side cache pricing. */ + upstream_inference_prompt_cost: number; + /** Upstream cost for completion generation. */ + upstream_inference_completions_cost: number; + /** Metered server-tool cost already included in OpenRouter's charged `cost`. */ + server_tool_cost?: number | null; + } | null; +} + +function isValidCost(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +export function isOpenRouterModel(model: { provider: string; baseUrl: string }): boolean { + return model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai"); +} + +/** Replace the catalog total with OpenRouter's billed cost when the response contains complete, valid accounting. */ +export function applyOpenRouterReportedCost(usage: Usage, reported: OpenRouterUsageAccounting): void { + if (!isValidCost(reported.cost)) return; + + if (reported.is_byok === false) { + usage.cost.total = reported.cost; + usage.cost.totalFromProvider = true; + return; + } + + if (reported.is_byok === true) { + const details = reported.cost_details; + if (!details) return; + let upstreamCost: unknown = details.upstream_inference_cost; + if (upstreamCost === null) { + if ( + !isValidCost(details.upstream_inference_prompt_cost) || + !isValidCost(details.upstream_inference_completions_cost) + ) { + return; + } + upstreamCost = details.upstream_inference_prompt_cost + details.upstream_inference_completions_cost; + } + if (!isValidCost(upstreamCost)) return; + usage.cost.total = reported.cost + upstreamCost; + usage.cost.totalFromProvider = true; + } +} diff --git a/packages/ai/test/anthropic-sse-parsing.test.ts b/packages/ai/test/anthropic-sse-parsing.test.ts index 66f4b3192..9f65f831a 100644 --- a/packages/ai/test/anthropic-sse-parsing.test.ts +++ b/packages/ai/test/anthropic-sse-parsing.test.ts @@ -139,6 +139,42 @@ describe("Anthropic raw SSE parsing", () => { expect(eventModels).toEqual([model, model, model, model, model, model]); }); + // Regression test for #9980. + it("uses the total reported by OpenRouter's Anthropic Messages endpoint", async () => { + const events = minimalAnthropicEvents.map((event) => ({ ...event })); + const delta = JSON.parse(events[4].data) as { + usage: Record; + }; + // These fields were captured from a production OpenRouter stream even though its + // MessagesDeltaEvent OpenAPI schema and the Anthropic SDK omit them. + delta.usage = { + ...delta.usage, + cost: 0.004, + is_byok: false, + cost_details: { upstream_inference_cost: 0.003 }, + }; + events[4].data = JSON.stringify(delta); + + const model = { + ...getModel("openrouter", "anthropic/claude-haiku-4.5"), + provider: "openrouter-work", + }; + const result = await streamAnthropic( + model, + normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }), + { client: createFakeAnthropicClient(createSseResponse(events)) }, + ).result(); + + expect(result.usage.cost.total).toBe(0.004); + expect(result.usage.cost.totalFromProvider).toBe(true); + expect( + result.usage.cost.input + + result.usage.cost.output + + result.usage.cost.cacheRead + + result.usage.cost.cacheWrite, + ).not.toBe(result.usage.cost.total); + }); + it("keeps signed thinking replayable when a proxy relabels the model", async () => { // Regression test for earendil-works/pi#9188. const model = getModel("anthropic", "claude-opus-5"); diff --git a/packages/ai/test/openai-completions-provider-stream-event.test.ts b/packages/ai/test/openai-completions-provider-stream-event.test.ts index b025599d3..bc7415f4a 100644 --- a/packages/ai/test/openai-completions-provider-stream-event.test.ts +++ b/packages/ai/test/openai-completions-provider-stream-event.test.ts @@ -34,12 +34,12 @@ vi.mock("openai", () => { return { default: FakeOpenAI }; }); -function openRouterModel(): Model<"openai-completions"> { +function openRouterModel(provider = "openrouter"): Model<"openai-completions"> { return { id: "openrouter/auto", name: "OpenRouter Auto", api: "openai-completions", - provider: "openrouter", + provider, baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], @@ -91,4 +91,65 @@ describe("openai-completions provider stream events", () => { expect(message.content).toEqual([{ type: "text", text: "hello" }]); expect(events).toEqual([firstChunk, finalChunk]); }); + + // Regression test for #9980. + it("uses OpenRouter's reported total without double-counting non-BYOK upstream cost", async () => { + mockState.chunks = [ + { + id: "chatcmpl-cost", + model: "openrouter/auto", + choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }], + // These billing fields were captured from a production OpenRouter stream + // even though the OpenAI SDK omits them from CompletionUsage. + usage: { + prompt_tokens: 100, + completion_tokens: 20, + cost: 0.004, + is_byok: false, + cost_details: { upstream_inference_cost: 0.003 }, + }, + }, + ]; + + const message = await completeSimple( + openRouterModel("openrouter-work"), + { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, + { apiKey: "test" }, + ); + + expect(message.usage.cost).toEqual({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0.004, + totalFromProvider: true, + }); + }); + + it("includes upstream inference cost for OpenRouter BYOK requests", async () => { + mockState.chunks = [ + { + id: "chatcmpl-byok", + model: "openrouter/auto", + choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }], + usage: { + prompt_tokens: 100, + completion_tokens: 20, + cost: 0.0002, + is_byok: true, + cost_details: { upstream_inference_cost: 0.003 }, + }, + }, + ]; + + const message = await completeSimple( + openRouterModel(), + { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, + { apiKey: "test" }, + ); + + expect(message.usage.cost.total).toBeCloseTo(0.0032, 10); + expect(message.usage.cost.totalFromProvider).toBe(true); + }); }); diff --git a/packages/ai/test/openrouter-cost.test.ts b/packages/ai/test/openrouter-cost.test.ts new file mode 100644 index 000000000..dd9bc5069 --- /dev/null +++ b/packages/ai/test/openrouter-cost.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import type { Usage } from "../src/types.ts"; +import { applyOpenRouterReportedCost, isOpenRouterModel } from "../src/utils/openrouter-cost.ts"; + +function usage(total = 1): Usage { + return { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0.25, output: 0.75, cacheRead: 0, cacheWrite: 0, total }, + }; +} + +describe("isOpenRouterModel", () => { + it("recognizes both the built-in provider and custom providers using OpenRouter's endpoint", () => { + expect(isOpenRouterModel({ provider: "openrouter", baseUrl: "https://example.com" })).toBe(true); + expect(isOpenRouterModel({ provider: "openrouter-work", baseUrl: "https://openrouter.ai/api/v1" })).toBe(true); + expect(isOpenRouterModel({ provider: "custom", baseUrl: "https://example.com/v1" })).toBe(false); + }); +}); + +describe("applyOpenRouterReportedCost", () => { + it("uses OpenRouter's reported cost for non-BYOK requests", () => { + const value = usage(); + applyOpenRouterReportedCost(value, { + cost: 0, + is_byok: false, + cost_details: { + upstream_inference_cost: 0, + upstream_inference_prompt_cost: 0, + upstream_inference_completions_cost: 0, + }, + }); + expect(value.cost.total).toBe(0); + expect(value.cost.totalFromProvider).toBe(true); + }); + + it("adds the upstream inference cost for BYOK requests", () => { + const value = usage(); + applyOpenRouterReportedCost(value, { + cost: 0.1, + is_byok: true, + cost_details: { + upstream_inference_cost: 0.9, + upstream_inference_prompt_cost: 0.6, + upstream_inference_completions_cost: 0.3, + }, + }); + expect(value.cost.total).toBe(1); + expect(value.cost.totalFromProvider).toBe(true); + }); + + it("uses upstream cost components when OpenRouter omits their total", () => { + const value = usage(); + applyOpenRouterReportedCost(value, { + cost: 0.1, + is_byok: true, + cost_details: { + upstream_inference_cost: null, + upstream_inference_prompt_cost: 0.6, + upstream_inference_completions_cost: 0.3, + }, + }); + expect(value.cost.total).toBeCloseTo(1); + expect(value.cost.totalFromProvider).toBe(true); + }); + + it("keeps the catalog total when OpenRouter's accounting is incomplete", () => { + const value = usage(); + value.cost.totalFromProvider = false; + applyOpenRouterReportedCost(value, { cost: null, is_byok: false, cost_details: null }); + applyOpenRouterReportedCost(value, { cost: 0.1, is_byok: true, cost_details: null }); + expect(value.cost.total).toBe(1); + expect(value.cost.totalFromProvider).toBe(false); + }); +}); diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index f845fce75..737adbdb5 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -1351,7 +1351,7 @@ let userMessages = 0, assistantMessages = 0, toolResults = 0; let customMessages = 0, compactions = 0, branchSummaries = 0, toolCalls = 0; const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }; const models = new Set(); for (const entry of entryList) { @@ -1367,10 +1367,18 @@ tokens.cacheRead += msg.usage.cacheRead || 0; tokens.cacheWrite += msg.usage.cacheWrite || 0; if (msg.usage.cost) { + const componentTotal = + (msg.usage.cost.input || 0) + + (msg.usage.cost.output || 0) + + (msg.usage.cost.cacheRead || 0) + + (msg.usage.cost.cacheWrite || 0); cost.input += msg.usage.cost.input || 0; cost.output += msg.usage.cost.output || 0; cost.cacheRead += msg.usage.cost.cacheRead || 0; cost.cacheWrite += msg.usage.cost.cacheWrite || 0; + // Provider-reported totals can intentionally differ from catalog-estimated components. + // Fall back to the component sum for sessions written before cost.total was recorded. + cost.total += typeof msg.usage.cost.total === 'number' ? msg.usage.cost.total : componentTotal; } } toolCalls += msg.content.filter(c => c.type === 'toolCall').length; @@ -1391,7 +1399,7 @@ const globalStats = computeStats(entries); function renderHeader() { - const totalCost = globalStats.cost.input + globalStats.cost.output + globalStats.cost.cacheRead + globalStats.cost.cacheWrite; + const totalCost = globalStats.cost.total; const tokenParts = []; if (globalStats.tokens.input) tokenParts.push(`↑${formatTokens(globalStats.tokens.input)}`); diff --git a/packages/coding-agent/test/export-html-cost.test.ts b/packages/coding-agent/test/export-html-cost.test.ts new file mode 100644 index 000000000..f7291ae06 --- /dev/null +++ b/packages/coding-agent/test/export-html-cost.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; + +type ExportStats = { + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; +}; + +describe("export HTML cost", () => { + it("uses recorded totals while falling back to component sums for old sessions", () => { + // Regression test for #9980: provider-reported totals may differ from catalog-estimated components. + const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8"); + const functionStart = templateJs.indexOf("function computeStats(entryList)"); + const functionEnd = templateJs.indexOf("const globalStats = computeStats(entries);"); + expect(functionStart).toBeGreaterThanOrEqual(0); + expect(functionEnd).toBeGreaterThan(functionStart); + + const context: { result?: ExportStats } = {}; + runInNewContext( + `${templateJs.slice(functionStart, functionEnd)} + result = computeStats(${JSON.stringify([ + { + type: "message", + message: { + role: "assistant", + content: [], + usage: { cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0, total: 0.9 } }, + }, + }, + { + type: "message", + message: { + role: "assistant", + content: [], + usage: { cost: { input: 0.4, output: 0.1, cacheRead: 0, cacheWrite: 0 } }, + }, + }, + ])});`, + context, + ); + + expect(context.result?.cost.input).toBeCloseTo(0.5); + expect(context.result?.cost.output).toBeCloseTo(0.3); + expect(context.result?.cost.total).toBeCloseTo(1.4); + }); +}); From 2685de7a1785f1835a9d3d684e16c29789e4eaa8 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 22 Oct 2026 11:24:31 +0200 Subject: [PATCH 12/30] fix: update deps for security advisories Bump vitest to 4.1.12 (vite 8.3.4) and the gondolin example extension to 0.14.0, which drops node-forge. Gondolin's undici moves to 6.29.1. --- package-lock.json | 632 ++++++++---------- packages/agent/package.json | 2 +- packages/ai/package.json | 2 +- packages/chord/package.json | 2 +- packages/codemode/package.json | 2 +- .../extensions/gondolin/package-lock.json | 44 +- .../examples/extensions/gondolin/package.json | 2 +- packages/coding-agent/package.json | 2 +- packages/durable/package.json | 2 +- packages/env/package.json | 2 +- packages/evals/package.json | 2 +- packages/mcp/package.json | 2 +- packages/telemetry/package.json | 2 +- 13 files changed, 308 insertions(+), 390 deletions(-) diff --git a/package-lock.json b/package-lock.json index 898afd386..f90a85222 100644 --- a/package-lock.json +++ b/package-lock.json @@ -758,31 +758,30 @@ "link": true }, "node_modules/@earendil-works/gondolin": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz", - "integrity": "sha512-BXbvzQKb5QmxY5NtthRDONJTu7+IDKbzqWGrJyyNXMP7N681Tx0Q9TK8pK1ba8nUvYQTipNJyGZOsJfYiZll1A==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.14.0.tgz", + "integrity": "sha512-MSw+EGcqFJeyKHdXEfm+nMRr74ytT4x8DGIohav0oPCHeVQAB9AajquM94cv2xqvIU3VDAaZZVC07IXsOY6OVQ==", "license": "Apache-2.0", "dependencies": { "cbor2": "^2.3.0", - "node-forge": "^1.3.3", "ssh2": "^1.17.0", - "undici": "^6.21.0" + "undici": "^6.29.0" }, "bin": { "gondolin": "dist/bin/gondolin.js" }, "engines": { - "node": ">=23.6.0" + "node": ">=22.19.0" }, "optionalDependencies": { - "@earendil-works/gondolin-krun-runner-darwin-arm64": "0.12.0", - "@earendil-works/gondolin-krun-runner-linux-x64": "0.12.0" + "@earendil-works/gondolin-krun-runner-darwin-arm64": "0.14.0", + "@earendil-works/gondolin-krun-runner-linux-x64": "0.14.0" } }, "node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.12.0.tgz", - "integrity": "sha512-ftDlusht4PcT7Y3TuPrZIKrCXy3isiBTVMvlXYK0pcud2uXY6uwFTGeunYgP+8ND/60ddb+MImqbfmkcK8B84A==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.14.0.tgz", + "integrity": "sha512-fvuhz1CZPaEbmXC0luZoXd1obAb0bjaaGJz33Jo63bhMlosYunAahItakh7cU0dnTlNFzufOSnpGOPqtuYjnpQ==", "cpu": [ "arm64" ], @@ -796,9 +795,9 @@ } }, "node_modules/@earendil-works/gondolin-krun-runner-linux-x64": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.12.0.tgz", - "integrity": "sha512-RRYsgwe2r5ApKmFNy469QgwnyjAHpAs9XANdWpTd9ol4iUYOY3sX7e0xIooAKxd+ktxGI4N/xRWicwGen3D/Ow==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.14.0.tgz", + "integrity": "sha512-91iw4smoOH/yOoAuQS6N2UZOxqSFwRhtk8rTyrokXBoWiWcveAzg4kHvnLcm40ZlNrWwzIDFrere4bkZeGDQcA==", "cpu": [ "x64" ], @@ -812,9 +811,9 @@ } }, "node_modules/@earendil-works/gondolin/node_modules/undici": { - "version": "6.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", - "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -860,18 +859,6 @@ "resolved": "packages/tui", "link": true }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -883,17 +870,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1915,33 +1891,14 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@pondwader/socks5-server": { @@ -2036,10 +1993,27 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", "cpu": [ "arm64" ], @@ -2054,9 +2028,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", "cpu": [ "arm64" ], @@ -2071,9 +2045,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", "cpu": [ "x64" ], @@ -2088,9 +2062,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", "cpu": [ "x64" ], @@ -2105,9 +2079,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", "cpu": [ "arm" ], @@ -2122,13 +2096,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2139,13 +2116,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2156,13 +2136,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2173,13 +2156,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2190,13 +2176,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2207,13 +2196,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2224,9 +2216,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", "cpu": [ "arm64" ], @@ -2240,40 +2232,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", "cpu": [ "arm64" ], @@ -2288,9 +2250,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", "cpu": [ "x64" ], @@ -2437,17 +2399,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2916,16 +2867,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2934,13 +2885,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2961,9 +2912,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -2974,13 +2925,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -2988,14 +2939,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3004,9 +2955,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -3014,13 +2965,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3272,9 +3223,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -3646,6 +3597,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -4152,9 +4121,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -4168,23 +4137,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "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" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -4203,9 +4172,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -4224,9 +4193,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -4245,9 +4214,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -4266,9 +4235,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -4287,13 +4256,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4308,13 +4280,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4329,13 +4304,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4350,13 +4328,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4371,9 +4352,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -4392,9 +4373,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -4641,9 +4622,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -4724,15 +4705,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -4868,6 +4840,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pnpm": { "version": "11.15.1", "resolved": "https://registry.npmjs.org/pnpm/-/pnpm-11.15.1.tgz", @@ -4888,9 +4873,9 @@ } }, "node_modules/postcss": { - "version": "8.5.24", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", - "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "dev": true, "funding": [ { @@ -4908,9 +4893,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -5106,13 +5091,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5122,21 +5107,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" } }, "node_modules/safe-buffer": { @@ -5516,37 +5501,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", @@ -5675,16 +5629,16 @@ "dev": true }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -5701,7 +5655,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5752,33 +5706,20 @@ } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@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", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5806,12 +5747,12 @@ "@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", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5883,19 +5824,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -6091,7 +6019,7 @@ }, "devDependencies": { "@types/node": "22.19.19", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6126,7 +6054,7 @@ "devDependencies": { "@types/node": "22.19.19", "canvas": "3.2.3", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6203,7 +6131,7 @@ "devDependencies": { "@types/ws": "8.18.2", "shx": "0.3.4", - "vitest": "4.1.11", + "vitest": "4.1.12", "ws": "8.21.0" }, "engines": { @@ -6219,7 +6147,7 @@ }, "devDependencies": { "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6264,7 +6192,7 @@ "@types/semver": "7.7.1", "marked": "18.0.11", "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6285,7 +6213,7 @@ "name": "pi-extension-gondolin", "version": "1.4.0", "dependencies": { - "@earendil-works/gondolin": "0.12.0" + "@earendil-works/gondolin": "0.14.0" } }, "packages/coding-agent/examples/extensions/sandbox": { @@ -6366,7 +6294,7 @@ "@cloudflare/vitest-plugin": "1.4.4", "fake-indexeddb": "6.2.6", "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6382,7 +6310,7 @@ }, "devDependencies": { "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6398,7 +6326,7 @@ "@vitest-evals/core": "0.15.0", "autoevals": "0.3.0", "shx": "0.3.4", - "vitest": "4.1.11", + "vitest": "4.1.12", "vitest-evals": "0.15.0" } }, @@ -6412,7 +6340,7 @@ "devDependencies": { "@types/cross-spawn": "6.0.6", "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6424,7 +6352,7 @@ "license": "MIT", "devDependencies": { "@types/node": "22.19.19", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" diff --git a/packages/agent/package.json b/packages/agent/package.json index ec1838680..d1231d95d 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -45,6 +45,6 @@ }, "devDependencies": { "@types/node": "22.19.19", - "vitest": "4.1.11" + "vitest": "4.1.12" } } diff --git a/packages/ai/package.json b/packages/ai/package.json index debb553dd..ac6f865c6 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -101,6 +101,6 @@ "devDependencies": { "@types/node": "22.19.19", "canvas": "3.2.3", - "vitest": "4.1.11" + "vitest": "4.1.12" } } diff --git a/packages/chord/package.json b/packages/chord/package.json index 9845c7594..57dbb5406 100644 --- a/packages/chord/package.json +++ b/packages/chord/package.json @@ -91,7 +91,7 @@ "devDependencies": { "@types/ws": "8.18.2", "shx": "0.3.4", - "vitest": "4.1.11", + "vitest": "4.1.12", "ws": "8.21.0" } } diff --git a/packages/codemode/package.json b/packages/codemode/package.json index b653b4fcd..b1f0fea9c 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -71,6 +71,6 @@ }, "devDependencies": { "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" } } diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index cacaab7b9..99a06ff69 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -8,7 +8,7 @@ "name": "pi-extension-gondolin", "version": "1.4.0", "dependencies": { - "@earendil-works/gondolin": "0.12.0" + "@earendil-works/gondolin": "0.14.0" } }, "node_modules/@cto.af/wtf8": { @@ -21,31 +21,30 @@ } }, "node_modules/@earendil-works/gondolin": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz", - "integrity": "sha512-BXbvzQKb5QmxY5NtthRDONJTu7+IDKbzqWGrJyyNXMP7N681Tx0Q9TK8pK1ba8nUvYQTipNJyGZOsJfYiZll1A==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.14.0.tgz", + "integrity": "sha512-MSw+EGcqFJeyKHdXEfm+nMRr74ytT4x8DGIohav0oPCHeVQAB9AajquM94cv2xqvIU3VDAaZZVC07IXsOY6OVQ==", "license": "Apache-2.0", "dependencies": { "cbor2": "^2.3.0", - "node-forge": "^1.3.3", "ssh2": "^1.17.0", - "undici": "^6.21.0" + "undici": "^6.29.0" }, "bin": { "gondolin": "dist/bin/gondolin.js" }, "engines": { - "node": ">=23.6.0" + "node": ">=22.19.0" }, "optionalDependencies": { - "@earendil-works/gondolin-krun-runner-darwin-arm64": "0.12.0", - "@earendil-works/gondolin-krun-runner-linux-x64": "0.12.0" + "@earendil-works/gondolin-krun-runner-darwin-arm64": "0.14.0", + "@earendil-works/gondolin-krun-runner-linux-x64": "0.14.0" } }, "node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.12.0.tgz", - "integrity": "sha512-ftDlusht4PcT7Y3TuPrZIKrCXy3isiBTVMvlXYK0pcud2uXY6uwFTGeunYgP+8ND/60ddb+MImqbfmkcK8B84A==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.14.0.tgz", + "integrity": "sha512-fvuhz1CZPaEbmXC0luZoXd1obAb0bjaaGJz33Jo63bhMlosYunAahItakh7cU0dnTlNFzufOSnpGOPqtuYjnpQ==", "cpu": [ "arm64" ], @@ -59,9 +58,9 @@ } }, "node_modules/@earendil-works/gondolin-krun-runner-linux-x64": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.12.0.tgz", - "integrity": "sha512-RRYsgwe2r5ApKmFNy469QgwnyjAHpAs9XANdWpTd9ol4iUYOY3sX7e0xIooAKxd+ktxGI4N/xRWicwGen3D/Ow==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.14.0.tgz", + "integrity": "sha512-91iw4smoOH/yOoAuQS6N2UZOxqSFwRhtk8rTyrokXBoWiWcveAzg4kHvnLcm40ZlNrWwzIDFrere4bkZeGDQcA==", "cpu": [ "x64" ], @@ -134,15 +133,6 @@ "license": "MIT", "optional": true }, - "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -173,9 +163,9 @@ "license": "Unlicense" }, "node_modules/undici": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", - "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index 813068135..d19d7190f 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -14,6 +14,6 @@ ] }, "dependencies": { - "@earendil-works/gondolin": "0.12.0" + "@earendil-works/gondolin": "0.14.0" } } diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index e0955b0e7..616d6a475 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -75,7 +75,7 @@ "@types/semver": "7.7.1", "marked": "18.0.11", "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "keywords": [ "coding-agent", diff --git a/packages/durable/package.json b/packages/durable/package.json index f5756989c..d40ddfbd8 100644 --- a/packages/durable/package.json +++ b/packages/durable/package.json @@ -162,6 +162,6 @@ "@cloudflare/vitest-plugin": "1.4.4", "fake-indexeddb": "6.2.6", "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" } } diff --git a/packages/env/package.json b/packages/env/package.json index 2ed4d5499..ab09e6546 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -50,6 +50,6 @@ }, "devDependencies": { "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" } } diff --git a/packages/evals/package.json b/packages/evals/package.json index 843677914..c33b9f58a 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -17,7 +17,7 @@ "@vitest-evals/core": "0.15.0", "autoevals": "0.3.0", "shx": "0.3.4", - "vitest": "4.1.11", + "vitest": "4.1.12", "vitest-evals": "0.15.0" } } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 518515ea4..b016e8067 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -56,6 +56,6 @@ "devDependencies": { "@types/cross-spawn": "6.0.6", "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" } } diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index e9eee2555..92b1f04e4 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -47,6 +47,6 @@ }, "devDependencies": { "@types/node": "22.19.19", - "vitest": "4.1.11" + "vitest": "4.1.12" } } From db774055e3ecae05ab6eae8656454cde98e36780 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Thu, 22 Oct 2026 12:43:08 +0300 Subject: [PATCH 13/30] Set up preview packages (#11260) * feat: publish preview packages to pkg.earendil.com CI packs every public package after the build, with internal dependencies pointing at the same commit's builds. A workflow_run job publishes them to the earendil-pkg worker with the job's GitHub OIDC token, moves main or pr-, and comments install commands on the PR. Closing a PR deletes its ref. pi-env is left out because its daemons are only built for releases. * feat: publish preview packages only for main and same-repo PRs pkg.earendil.com requires a consumer token, so fork authors could not install their previews anyway. Fork PRs are no longer packed or published, which keeps fork code out of the registry and removes the PR lookup: the PR number now comes from the workflow_run event. The PR comment is dropped; the job summary lists the install commands. * fix: pick the PR into main and describe only the workflow in preview-packages.yml - Read the PR number from the workflow_run pull request list with jq, selecting the PR into main; a branch can also have PRs into other branches, and the index expression tripped workflow linters. - Comments describe what the workflow does and what it needs from pkg.earendil.com, not how the service works. * fix: gate preview publishing to main pushes and open PRs into main - Publish only CI runs for pushes to main and for pull requests. A branch can make its own copy of CI run on push; those runs are unreviewed and were uploaded as push builds. - Move pr- only while the PR is open into main with this commit as its head, checked live, so a late publish cannot recreate the ref of a closed PR; otherwise publish without a ref and print a notice. - Set concurrency per job, so skipped fork runs never wait in a group. * docs: drop the service rule from the preview-packages.yml header * docs: name the earendil-works/pkg worker in the preview script --- .github/workflows/ci.yml | 17 +++ .github/workflows/preview-packages.yml | 117 +++++++++++++++ scripts/preview-packages.mjs | 188 +++++++++++++++++++++++++ scripts/preview-packages.test.mjs | 127 +++++++++++++++++ 4 files changed, 449 insertions(+) create mode 100644 .github/workflows/preview-packages.yml create mode 100644 scripts/preview-packages.mjs create mode 100644 scripts/preview-packages.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 302158278..a8c980e5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,23 @@ jobs: - name: Build run: npm run build + # Previews are published for main and PRs from branches of this repo only (see preview-packages.yml). + - name: Pack preview packages + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + env: + # Pull request runs check out a merge commit; tarballs are keyed by the PR head commit. + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: node scripts/preview-packages.mjs pack --sha "$SHA" --out "$RUNNER_TEMP/preview-packages" + + - name: Upload preview packages + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: preview-packages + path: ${{ runner.temp }}/preview-packages + retention-days: 3 + if-no-files-found: error + - name: Check run: npm run check diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml new file mode 100644 index 000000000..3b4121b79 --- /dev/null +++ b/.github/workflows/preview-packages.yml @@ -0,0 +1,117 @@ +# Publishes the preview packages that CI packed for main and for PRs from branches of this repo to pkg.earendil.com, +# moving the `main` or `pr-` ref, and deletes a PR's ref when the PR closes. It reads the CI artifact as data +# and never checks out or runs PR code. +# +# Uploads are only accepted if pkg.earendil.com allows this repository, this workflow on main, and every package it +# publishes. + +name: Preview packages + +on: + workflow_run: + workflows: [CI] + types: [completed] + pull_request_target: + types: [closed] + +permissions: {} + +jobs: + publish: + # Only CI runs for pushes to main and for PRs from branches of this repo. A branch can make its own copy of CI run + # on push; such runs are not reviewed and must not be published as push builds. + if: >- + github.event_name == 'workflow_run' && github.event.workflow_run.conclusion != 'cancelled' && + github.event.workflow_run.head_repository.full_name == github.repository && + ((github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main') || + github.event.workflow_run.event == 'pull_request') + # Publish and cleanup runs for the same branch execute one at a time, in order. The group is set per job, so runs + # whose job is skipped (forks) never wait in it. `queue: max` keeps every waiting run instead of replacing one. + concurrency: + group: preview-packages-${{ github.event.workflow_run.head_branch }} + queue: max + runs-on: ubuntu-latest + permissions: + actions: read # download the artifact from the CI run + contents: read # check out scripts/ + id-token: write # authenticate to pkg.earendil.com + pull-requests: read # check that the PR is still open + steps: + - name: Find packed packages + id: find + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" \ + --jq '[.artifacts[] | select(.name == "preview-packages")] | length') + if [ "$count" = 0 ]; then echo "::notice::CI produced no preview packages (it failed before packing)"; fi + echo "found=$([ "$count" != 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Download packed packages + if: steps.find.outputs.found == 'true' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: preview-packages + path: ${{ runner.temp }}/preview-packages + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Check out scripts + if: steps.find.outputs.found == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: scripts + persist-credentials: false + + - name: Publish + if: steps.find.outputs.found == 'true' + env: + GH_TOKEN: ${{ github.token }} + # Event data is passed through env, never interpolated into the script: branch names are PR-controlled. + EVENT: ${{ github.event.workflow_run.event }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + PULL_REQUESTS: ${{ toJSON(github.event.workflow_run.pull_requests) }} + RUN_ID: ${{ github.event.workflow_run.id }} + DIR: ${{ runner.temp }}/preview-packages + run: | + args=(--dir "$DIR" --sha "$HEAD_SHA" --run "$RUN_ID" --event "$EVENT") + if [ "$EVENT" = push ] && [ "$HEAD_BRANCH" = main ]; then + args+=(--ref main) + else + # The branch's PR into main; a branch can also have PRs into other branches. It may have closed or received + # new commits since CI ran, so the ref moves only while the PR is open with this commit as its head. + pr=$(jq -r 'map(select(.base.ref == "main")) | .[0].number // empty' <<< "$PULL_REQUESTS") + state=$([ -z "$pr" ] || gh api "repos/$GITHUB_REPOSITORY/pulls/$pr" --jq '"\(.state) \(.base.ref) \(.head.sha)"') + if [ "$state" = "open main $HEAD_SHA" ]; then + args+=(--ref "pr-$pr") + else + echo "::notice::No open PR into main has head $HEAD_SHA; publishing without a pr- ref" + fi + fi + node scripts/preview-packages.mjs publish "${args[@]}" + + cleanup: + # Only PRs into main: pull_request_target runs this file from the PR's base branch, and only main is allowed. + if: >- + github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == github.event.repository.default_branch && + github.event.pull_request.head.repo.full_name == github.repository + concurrency: + group: preview-packages-${{ github.event.pull_request.head.ref }} + queue: max + runs-on: ubuntu-latest + permissions: + contents: read # check out scripts/ from the base branch, not PR code + id-token: write # authenticate to pkg.earendil.com + steps: + - name: Check out scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: scripts + persist-credentials: false + + - name: Delete PR ref + env: + PR: ${{ github.event.pull_request.number }} + run: node scripts/preview-packages.mjs delete-ref --ref "pr-$PR" diff --git a/scripts/preview-packages.mjs b/scripts/preview-packages.mjs new file mode 100644 index 000000000..bf18e9cf4 --- /dev/null +++ b/scripts/preview-packages.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +// Preview builds of pi's packages on pkg.earendil.com, served by the earendil-works/pkg worker. +// Used by .github/workflows/ci.yml (pack) and .github/workflows/preview-packages.yml (publish, delete-ref). +// +// pack Point internal dependencies at this commit's builds, then `npm pack` every public package. +// Runs in the regular CI job after the build. +// publish Upload the packed tarballs and move refs (main, pr-1234). Runs in the trusted workflow_run job and +// authenticates with the job's GitHub OIDC token (`permissions: id-token: write`). +// delete-ref Remove a ref, e.g. pr-1234 when the PR closes. + +import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { execNpmSync } from "./npm-command.mjs"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; + +const BASE_URL = "https://pkg.earendil.com"; +const FULL_SHA = /^[0-9a-f]{40}$/; +// Its daemon binaries are cross-compiled only by the release workflow; previews would ship without them. +const EXCLUDED = new Set(["@earendil-works/pi-env"]); + +class UsageError extends Error {} + +function assertSha(sha) { + if (typeof sha !== "string" || !FULL_SHA.test(sha)) { + throw new UsageError(`--sha must be a full commit SHA, got ${sha}`); + } +} + +async function pack(argv) { + const { values } = parseArgs({ + args: argv, + options: { sha: { type: "string" }, out: { type: "string", default: "preview-packages" } }, + }); + assertSha(values.sha); + const sha = values.sha; + const packages = getPublicWorkspacePackages().filter((pkg) => !EXCLUDED.has(pkg.name)); + const names = new Set(packages.map((pkg) => pkg.name)); + const outDir = resolve(values.out); + await mkdir(outDir, { recursive: true }); + + const packed = []; + for (const pkg of packages) { + const manifestPath = join(pkg.directory, "package.json"); + const original = await readFile(manifestPath, "utf8"); + const manifest = JSON.parse(original); + // Internal dependencies point at the same commit's builds, so one install resolves a consistent set. + for (const field of ["dependencies", "optionalDependencies"]) { + for (const name of Object.keys(manifest[field] ?? {})) { + if (names.has(name)) manifest[field][name] = `${BASE_URL}/${name}@${sha}`; + } + } + await writeFile(manifestPath, `${JSON.stringify(manifest, null, "\t")}\n`); + try { + const output = execNpmSync(["pack", "--json", "--ignore-scripts", "--pack-destination", outDir], { + cwd: pkg.directory, + encoding: "utf8", + stdio: ["inherit", "pipe", "inherit"], + maxBuffer: 64 * 1024 * 1024, + }); + const [info] = JSON.parse(output); + packed.push({ name: info.name, file: info.filename }); + console.error(`packed ${info.name}@${info.version} -> ${info.filename}`); + } finally { + await writeFile(manifestPath, original); + } + } + await writeFile(join(outDir, "manifest.json"), `${JSON.stringify({ sha, packages: packed }, null, "\t")}\n`); +} + +// The audience names the production worker, also when --base-url points elsewhere (tests). +async function getOidcToken() { + const url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + const token = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + if (!url || !token) { + throw new UsageError("No GitHub OIDC token available; the job needs `permissions: id-token: write`"); + } + const requestUrl = new URL(url); + requestUrl.searchParams.set("audience", BASE_URL); + const response = await fetch(requestUrl, { headers: { authorization: `Bearer ${token}` } }); + if (!response.ok) throw new Error(`Requesting OIDC token failed: ${response.status}`); + const { value } = await response.json(); + return value; +} + +async function call(method, url, token, body) { + const response = await fetch(url, { method, headers: { authorization: `Bearer ${token}` }, body }); + const text = await response.text(); + if (!response.ok) throw new Error(`${method} ${url} failed: ${response.status} ${text}`); + return JSON.parse(text); +} + +function renderSummary({ baseUrl, sha, refs, names }) { + const lines = ["### Preview packages", "", `Built from \`${sha.slice(0, 7)}\`.`, ""]; + if (refs.length > 0) { + lines.push(`Latest build of \`${refs[0]}\` (updates with new pushes):`, "", "```sh"); + for (const name of names) lines.push(`npm i ${baseUrl}/${name}@${refs[0]}`); + lines.push("```", ""); + } + lines.push("
Pinned to this commit", "", "```sh"); + for (const name of names) lines.push(`npm i ${baseUrl}/${name}@${sha}`); + lines.push("```", "", "
", ""); + lines.push(`Installs need a token in \`.npmrc\`: \`//${new URL(baseUrl).host}/:_authToken=...\``); + return `${lines.join("\n")}\n`; +} + +async function publish(argv) { + const { values } = parseArgs({ + args: argv, + options: { + dir: { type: "string", default: "preview-packages" }, + sha: { type: "string" }, + ref: { type: "string", multiple: true, default: [] }, + run: { type: "string" }, + event: { type: "string" }, + "base-url": { type: "string", default: BASE_URL }, + }, + }); + assertSha(values.sha); + const { sha, ref: refs, event } = values; + const run = Number(values.run); + if (!/^\d+$/.test(values.run ?? "") || !Number.isSafeInteger(run)) { + throw new UsageError(`--run must be the CI run ID, got ${values.run}`); + } + if (event !== "push" && event !== "pull_request") { + throw new UsageError(`--event must be push or pull_request, got ${event}`); + } + const baseUrl = values["base-url"]; + const dir = resolve(values.dir); + + // The artifact comes from an untrusted PR build: only read tarballs from it. The SHA, run, event, and refs + // come from the trusted workflow_run event; the worker authorizes package names against its config. + const manifest = JSON.parse(await readFile(join(dir, "manifest.json"), "utf8")); + if (manifest.sha !== sha) throw new Error(`Artifact was packed for ${manifest.sha}, expected ${sha}`); + if (!Array.isArray(manifest.packages) || manifest.packages.length === 0) throw new Error("Artifact has no packages"); + + const token = await getOidcToken(); + const names = []; + for (const { name, file } of manifest.packages) { + if (typeof file !== "string" || basename(file) !== file || !file.endsWith(".tgz")) { + throw new Error(`Invalid tarball name in artifact: ${file}`); + } + const url = `${baseUrl}/_/upload/${sha}/${encodeURIComponent(name)}?event=${event}`; + const result = await call("PUT", url, token, await readFile(join(dir, file))); + names.push(result.name); + console.error(`${result.existing ? "exists" : "uploaded"} ${result.name}@${sha}`); + } + if (refs.length > 0) { + const result = await call("POST", `${baseUrl}/_/refs`, token, JSON.stringify({ sha, refs, run })); + const newer = result.refs.filter((ref) => ref.sha !== sha); + if (newer.length > 0) { + // A newer run already published these refs; its summary has the current install commands. + console.error(`a newer run already published ${newer.map((ref) => `${ref.ref} (${ref.sha})`).join(", ")}`); + return; + } + console.error(`moved ${refs.join(", ")} to ${sha}`); + } + + const summary = renderSummary({ baseUrl, sha, refs, names: names.sort() }); + process.stdout.write(summary); + if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, summary); +} + +async function deleteRef(argv) { + const { values } = parseArgs({ + args: argv, + options: { ref: { type: "string" }, "base-url": { type: "string", default: BASE_URL } }, + }); + if (!values.ref) throw new UsageError("delete-ref needs --ref"); + await call("DELETE", `${values["base-url"]}/_/refs/${encodeURIComponent(values.ref)}`, await getOidcToken()); + console.error(`deleted ${values.ref}`); +} + +const USAGE = `Usage: + node scripts/preview-packages.mjs pack --sha [--out dir] + node scripts/preview-packages.mjs publish --sha --run --event + [--dir dir] [--ref name]... + node scripts/preview-packages.mjs delete-ref --ref `; + +const [command, ...argv] = process.argv.slice(2); +const commands = { pack, publish, "delete-ref": deleteRef }; +try { + if (!commands[command]) throw new UsageError(USAGE); + await commands[command](argv); +} catch (error) { + console.error(error instanceof UsageError ? error.message : `preview-packages: ${error.message}`); + process.exit(1); +} diff --git a/scripts/preview-packages.test.mjs b/scripts/preview-packages.test.mjs new file mode 100644 index 000000000..d287c0c49 --- /dev/null +++ b/scripts/preview-packages.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { execFile, execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const script = join(dirname(fileURLToPath(import.meta.url)), "preview-packages.mjs"); +const sha = "a".repeat(40); +const newerSha = "b".repeat(40); + +function temporaryDirectory(t) { + const directory = mkdtempSync(join(tmpdir(), "pi-preview-packages-test-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function writePackage(repo, directory, manifest) { + mkdirSync(join(repo, "packages", directory), { recursive: true }); + writeFileSync(join(repo, "packages", directory, "package.json"), `${JSON.stringify(manifest, null, "\t")}\n`); +} + +/** + * Stands in for the pkg.earendil.com worker and GitHub's OIDC token endpoint, recording requests. `refsSha` is what the + * worker reports each ref resolves to after the update; `uploadBody` overrides the upload response. + */ +async function startServer(t, { refsSha = sha, uploadBody } = {}) { + const requests = []; + const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + requests.push({ method: req.method, url: req.url, authorization: req.headers.authorization, body }); + const url = new URL(req.url, "http://localhost"); + if (url.pathname === "/token") return res.end(JSON.stringify({ value: `oidc-for-${url.searchParams.get("audience")}` })); + if (req.method === "PUT") { + const name = decodeURIComponent(url.pathname.split("/").slice(4).join("/")); + return res.end(uploadBody ?? JSON.stringify({ name, sha, existing: false })); + } + const { refs } = JSON.parse(body); + res.end(JSON.stringify({ refs: refs.map((ref) => ({ ref, sha: refsSha })) })); + }); + await new Promise((ready) => server.listen(0, "127.0.0.1", ready)); + t.after(() => server.close()); + return { url: `http://127.0.0.1:${server.address().port}`, requests }; +} + +function writeArtifact(directory) { + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "scope-a-1.0.0.tgz"), "tarball a"); + const packages = [{ name: "@scope/a", file: "scope-a-1.0.0.tgz" }]; + writeFileSync(join(directory, "manifest.json"), JSON.stringify({ sha, packages })); +} + +function publish(server, dir) { + const { GITHUB_STEP_SUMMARY: _, ...env } = process.env; + const args = ["publish", "--sha", sha, "--run", "7", "--event", "pull_request", "--ref", "pr-1", "--dir", dir]; + return run(process.execPath, [script, ...args, "--base-url", server.url], { + env: { ...env, ACTIONS_ID_TOKEN_REQUEST_URL: `${server.url}/token`, ACTIONS_ID_TOKEN_REQUEST_TOKEN: "runner" }, + }); +} + +test("pack points internal dependencies at the commit's builds and restores package.json", async (t) => { + const repo = temporaryDirectory(t); + writePackage(repo, "a", { name: "@scope/a", version: "1.0.0", dependencies: { "@scope/b": "1.0.0", other: "2.0.0" } }); + writePackage(repo, "b", { name: "@scope/b", version: "1.0.0" }); + writePackage(repo, "internal", { name: "@scope/internal", version: "1.0.0", private: true }); + writePackage(repo, "env", { name: "@earendil-works/pi-env", version: "1.0.0" }); + const original = readFileSync(join(repo, "packages/a/package.json"), "utf8"); + + const out = join(repo, "out"); + await run(process.execPath, [script, "pack", "--sha", sha, "--out", out], { cwd: repo }); + + const manifest = JSON.parse(readFileSync(join(out, "manifest.json"), "utf8")); + assert.equal(manifest.sha, sha); + assert.deepEqual(manifest.packages.map((pkg) => pkg.name).sort(), ["@scope/a", "@scope/b"]); + assert.deepEqual(readdirSync(out).sort(), ["manifest.json", ...manifest.packages.map((pkg) => pkg.file)].sort()); + const fileA = manifest.packages.find((pkg) => pkg.name === "@scope/a").file; + const packedA = JSON.parse(execFileSync("tar", ["-xOzf", join(out, fileA), "package/package.json"], { encoding: "utf8" })); + assert.deepEqual(packedA.dependencies, { "@scope/b": `https://pkg.earendil.com/@scope/b@${sha}`, other: "2.0.0" }); + assert.equal(readFileSync(join(repo, "packages/a/package.json"), "utf8"), original); +}); + +test("publish uploads each tarball, moves refs and prints install commands", async (t) => { + const server = await startServer(t); + const dir = join(temporaryDirectory(t), "artifact"); + writeArtifact(dir); + + const { stdout } = await publish(server, dir); + + const [token, upload, refs] = server.requests; + assert.equal(token.url, "/token?audience=https%3A%2F%2Fpkg.earendil.com"); + assert.deepEqual( + { method: upload.method, url: upload.url, authorization: upload.authorization, body: upload.body }, + { + method: "PUT", + url: `/_/upload/${sha}/%40scope%2Fa?event=pull_request`, + authorization: "Bearer oidc-for-https://pkg.earendil.com", + body: "tarball a", + }, + ); + assert.deepEqual(JSON.parse(refs.body), { sha, refs: ["pr-1"], run: 7 }); + assert.match(stdout, new RegExp(`npm i ${server.url}/@scope/a@pr-1`)); + assert.match(stdout, new RegExp(`npm i ${server.url}/@scope/a@${sha}`)); +}); + +test("publish leaves the summary to a newer run that already owns the ref", async (t) => { + const server = await startServer(t, { refsSha: newerSha }); + const dir = join(temporaryDirectory(t), "artifact"); + writeArtifact(dir); + + const { stdout, stderr } = await publish(server, dir); + assert.equal(stdout, ""); + assert.match(stderr, new RegExp(`a newer run already published pr-1 \\(${newerSha}\\)`)); +}); + +test("publish fails on a successful response that is not JSON", async (t) => { + const server = await startServer(t, { uploadBody: "proxy" }); + const dir = join(temporaryDirectory(t), "artifact"); + writeArtifact(dir); + + await assert.rejects(publish(server, dir), /not valid JSON/); + assert.equal(server.requests.filter((request) => request.method === "POST").length, 0); +}); From 3bc6eebacf3a6497eb82a66e02ca8644cfa3dde2 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Thu, 22 Oct 2026 12:43:26 +0200 Subject: [PATCH 14/30] fix(ai): hold Anthropic system updates past dropped assistant messages A mid-conversation system message was placed before checking whether the next assistant message converts to any blocks. When it did not (empty text, or another model's reasoning-only reply), the update landed before a user message and Claude rejected the request with a 400. closes #11091 --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/src/api/anthropic-messages.ts | 6 ++- .../ai/test/transcript-tool-changes.test.ts | 46 ++++++++++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0c9fcee1d..74aa15c4d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Claude models that accept mid-conversation system messages rejecting a session with a 400 (`role 'system' must precede an 'assistant' message`) when a system update was followed by an assistant reply with nothing to send to Anthropic, such as an empty reply or another model's reasoning-only reply ([#11091](https://github.com/earendil-works/pi/issues/11091)) + ## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 761ccd1e9..b43b11c0e 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -1375,7 +1375,9 @@ function convertMessages( const params: MessageParam[] = []; const assistantLevels = new Map(); // Later system messages are held back and emitted directly before the next assistant - // message (or at the end of the transcript). Anthropic requires `tool_result` blocks to + // message that is sent (or at the end of the transcript). Assistant messages that convert to + // no blocks are dropped, so holding past them keeps an update from landing before a user + // message (#11091). Anthropic requires `tool_result` blocks to // immediately follow their `tool_use`, so a system message between them is rejected; this // also mirrors where the managed-effort system messages are inserted. As a result an // update placed before a user message in the transcript lands after it on the wire. @@ -1442,7 +1444,6 @@ function convertMessages( }); } } else if (msg.role === "assistant") { - flushPendingSystemMessages(); const blocks: ContentBlockParam[] = []; for (const block of msg.content) { @@ -1497,6 +1498,7 @@ function convertMessages( } } if (blocks.length === 0) continue; + flushPendingSystemMessages(); const messageIndex = params.length; params.push({ role: "assistant", diff --git a/packages/ai/test/transcript-tool-changes.test.ts b/packages/ai/test/transcript-tool-changes.test.ts index 03127cd24..caaa7fe12 100644 --- a/packages/ai/test/transcript-tool-changes.test.ts +++ b/packages/ai/test/transcript-tool-changes.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, test } from "vitest"; import { getModel, streamSimple } from "../src/compat.ts"; -import type { Api, Context, Message, Model, SystemMessage, Tool } from "../src/types.ts"; +import type { Api, AssistantMessage, Context, Message, Model, SystemMessage, Tool } from "../src/types.ts"; import { resolveTranscriptTools } from "../src/utils/transcript.ts"; class PayloadCaptured extends Error {} @@ -244,6 +244,50 @@ describe("transcript system messages", () => { expect(payload.messages.at(-1)?.content.map((block) => block.type)).toEqual(["text"]); }); + // #11091 + test("holds an Anthropic update past an assistant message that converts to no blocks", async () => { + const reply = (text: string, provider: string, api: Api, model: string): AssistantMessage => ({ + role: "assistant", + content: [{ type: "text", text }], + api, + provider, + model, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 3, + }); + const messages: Message[] = [ + { role: "system", content: "base prompt", timestamp: 0 }, + { role: "user", content: "hi", timestamp: 1 }, + { role: "system", content: "updated guidance", timestamp: 2 }, + // Another model's empty reply: Anthropic rejects empty text blocks, so it is dropped. + reply("", "openai-codex", "openai-codex-responses", "gpt-x"), + { role: "user", content: "again", timestamp: 4 }, + ]; + const roles = (payload: AnthropicPayload) => payload.messages.map((message) => message.role); + + // No later assistant: the update goes at the end. + const atEnd = await capturePayload(anthropicNativeModel, { messages }); + expect(roles(atEnd)).toEqual(["user", "user", "system"]); + + // A later assistant that is sent: the update goes directly before it. + const beforeAssistant = await capturePayload(anthropicNativeModel, { + messages: [ + ...messages, + reply("answer", anthropicNativeModel.provider, anthropicNativeModel.api, anthropicNativeModel.id), + { role: "user", content: "third", timestamp: 5 }, + ], + }); + expect(roles(beforeAssistant)).toEqual(["user", "user", "system", "assistant", "user"]); + }); + test("folds Anthropic updates into the system prompt without native support", async () => { const model: Model<"anthropic-messages"> = { ...modelBase, From 92ab53602474f8592df92c68d69e782cbed28e39 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 22 Oct 2026 10:52:50 +0000 Subject: [PATCH 15/30] feat: Durable storage adopt callback inside the commit, watch types in Chord, explicit watch retirement Breaking changes: - Durable Storage.commit(writes, adopt, context): storages call adopt(seq) once, synchronously, when the batch is durable and before their next commit - Chord traceIn(options, fn, context): context last - WatchHandle and WatchEnd move to Chord (Durable re-exports them); end reasons ended (was session_closed) and failed (was session_failed); retirement is an explicit source event, and an ended watch drops its references into the Session - Removed Session.documentState(), Conversation.viewState(), Harness.taskGraph() and DocumentState; use watchDoc(), watch(), watchTaskGraph() --- packages/chord/CHANGELOG.md | 8 + packages/chord/README.md | 40 +++- packages/chord/src/context/index.ts | 2 +- packages/chord/src/index.ts | 1 + packages/chord/src/watch.ts | 29 +++ packages/chord/test/context.test.ts | 76 ++++-- packages/durable/CHANGELOG.md | 7 + packages/durable/README.md | 33 +-- packages/durable/docs/spec.md | 207 +++++++--------- packages/durable/src/harness/events.ts | 5 +- packages/durable/src/harness/harness.ts | 14 +- packages/durable/src/harness/task-graph.ts | 23 +- packages/durable/src/harness/types.ts | 18 +- packages/durable/src/harness/view.ts | 25 +- packages/durable/src/index.ts | 4 +- packages/durable/src/session/observation.ts | 202 ++++------------ packages/durable/src/session/session.ts | 148 ++++-------- packages/durable/src/storage/indexeddb.ts | 7 +- packages/durable/src/storage/jsonl/storage.ts | 4 +- packages/durable/src/storage/memory.ts | 6 +- .../durable/src/storage/sqlite/storage.ts | 4 +- .../durable/src/testing/storage-benchmark.ts | 27 ++- .../src/testing/storage-conformance.ts | 167 +++++++++++-- packages/durable/src/types.ts | 78 ++---- .../durable/test/examples/04-chord-state.ts | 38 --- .../durable/test/examples/21-late-join.ts | 6 +- .../durable/test/examples/24-child-tasks.ts | 4 +- packages/durable/test/harness-blocked.test.ts | 32 ++- .../test/harness-cancellation-barrier.test.ts | 20 +- .../durable/test/harness-compaction.test.ts | 15 +- .../test/harness-conversations.test.ts | 6 +- packages/durable/test/harness-events.test.ts | 2 +- packages/durable/test/harness-inbox.test.ts | 5 +- .../durable/test/harness-lifecycle.test.ts | 54 ++--- .../durable/test/harness-live-deltas.test.ts | 5 +- .../durable/test/harness-nested-tools.test.ts | 5 +- .../durable/test/harness-ownership.test.ts | 9 +- .../test/harness-scheduler-indexes.test.ts | 8 +- packages/durable/test/harness-sleep.test.ts | 6 +- .../test/harness-storage-failure.test.ts | 12 +- .../durable/test/harness-structured.test.ts | 10 +- .../durable/test/harness-task-graph.test.ts | 45 ++-- packages/durable/test/harness-tasks.test.ts | 2 +- packages/durable/test/harness-view.test.ts | 30 ++- .../durable/test/indexeddb-storage.test.ts | 18 +- packages/durable/test/jsonl-storage.test.ts | 70 ++++-- packages/durable/test/memory-storage.test.ts | 2 +- packages/durable/test/nested-images-bench.ts | 4 +- packages/durable/test/nested-tools-bench.ts | 4 +- .../durable/test/scheduler-fanout-bench.ts | 4 +- .../durable/test/session-adoption.test.ts | 106 ++++++++ .../session-checkpoints-migrations.test.ts | 3 - .../durable/test/session-definitions.test.ts | 8 - .../test/session-document-retention.test.ts | 39 +-- packages/durable/test/session-states.test.ts | 226 ------------------ packages/durable/test/session-support.ts | 8 +- packages/durable/test/session-watches.test.ts | 84 ++++++- .../durable/test/sqlite-cloudflare.test.ts | 2 + packages/durable/test/sqlite-facade.test.ts | 15 +- .../durable/test/sqlite-migrations.test.ts | 1 + packages/durable/test/sqlite-storage.test.ts | 47 +++- 61 files changed, 1025 insertions(+), 1065 deletions(-) create mode 100644 packages/chord/src/watch.ts delete mode 100644 packages/durable/test/examples/04-chord-state.ts create mode 100644 packages/durable/test/session-adoption.test.ts delete mode 100644 packages/durable/test/session-states.test.ts diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 5a2aca4b6..c44321d60 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- `traceIn(options, fn, context)` takes the context last (was `traceIn(context, options, fn)`), like the rest of the API. + +### Added + +- `WatchHandle` and `WatchEnd`, the one watch API, moved from Durable: a serialized exact-frame observation with `value`, `start(listener)`, `stop()`, and `closed`. `WatchEnd` reasons are `stopped`, `cancelled`, `retired`, `ended`, `listener_error`, and `failed`. + ## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/chord/README.md b/packages/chord/README.md index 20f595ccf..d2a136d47 100644 --- a/packages/chord/README.md +++ b/packages/chord/README.md @@ -14,7 +14,7 @@ Contracts are runtime values described with [TypeBox](https://github.com/sinclai | Import | Contents | Runs in | |--------|----------|---------| -| `@earendil-works/chord` | Contracts (`local`, `remote`, `method`, `opaque`), declarations (through the factory's `self`: `state`, `derive`, `resource`, `on`, `task`), hosts (`createHost`, `defineFacet`, `defineRegistry`), links (`memoryLinkPair`, `ipcLink`, `webSocketLink`, `acceptWebSockets`), errors (`ChordError`, `isChordError`, `forbidden`, `invalid`). Also the shared base: `Context`, `JsonValue`, `JsonRepresentation`, `Draft`, `copyJson`, `isJsonValue`, and `replicatedState` with its types. | Node and browsers | +| `@earendil-works/chord` | Contracts (`local`, `remote`, `method`, `opaque`), declarations (through the factory's `self`: `state`, `derive`, `resource`, `on`, `task`), hosts (`createHost`, `defineFacet`, `defineRegistry`), links (`memoryLinkPair`, `ipcLink`, `webSocketLink`, `acceptWebSockets`), errors (`ChordError`, `isChordError`, `forbidden`, `invalid`). Also the shared base: `Context`, `JsonValue`, `JsonRepresentation`, `Draft`, `copyJson`, `isJsonValue`, `replicatedState` with its types, and the watch types `WatchHandle` and `WatchEnd`. | Node and browsers | | `@earendil-works/chord/node` | Child host processes: `createHostManager`, `forkDriver`, `childControl`, `processLock`; the facet module loader `nodeLoader`. | Node | | `@earendil-works/chord/context` | Explicit cancellation, telemetry, and invocation values: `BACKGROUND_CONTEXT`, `withCancel`, `withAbortSignal`, `withoutAbortSignal`, `withTelemetry`, `traceIn`, `withContextValue`, `awaitWithContext`. | Node and browsers | | `@earendil-works/chord/delta` | The transactional JSON tracker, structural diff, immutable apply, and the wire encoder/decoder for operation batches. | Node and browsers | @@ -81,14 +81,18 @@ import { InMemoryTelemetryContext } from '@earendil-works/pi-telemetry'; const telemetry = new InMemoryTelemetryContext(); await telemetry.startSpan({ name: 'app.request' }, async (span) => { const context = withTelemetry(span, BACKGROUND_CONTEXT); - await traceIn(context, { name: 'app.load' }, async (child) => { - child.telemetry.addEvent('cache.miss'); - return traceIn(child, { name: 'app.read' }, (nested) => readAccount(nested)); - }); + await traceIn( + { name: 'app.load' }, + async (child) => { + child.telemetry.addEvent('cache.miss'); + return traceIn({ name: 'app.read' }, (nested) => readAccount(nested), child); + }, + context, + ); }); ``` -`withTelemetry(span, context)` derives a new context; it never changes its parent, cancellation, or typed values. `traceIn(context, options, fn)` creates a callback-managed child span, and `fn` receives its derived context. Every context already supports `telemetry.addEvent()` and `telemetry.addLink()`. Values, cancellation, callback results, and rejection identities are preserved. Concurrent calls keep independent parents; there is no ambient current span. `value()`, `ContextKey`, and `withContextValue()` remain supported. +`withTelemetry(span, context)` derives a new context; it never changes its parent, cancellation, or typed values. `traceIn(options, fn, context)` creates a callback-managed child span, and `fn` receives its derived context. Every context already supports `telemetry.addEvent()` and `telemetry.addLink()`. Values, cancellation, callback results, and rejection identities are preserved. Concurrent calls keep independent parents; there is no ambient current span. `value()`, `ContextKey`, and `withContextValue()` remain supported. These helpers operate on the standalone `Context` API. They do not yet instrument Chord hosts, methods, or network frames. @@ -152,8 +156,7 @@ their containers with the provider; mutating a consumed value violates the contr and can corrupt authority. Clone or serialize at any mutable trust boundary. Replicated state provides the same model through a callback. `replicatedState` is a -standalone observable value, independent of hosts; Durable uses it for document, -view, and task-graph states: +standalone observable value, independent of hosts: ```ts import { replicatedState } from "@earendil-works/chord"; @@ -200,3 +203,24 @@ running callback. Cells of Chord objects and their remote readables have their own subscription model; see [State](docs/guide.md#state) in the usage guide. + +### Watches + +`WatchHandle` is the one watch interface, implemented by watch sources such as +Durable's documents, conversation views, and task graph. `watch.value` is the value +at acquisition until `start(listener)` installs the sole asynchronous listener; each +later change arrives as `(value, ops, context)`, one call at a time, and `value` +advances to it. A source keeps at most 100 undelivered frames and then replaces them +with one root replacement `[["r", newest]]`. `stop()` is idempotent, drops pending +frames, and neither aborts nor awaits a running call; it and `closed` give the +`WatchEnd`: + +- `stopped`: `stop()` was called; +- `cancelled`: the context the watch was acquired with was cancelled; +- `retired`: the source was retired, an explicit event after a last frame `[["r", null]]`; +- `ended`: the source closed (its owner ended or closed); +- `listener_error` with `error`: the listener threw or rejected; +- `failed` with `error`: the source failed. + +An ended watch drops every reference into its source, so a stopped watch that is +still referenced keeps only its last value alive. diff --git a/packages/chord/src/context/index.ts b/packages/chord/src/context/index.ts index 7e8da6688..7318e08e6 100644 --- a/packages/chord/src/context/index.ts +++ b/packages/chord/src/context/index.ts @@ -97,9 +97,9 @@ export function withTelemetry( /** Run a child span with a derived context whose telemetry member is that span. */ export function traceIn( - context: Context, options: SpanOptions, fn: (context: Context) => T | Promise, + context: Context, ): Promise { return context.telemetry.startSpan(options, (span) => fn(withTelemetry(span, context))); } diff --git a/packages/chord/src/index.ts b/packages/chord/src/index.ts index 6fb71ae10..08b2f891e 100644 --- a/packages/chord/src/index.ts +++ b/packages/chord/src/index.ts @@ -78,5 +78,6 @@ export { replicatedState, } from "./state.ts"; export type { Ctx, GetOptions, Readable, RefStatus, Shape, StateEvent } from "./types.ts"; +export type { WatchEnd, WatchHandle } from "./watch.ts"; export { acceptWebSockets, type WebSocketAcceptOptions, type WebSocketServerLike } from "./websocket.ts"; export { type Frame, PROTOCOL_VERSION } from "./wire.ts"; diff --git a/packages/chord/src/watch.ts b/packages/chord/src/watch.ts new file mode 100644 index 000000000..b4922fb2f --- /dev/null +++ b/packages/chord/src/watch.ts @@ -0,0 +1,29 @@ +import type { Context } from "./context/index.ts"; +import type { Op } from "./delta/index.ts"; + +/** + * Terminal result of one watch. + * + * - `stopped`: `stop()` was called. + * - `cancelled`: the context the watch was acquired with was cancelled. + * - `retired`: the source was retired; its last frame was `[["r", null]]`. + * - `ended`: the source closed (its owner ended or closed). + * - `listener_error`: the listener threw or rejected with `error`. + * - `failed`: the source failed with `error`. + */ +export type WatchEnd = + | { readonly reason: "stopped" | "cancelled" | "retired" | "ended" } + | { readonly reason: "listener_error"; readonly error: Error } + | { readonly reason: "failed"; readonly error: unknown }; + +/** Serialized exact-frame observation of an immutable value with bounded pending delivery. */ +export interface WatchHandle { + /** Acquisition revision before start; latest delivered immutable revision afterward. */ + readonly value: T; + /** Install the sole asynchronous listener. Never invokes it inline. */ + start(listener: (value: T, ops: readonly Op[], context: Context) => Promise): void; + /** Idempotently stop future callbacks and return this watch's terminal result. */ + stop(): Promise; + /** Settle when the watch terminates; an already-running callback remains caller-owned. */ + readonly closed: Promise; +} diff --git a/packages/chord/test/context.test.ts b/packages/chord/test/context.test.ts index e9a51a744..88b9e7f75 100644 --- a/packages/chord/test/context.test.ts +++ b/packages/chord/test/context.test.ts @@ -91,15 +91,23 @@ describe("Context", () => { expect(replaced.value(key)).toBe("owner"); expect(replaced.abortSignal).toBe(base.abortSignal); expect(withoutAbortSignal(context).telemetry).toBe(parent); - await traceIn(context, { name: "outer" }, async (child) => { - expect(child.value(key)).toBe("owner"); - expect(child.abortSignal).toBe(context.abortSignal); - child.telemetry.addEvent("started"); - await traceIn(child, { name: "inner" }, (nested) => { - nested.telemetry.addLink({ context: child.telemetry }); - return 42; - }); - }); + await traceIn( + { name: "outer" }, + async (child) => { + expect(child.value(key)).toBe("owner"); + expect(child.abortSignal).toBe(context.abortSignal); + child.telemetry.addEvent("started"); + await traceIn( + { name: "inner" }, + (nested) => { + nested.telemetry.addLink({ context: child.telemetry }); + return 42; + }, + child, + ); + }, + context, + ); const [caller, outer, inner] = telemetry.getSpans(); expect(outer?.parentId).toBe(caller?.id); expect(inner?.parentId).toBe(outer?.id); @@ -119,15 +127,23 @@ describe("Context", () => { const gate = new Promise((resolve) => { release = resolve; }); - const first = traceIn(context, { name: "first" }, async (child) => { - await gate; - return traceIn(child, { name: "first.child" }, () => "first"); - }); - const second = traceIn(context, { name: "second" }, async (child) => { - const result = await traceIn(child, { name: "second.child" }, () => "second"); - release?.(); - return result; - }); + const first = traceIn( + { name: "first" }, + async (child) => { + await gate; + return traceIn({ name: "first.child" }, () => "first", child); + }, + context, + ); + const second = traceIn( + { name: "second" }, + async (child) => { + const result = await traceIn({ name: "second.child" }, () => "second", child); + release?.(); + return result; + }, + context, + ); expect(await Promise.all([first, second])).toEqual(["first", "second"]); const spans = telemetry.getSpans(); for (const name of ["first", "second"]) { @@ -136,16 +152,24 @@ describe("Context", () => { } const failure = { reason: "same value" }; await expect( - traceIn(context, { name: "throw" }, () => { - throw failure; - }), + traceIn( + { name: "throw" }, + () => { + throw failure; + }, + context, + ), ).rejects.toBe(failure); - await expect(traceIn(context, { name: "reject" }, () => Promise.reject(failure))).rejects.toBe(failure); + await expect(traceIn({ name: "reject" }, () => Promise.reject(failure), context)).rejects.toBe(failure); expect( - await traceIn(BACKGROUND_CONTEXT, { name: "noop" }, (child) => { - child.telemetry.addLink({ context: parent }); - return 42; - }), + await traceIn( + { name: "noop" }, + (child) => { + child.telemetry.addLink({ context: parent }); + return 42; + }, + BACKGROUND_CONTEXT, + ), ).toBe(42); }); }); diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index f764683f0..0f8d53b5d 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Breaking Changes + +- `Storage.commit(writes, adopt, context)` takes an `adopt(seq)` callback, the Session's in-memory adoption of the batch: a storage calls it exactly once, synchronously, once the batch is durable and before its next commit starts, then resolves with `seq`, and never for a commit that fails before the batch is durable. Memory, SQLite, JSONL, and IndexedDB storages do; custom storages and wrappers must call or forward it, and the storage conformance suite checks it. A commit that rejects after its adoption fails the Session without rolling the adopted changes back. +- `Storage.mintId()`'s contract is narrowed to IDs greater than every ID minted since the storage was opened and every ID stored, also across reopen. The bundled storages already behave so. +- Watches use Chord's `WatchHandle` and `WatchEnd` (Durable re-exports both). End reasons are `stopped`, `cancelled`, `retired`, `ended` (was `session_closed`), `listener_error`, and `failed` (was `session_failed`). Retirement is an explicit event of the watched document incarnation (last frame `[["r", null]]`, then `retired`), never inferred from a `null` value. An ended watch drops its listener and every reference into its Session, so a stopped watch that is still referenced keeps only its last value. +- Removed the Chord state getters `Session.documentState()`, `Conversation.viewState()`, and `Harness.taskGraph()`, and the `DocumentState` type; use `watchDoc()`, `Conversation.watch()`, and `Harness.watchTaskGraph()`. Durable no longer uses Chord's `replicatedState()`. + ## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/durable/README.md b/packages/durable/README.md index 71aa68376..6f8898d26 100644 --- a/packages/durable/README.md +++ b/packages/durable/README.md @@ -473,30 +473,23 @@ Work that already started keeps the code it took: a running tool call finishes u ## Watching a Conversation -Everything a UI needs is committed state. `viewState()` returns the conversation's structural view as a read-only Chord state, updated after every commit that touches it: - -```typescript -const view = await root.viewState(context); -view.subscribe((value) => { - // value.entries: the active transcript - // value.docs["pi.live"]: the running generation (streamed partial, retry, deferred) and tool calls (output, details) - // value.docs["pi.inbox"], value.docs["pi.usage"], value.docs["pi.agent"], value.docs["pi.provider"] - render(value); -}); -// later: view.dispose(); -``` - -`watch()` delivers the same view with the exact Chord operations of each commit, one callback at a time: +Everything a UI needs is committed state. `watch()` returns a watch of the conversation's structural view: its value at attachment, then each commit that touches it, with the exact Chord operations of that commit, one callback at a time: ```typescript const watch = await root.watch(context); render(watch.value); // the state at attachment watch.start(async (value, ops) => { + // value.entries: the active transcript + // value.docs["pi.live"]: the running generation (streamed partial, retry, deferred) and tool calls (output, details) + // value.docs["pi.inbox"], value.docs["pi.usage"], value.docs["pi.agent"], value.docs["pi.provider"] + render(value); await send(ops); // for example to a remote client that applies them }); // later: await watch.stop(); ``` +Watches are Chord's `WatchHandle` (exported by `@earendil-works/chord`; Durable re-exports it). A watch ends once, and `closed` (or `stop()`) gives the reason: `stopped`, `cancelled` (its acquisition context was cancelled), `retired` (a document watch whose incarnation retired, after it delivered `[["r", null]]`), `ended` (the Harness closed), `listener_error`, or `failed` (the Harness failed). An ended watch keeps its last value and no longer references the Harness. + A slow watch keeps at most 100 undelivered frames. After that, the pending frames are replaced by one frame holding the whole newest view. A client that joins late or reconnects starts from the current view; nothing is replayed. Partial answers and tool output are committed at most every 100 ms by default, so a crash loses at most that window. `settings.progress` changes the intervals; a host whose storage is remote can commit less often, for example `{ partialIntervalMs: 500, outputIntervalMs: 500 }`. @@ -709,11 +702,11 @@ decide: async (task, runtime, context) => { ## Task Graph -`harness.taskGraph(context)` shows every live task of the Session as one Chord state, for a task panel or debugging. Each node has its owner edge (`owner` task, or none for a task its conversation owns), its status, whether it is `background` or abort-marked, and the conversations it owns. `harness.watchTaskGraph(context)` delivers the same value as a watch, like a conversation's `watch()`. +`harness.watchTaskGraph(context)` watches every live task of the Session as one value, for a task panel or debugging, like a conversation's `watch()`. Each node has its owner edge (`owner` task, or none for a task its conversation owns), its status, whether it is `background` or abort-marked, and the conversations it owns. ```typescript -const graph = await harness.taskGraph(context); -graph.subscribe((value) => { +const graph = await harness.watchTaskGraph(context); +graph.start(async (value) => { for (const node of Object.values(value.tasks)) { const status = node.state.status === "waiting" ? `waiting on ${node.state.on.join(", ")}` : node.state.status; console.log(`${node.id} ${node.kind} ${status}`, node.owner ?? `conversation ${node.conversationId}`); @@ -746,7 +739,7 @@ await root.commit(async (tx) => { console.log(await harness.snapshot(Todos, root.id, context)); ``` -`harness.watchDoc()` and `harness.documentState()` observe one document like the view above. `HarnessOptions.conversationCreated(tx, conversation)` runs in every commit that creates or forks a conversation, including a tool's raw `tx.createConversation()`, so every conversation gets your documents; `init` in `createConversation()`, `fork()`, and `root()` writes per-call data in the same commit. An extension's tools, sections, and hooks read their own documents through `api` or `input.read`, and treat an absent one as its default ([`11-extension-state.ts`](test/examples/11-extension-state.ts)). +`harness.watchDoc()` watches one document like the view above. `HarnessOptions.conversationCreated(tx, conversation)` runs in every commit that creates or forks a conversation, including a tool's raw `tx.createConversation()`, so every conversation gets your documents; `init` in `createConversation()`, `fork()`, and `root()` writes per-call data in the same commit. An extension's tools, sections, and hooks read their own documents through `api` or `input.read`, and treat an absent one as its default ([`11-extension-state.ts`](test/examples/11-extension-state.ts)). ## Usage and Cost @@ -798,7 +791,7 @@ await database.transaction(async (transaction) => { }); ``` -Custom backends can run the shared conformance suite with any Vitest- or Jest-compatible runner: +A custom backend's `commit(writes, adopt, context)` calls `adopt(seq)` once the batch is durable, synchronously and before its next commit starts, and then resolves with `seq`; a commit that fails before the batch is durable never calls it. `adopt` is the Session's in-memory adoption of the batch, so memory never shows a partial log. Custom backends can run the shared conformance suite, which checks this too, with any Vitest- or Jest-compatible runner: ```typescript import { registerStorageConformance } from "@earendil-works/pi-durable/testing"; @@ -820,7 +813,7 @@ The package root loads TypeBox, because the tool task validates arguments with p Three rules decide what an error does: -1. **A storage error ends the Harness.** The call that hit it rejects with the error. Everything after it, and every pending wait, rejects with `SessionFailed` (its `cause` is that error); watches and event streams end with `session_failed`; running tasks get their abort signal; `onReport` gets the error once. The Harness then closes itself. Nothing is retried; a custom storage retries its own transient failures. Reopen to recover: commits are atomic, so the store is consistent, and resubmitting with the same `requestId` finds a submission that was already admitted. +1. **A storage error ends the Harness.** The call that hit it rejects with the error. Everything after it, and every pending wait, rejects with `SessionFailed` (its `cause` is that error); watches and event streams end with `failed`; running tasks get their abort signal; `onReport` gets the error once. The Harness then closes itself. Nothing is retried; a custom storage retries its own transient failures. Reopen to recover: commits are atomic, so the store is consistent, and resubmitting with the same `requestId` finds a submission that was already admitted. 2. **An invalid read fails only that call:** a storage read throws `StorageRequestError` for a bad cursor, an unknown conversation, or history a document does not keep. A read you cancelled fails only itself too. The exemption covers reads only: any error from `commit()` or `mintId()`, `StorageRequestError` included, ends the Harness as in rule 1. 3. **Your code fails only its own unit:** a throwing task phase faults that task, a throwing tool gets an error result, a throwing hook is reported and skipped (`beforeTool` blocks the call), and a throwing commit callback rolls back that commit. Listeners that throw, or return a rejected promise, are reported and the others still run. The exception is a host `RegistryReader` whose `snapshot()` throws: the Harness cannot schedule without it, so it fails the Harness like a storage error. diff --git a/packages/durable/docs/spec.md b/packages/durable/docs/spec.md index 3e206cf8e..99ebf614e 100644 --- a/packages/durable/docs/spec.md +++ b/packages/durable/docs/spec.md @@ -5,12 +5,12 @@ It uses existing package types as follows: ```ts import type { - AttachedReplicatedState, Context, Draft, JsonRepresentation, JsonValue, - ReplicatedState, + WatchEnd, + WatchHandle, } from "@earendil-works/chord"; import type { Op } from "@earendil-works/chord/delta"; import type { @@ -456,7 +456,7 @@ type HarnessSettings = { readonly followUpMode?: QueueMode; /** How long an idle conversation keeps its last context read in memory; busy ones always keep it. */ readonly contextRetentionMs?: number; - /** How long a loaded document no watch or document state observes stays in memory after its last use (section 4). */ + /** How long a loaded document no watch observes stays in memory after its last use (section 4). */ readonly documentRetentionMs?: number; /** What a failed run does with the inputs queued behind it (section 6). */ readonly afterFailedRun?: "keep" | "continue" | "withdraw"; @@ -668,7 +668,7 @@ interface Conversation { /** `{ background: true }` also aborts background work under the conversation (section 5.4). */ abort(context: Context, options?: { readonly background?: boolean }): Promise; waitForIdle(context: Context): Promise; - viewState(context: Context): Promise>; + /** The structural view (section 9.3) as a watch. */ watch(context: Context): Promise; } @@ -699,8 +699,7 @@ interface Harness extends Session { waitForIdle(context: Context): Promise; /** Session total of every conversation's `pi.usage` (section 8.6). */ usage(context: Context): Promise; - /** Live tasks as a structural view (section 9.5). */ - taskGraph(context: Context): Promise>; + /** Live tasks as a structural view (section 9.5), as a watch. */ watchTaskGraph(context: Context): Promise; } @@ -1035,17 +1034,15 @@ one to find out. Blocked reasons are derived, never stored. Queued and placed submissions complete the view. Finished tasks, settled submissions, transcripts, and documents use their own APIs. -`Conversation.viewState()` returns its current structural mount as a disposable -read-only Chord state. `Conversation.watch()` atomically captures that immutable -revision and registers for later exact complete-commit frames. Its `WatchHandle` -uses the same serialized asynchronous, bounded-buffer contract as `watchDoc()` -in section 9.2. Neither carries semantic events or owns a second persistence -authority. +`Conversation.watch()` atomically captures the immutable revision of its +current structural mount and registers for later exact complete-commit frames. +Its `WatchHandle` uses the same serialized asynchronous, bounded-buffer contract +as `watchDoc()` in section 9.2. It carries no semantic events and owns no second +persistence authority. `close()` seals mutation admission and task reservation, signals invocations, -and ends every document state, view state, and watch at that seal: a commit -that settles during close publishes nothing to them, and a state keeps its last -value. Outside the Session line it lets already-admitted storage commits settle, +and ends every watch at that seal (`ended`): a commit that settles during close +publishes nothing to them, and a watch keeps its last value. Outside the Session line it lets already-admitted storage commits settle, joins task, tool, and hook invocations, including code that ignores its signal, then closes Storage. Once `close()` resolves, no invocation code of this Harness runs and no handler holds Storage, so a new Harness may open the same Storage. @@ -1297,13 +1294,6 @@ interface Session extends DocumentObserver { snapshotAsOf(token: RewindableConversationDocToken, conversationId: ConversationId, at: EntryId, context: Context): Promise | undefined>; snapshotAsOf(token: RewindableConversationDocFamilyToken, conversationId: ConversationId, key: string, at: EntryId, context: Context): Promise | undefined>; - - documentState(token: SessionDocToken, context: Context): Promise | undefined>; - documentState(token: ConversationDocToken, conversationId: ConversationId, context: Context): Promise | undefined>; - documentState(token: TaskDocToken, taskId: TaskId, context: Context): Promise | undefined>; - documentState(token: SessionDocFamilyToken, key: string, context: Context): Promise | undefined>; - documentState(token: ConversationDocFamilyToken, conversationId: ConversationId, key: string, context: Context): Promise | undefined>; - documentState(token: TaskDocFamilyToken, taskId: TaskId, key: string, context: Context): Promise | undefined>; } interface Tx { @@ -1388,7 +1378,7 @@ registered and ordinary documents are not scanned at open. - The first acquisition of one logical address is memoized before awaiting. Later acquisitions in that transaction return the same draft; for a missing family, the first call's detached seed wins and later seeds are ignored. -- `snapshot()`, `snapshotAsOf()`, `documentState()`, and `watchDoc()` never create +- `snapshot()`, `snapshotAsOf()`, and `watchDoc()` never create or persist migration. They return `undefined` when the requested incarnation is absent and migrate a reconstructed value only in memory. - Task-scoped `tx.doc()` validates against the transaction's latest candidate task @@ -1405,7 +1395,7 @@ registered and ordinary documents are not scanned at open. - `snapshot()` returns the current shareable immutable revision. Mutation of it or any retained descendant is unsupported; callers that need mutable ownership must copy it first. -- `documentState()` returns a disposable read-only Chord state bound to one committed incarnation. +- `watchDoc()` returns a watch bound to one committed incarnation (section 9.2). - `tx.doc()` returns one revocable Astra overlay `Draft` for the transaction. - Historical reads never create documents in the past. @@ -1601,8 +1591,8 @@ no migrate -> reject older stored version ``` `migrate()` is pure and returns a complete current-version value. Migration is -access-driven: `tx.doc()`, `snapshot()`, `snapshotAsOf()`, `documentState()`, and -`watchDoc()` reconstruct and migrate through the token supplied to that call. +access-driven: `tx.doc()`, `snapshot()`, `snapshotAsOf()`, and `watchDoc()` +reconstruct and migrate through the token supplied to that call. Harness open does not sweep ordinary documents. - Read-only access migrates only in memory and never writes. It may cache a @@ -1666,8 +1656,8 @@ inside the creating transaction lazily reads the detached source, migrates when required, and replaces the copy with one ordinary child create containing the final prepared value. Definition-free copies publish explicit `document.copy` metadata rather than a value. That metadata announces Storage-backed initial -state and is never interpreted as a document value. Document states, watches, -and mounted views hydrate by capturing their baseline and subscription +state and is never interpreted as a document value. Watches and mounted views +hydrate by capturing their baseline and subscription atomically on the Session line: a later commit already present becomes the baseline, while one committed after registration is delivered. Publications and watches are convergence mechanisms, not audit streams. A mounted aggregate @@ -1678,7 +1668,7 @@ Session-line operation so it never exposes a mixture from one commit. The Session keeps the documents it loads or creates in memory, as Chord trackers, so reads and changes need no Storage access. The cache is bounded by -use, not by history: a document no watch or document state observes leaves it +use, not by history: a document no watch observes leaves it once it was not read or written for `documentRetentionMs` (default ten minutes; `0` drops it right after use), and the next access loads it from Storage. An observed document never leaves. Eviction runs on the mutation line, @@ -1696,8 +1686,14 @@ sets them for a plain Session; the Harness reads its settings at each use. A Session commit callback may be asynchronous. It owns the Session mutation line through callback execution, preparation, storage settlement, committed -baseline adoption, and publication enqueue. Commit observers run on the -line; document-state and watch user callbacks run later. +baseline adoption, and publication enqueue. Adoption runs inside +`Storage.commit()`: the Session passes an `adopt(seq)` callback, which the +storage calls synchronously once the batch is durable and before its next +commit (section 10), so memory always shows a complete prefix of the log. The +callback keeps its own error: a failed adoption fails the Session once +`Storage.commit()` returns. A commit that rejects after its adoption fails the +Session too, and its adopted changes stay adopted. Commit observers run on the +line; watch listeners run later. External model, process, tool, network, and human effects run outside it. `subscribeCommits()` observes complete immutable publications synchronously on the line after adoption. `subscribeClose()` observes close synchronously when it @@ -1706,8 +1702,8 @@ Harness ends waits, watches, and streams there, and signals task invocations after every close listener has run, so what a task holds ends with the Session's reason rather than as cancelled. Both return idempotent disposers; their listeners must not block or call Session APIs, and one that -throws is reported without affecting the others (section 10.1). Document-state -subscribers and watch listeners still run later, off the line. +throws is reported without affecting the others (section 10.1). Watch listeners +still run later, off the line. ```ts await session.commit(async tx => { @@ -4610,53 +4606,18 @@ aborted directly (section 8.3). ## 9. Document observation and Chord -### 9.1 Document state +### 9.1 Watches -Chord's existing replicated-state layer exposes this source-adoption contract: +Durable exposes committed state only through watches: Chord's `WatchHandle` +and its end type `WatchEnd`, exported by `@earendil-works/chord` (Durable +re-exports both): ```ts -interface ReplicatedStateSourceFrame { - readonly cursor: number; - readonly value: T; - readonly ops: readonly Op[]; - readonly context: Context; -} - -interface ReplicatedStateSourceAttachment { - /** Fixed immutable snapshot captured at the atomic attachment boundary. */ - readonly snapshot: { readonly value: T; readonly cursor: number }; - /** Install the sole listener and synchronously drain every buffered frame. */ - activate(listener: (frame: ReplicatedStateSourceFrame) => void): void; - dispose(): void; -} - -interface ReplicatedStateSource { - /** Atomically capture a snapshot and begin buffering every later commit. */ - attach(): ReplicatedStateSourceAttachment; -} - -interface ReplicatedStateSourceOptions { - readonly onError?: (error: Error) => void; -} - -interface AttachedReplicatedState extends ReplicatedState { - readonly value: T; - dispose(): void; -} - -function replicatedState( - source: ReplicatedStateSource, - options?: ReplicatedStateSourceOptions, -): AttachedReplicatedState; - -type DocumentState = - AttachedReplicatedState | null>; - type WatchEnd = - | { readonly reason: "stopped" | "cancelled" | "session_closed" | "retired" } + | { readonly reason: "stopped" | "cancelled" | "retired" | "ended" } | { readonly reason: "listener_error"; readonly error: Error } - /** The Session failed; `error` is what failed it, usually a storage error (section 10.1). */ - | { readonly reason: "session_failed"; readonly error: unknown }; + /** The source failed; for Durable, what failed the Session, usually a storage error (section 10.1). */ + | { readonly reason: "failed"; readonly error: unknown }; interface WatchHandle { /** Acquisition revision before start; latest delivered immutable revision afterward. */ @@ -4681,30 +4642,32 @@ interface DocumentObserver { } ``` -`documentState()` attaches Durable's committed document stream to Chord before -returning. The returned state is already hydrated with the exact shareable -immutable revision, has no mutation methods, and publishes every later exact -committed revision and operation batch without another tracker, value copy, or -re-diff. Disposing it unregisters only that observation; Durable remains the sole -document mutator. +A watch ends once, with the first reason that applies: `stopped` by `stop()`; +`cancelled` when its acquisition `Context` is cancelled; `retired` when its +document incarnation retired; `ended` when the Session closed; `listener_error` +when its listener threw or rejected; `failed` when the Session failed. -State and watch acquisition never create; absent lookup returns `undefined`. -Successful acquisition binds one concrete incarnation. Retirement publishes a -JSON `null` root replacement and ends that incarnation's stream. If the state -remains exposed, consumers see `null`, never stale state. A later recreation -requires acquiring a new state or watch. +Watch acquisition never creates; absent lookup returns `undefined`. Successful +acquisition binds one concrete incarnation. Retirement is an explicit event of +that incarnation, never inferred from a `null` value: it delivers a JSON `null` +root replacement `[["r", null]]` as the last frame, then ends the watch as +`retired`. A later recreation requires acquiring a new watch. -Session close ends every state at its seal: the state keeps its last value and +Session close ends every watch at its seal: the watch keeps its last value and receives no frame from a commit that settles during close. -Each document state assigns its own in-memory contiguous Chord delivery sequence. -Durable does not persist or expose that sequence through `WatchHandle`. States and watches retain immutable revisions independently of the loaded tracker -cache. Reopen creates a new state lifetime and hydration. +Termination drops every reference the watch holds into its source: its listener, +its subscriptions and the closures that detach, report, and replace, and its +cancellation hook. A stopped watch that is still referenced keeps only its last +value alive, never the Session or the listener; a callback already running keeps +what it needs until it settles. Watches retain immutable revisions independently +of the loaded tracker cache. Reopen creates a new watch lifetime. -Internally, state attachment uses Chord's synchronous atomic snapshot-and-register -boundary: the snapshot includes every commit before attachment, and every later -exact frame is buffered until activation. This prevents a snapshot from being -paired with operations based on a newer unseen revision. +Acquisition captures the value and registers for later commits atomically, on +the Session line: the acquisition value includes every commit before +acquisition, and every later exact frame is buffered until `start()`. This +prevents a value from being paired with operations based on a newer unseen +revision. ### 9.2 `watchDoc` @@ -4785,8 +4748,8 @@ type ConversationView = { These kinds and their fields are public protocol. Third-party documents are exposed through their own Chord objects, not mounted. -The Harness keeps at most one mount per conversation. The first `viewState()`, -`watch()`, or event attachment builds its revision on the Session line from the +The Harness keeps at most one mount per conversation. The first `watch()` or +event attachment builds its revision on the Session line from the committed active transcript and documents; the mount is dropped when its last observer detaches. A mount derives each next revision from the Session's `subscribeCommits()` publications, like `watchDoc()`: every publication is @@ -4813,9 +4776,8 @@ redundant nonempty batch remains a real publication. Mounted document revisions are structurally shared under the trusted immutability contract. The mount performs no semantic projection and owns no persistence. -`Conversation.viewState()` exposes the mount as a disposable read-only Chord -state. `Conversation.watch()` exposes the same mount through the serialized -exact-frame watch of section 9.2, with bounded pending frames and full-value +`Conversation.watch()` exposes the mount through the serialized exact-frame +watch of section 9.2, with bounded pending frames and full-value overflow replacements. ### 9.4 Agent events (experimental) @@ -5030,7 +4992,6 @@ type TaskGraph = { type TaskGraphWatch = WatchHandle; interface Harness { - taskGraph(context: Context): Promise>; watchTaskGraph(context: Context): Promise; } ``` @@ -5055,8 +5016,7 @@ node that lists it; a later task there is a top-level node of that conversation. A UI that places it under its parent reads the conversation's owner edge from `ConversationRecord.owner`, which `ConversationView.conversation` also carries. -The Harness keeps at most one graph mount. The first `taskGraph()` or -`watchTaskGraph()` builds its revision on the Session line from the committed +The Harness keeps at most one graph mount. The first `watchTaskGraph()` builds its revision on the Session line from the committed live tasks and the conversations they own, one owner scan per live task, and holds the line while it does; the mount is dropped when its last observer detaches. It advances from `subscribeCommits()` publications like the @@ -5065,14 +5025,13 @@ that sets or deletes `tasks[id]`, or sets a node's `conversations`. A publication that changes no node creates no revision. Task and conversation records are immutable values; a node shares nothing with them. -`taskGraph()` returns the mount as a disposable read-only Chord state. -`watchTaskGraph()` returns it through the serialized exact-frame watch of -section 9.2. Both read only: neither enables scheduling. Close ends them at its -seal like every other state and watch (section 2.2). +`watchTaskGraph()` returns the mount through the serialized exact-frame watch +of section 9.2. It reads only: it never enables scheduling. Close ends it at its +seal like every other watch (section 2.2). ```ts -const graph = await harness.taskGraph(context); -graph.subscribe((value) => { +const graph = await harness.watchTaskGraph(context); +graph.start(async (value) => { for (const node of Object.values(value.tasks)) { const where = node.owner === undefined ? `conversation ${node.conversationId}` : `task ${node.owner}`; render(`${node.id} ${node.kind} ${node.state.status} under ${where}`); @@ -5179,10 +5138,16 @@ type StorageWrite = * gaps. Once commit() resolves, later reads through that Storage observe it. */ interface Storage { - commit(writes: readonly StorageWrite[], context: Context): Promise; /** - * Allocate from the one global numeric namespace, above every ID minted or stored before, also across reopen: scans - * order by ID as creation order, and the Harness relies on it. The generic brand is compile-time only. + * Persist the batch atomically. Once it is durable, call `adopt(seq)` exactly once, synchronously and before this + * storage's next commit starts, then resolve with `seq`; a commit that fails before the batch is durable never calls + * it. `adopt` must not throw. + */ + commit(writes: readonly StorageWrite[], adopt: (seq: Seq) => void, context: Context): Promise; + /** + * Allocate from the one global numeric namespace, above every ID minted since this storage was opened and every ID + * stored, also across reopen: scans order by ID as creation order, and the Harness relies on it. The generic brand + * is compile-time only. */ mintId>(): Promise; @@ -5296,8 +5261,8 @@ may not have committed, so a host that resubmits after reopening reuses its and every wait that starts later rejects with `SessionFailed`, whose `cause` is the first error; so does every Storage call still underway when it returns, whatever it returned. -- Watches and event streams end with `{ reason: "session_failed", error }`; - attached replicated states stop updating and keep their last value. +- Watches and event streams end with `{ reason: "failed", error }` and keep + their last value. - Running task invocations get their abort signal. `HarnessOptions.onReport` gets the error once, after the Session is sealed, so a report handler that calls back finds it failed. @@ -5309,7 +5274,7 @@ may not have committed, so a host that resubmits after reopening reuses its ignores its abort signal keeps the backend open and `closed` pending. - A failing `Storage.close()` fails the Session too, unless it already failed; the `close()` call rejects with it, and `closed` settles `failed`. Waits and - watches had already ended as for a close (`session_closed`). + watches had already ended as for a close (`ended`). The exceptions fail only their call, and apply to reads alone: a `StorageRequestError`, which a Storage throws for an invalid read, with no @@ -5320,7 +5285,9 @@ exempt: once a batch is admitted, whether it committed is unknown. A commit callback that catches a failed read cannot commit afterwards: the Session checks its health after the callback. Adopting a committed batch in memory that -fails also fails the Session, since memory would be behind storage. +fails also fails the Session, since memory would be behind storage; so does a +`Storage.commit()` that resolves without adopting, or rejects after it adopted +(the adopted changes are not rolled back). **The Session's own bookkeeping fails it too.** A throw in a commit the scheduler makes for itself (reconciliation, reservation, a fault or terminal @@ -5339,8 +5306,8 @@ throw are reported and skipped, except `beforeTool`, whose throw blocks the call a commit callback, `conversationCreated`, `init`, or a document's `initial`, `migrate`, or `checkpointWhen` that throws rolls its commit back and rejects it. Callbacks that must not fail their caller are contained: a host -`subscribeCommits()` or `subscribeClose()` listener, a watch listener, a -replicated-state subscriber, and a view observer that throws, or returns a +`subscribeCommits()` or `subscribeClose()` listener, a watch listener, and a +view observer that throws, or returns a promise that rejects, are reported, and the others still run; a watch whose listener throws ends with `listener_error`. A throwing or rejecting `onReport` is dropped, since nothing is left to report it to. A throwing `now()` is reported @@ -5521,13 +5488,13 @@ These are contracts, not invitations to add defensive machinery: - **Long transactions:** an async commit callback holds the Session mutation line. Never await models, tools, processes, network calls, humans, a nested Session commit, or a Session waiter inside it. Use methods on the current `Tx`. -- **Reads inside a commit:** `snapshot()`, `documentState()`, `watchDoc()`, +- **Reads inside a commit:** `snapshot()`, `watchDoc()`, `getTask()`, and the other Session, Harness, and runtime reads may queue on the mutation line, for example to load a document not in memory. Inside a commit callback, which holds that line, such a read never resolves, and the line stays held. Only a cache hit returns, so the hang is intermittent. Read through `tx`. -- **Explicit creation:** only typed `tx.doc()` creates an absent document. Snapshot, - state, and watch lookup return `undefined` instead. +- **Explicit creation:** only typed `tx.doc()` creates an absent document. Snapshot + and watch lookup return `undefined` instead. - **Family initialization:** the first acquisition of an absent family address selects its seed. Existing instances and later calls ignore seeds; a seed is neither identity nor an update. diff --git a/packages/durable/src/harness/events.ts b/packages/durable/src/harness/events.ts index 3f19da135..b04c6a4e2 100644 --- a/packages/durable/src/harness/events.ts +++ b/packages/durable/src/harness/events.ts @@ -1,4 +1,4 @@ -import type { Context, JsonValue } from "@earendil-works/chord"; +import type { Context, JsonValue, WatchEnd } from "@earendil-works/chord"; import type { Op, Path } from "@earendil-works/chord/delta"; import type { AssistantMessage, Message, Usage } from "@earendil-works/pi-ai"; import { CommittedWatch } from "../session/observation.ts"; @@ -11,7 +11,6 @@ import type { SubmissionId, SubmissionRecord, TaskId, - WatchEnd, } from "../types.ts"; import { AgentDoc } from "./agent.ts"; import type { InboxItem, InboxState } from "./inbox.ts"; @@ -193,7 +192,7 @@ export async function watchEvents( const events = translate(conversationId, before, after, ops, publication, held); if (events.length > 0) watch.advance(events, [], commitContext); }, - closeSession: (failure) => watch.closeSession(failure), + end: (failure) => watch.end(failure), }; }, context, diff --git a/packages/durable/src/harness/harness.ts b/packages/durable/src/harness/harness.ts index e7f19dfea..7b315be5f 100644 --- a/packages/durable/src/harness/harness.ts +++ b/packages/durable/src/harness/harness.ts @@ -1,4 +1,4 @@ -import type { AttachedReplicatedState, Context, JsonValue } from "@earendil-works/chord"; +import type { Context, JsonValue } from "@earendil-works/chord"; import { withAbortSignal, withoutAbortSignal } from "@earendil-works/chord/context"; import type { Models } from "@earendil-works/pi-ai"; import { @@ -45,7 +45,7 @@ import { ProviderDoc } from "./provider.ts"; import { BUILTIN_TASKS } from "./registry.ts"; import { type InvocationBinding, TaskScheduler } from "./scheduler.ts"; import { Submissions } from "./submissions.ts"; -import { type TaskGraph, TaskGraphView, type TaskGraphWatch } from "./task-graph.ts"; +import { TaskGraphView, type TaskGraphWatch } from "./task-graph.ts"; import { DURABLE_TELEMETRY_SCHEMA } from "./telemetry.ts"; import type { Agent, @@ -71,7 +71,7 @@ import type { } from "./types.ts"; import { addUsageState, UsageDoc, type UsageState } from "./usage.ts"; import { scanAll } from "./util.ts"; -import { type ConversationView, ConversationViews } from "./view.ts"; +import { ConversationViews } from "./view.ts"; const SCAN_PAGE_SIZE = 256; @@ -175,10 +175,6 @@ class ConversationImpl implements Conversation { return this.#host.tasks.waitForIdle(this.id, context); } - viewState(context: Context): Promise> { - return this.#host.views.state(this.id, context); - } - watch(context: Context): Promise { return this.#host.views.watch(this.id, context); } @@ -364,10 +360,6 @@ class HarnessImpl extends SessionImpl implements return total; } - taskGraph(context: Context): Promise> { - return this.#taskGraph.state(context); - } - watchTaskGraph(context: Context): Promise { return this.#taskGraph.watch(context); } diff --git a/packages/durable/src/harness/task-graph.ts b/packages/durable/src/harness/task-graph.ts index 0c3ff464c..d7fd62ae1 100644 --- a/packages/durable/src/harness/task-graph.ts +++ b/packages/durable/src/harness/task-graph.ts @@ -1,7 +1,7 @@ -import { type AttachedReplicatedState, type Context, type JsonValue, replicatedState } from "@earendil-works/chord"; +import type { Context, JsonValue, WatchHandle } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT, withoutAbortSignal } from "@earendil-works/chord/context"; import { applyImmutable, type Op } from "@earendil-works/chord/delta"; -import { CommittedStateSource, CommittedWatch } from "../session/observation.ts"; +import { CommittedWatch } from "../session/observation.ts"; import { contained, type SessionImpl } from "../session/session.ts"; import type { CommitPublication, @@ -11,7 +11,6 @@ import type { TaskId, TaskOutcome, TaskRecord, - WatchHandle, } from "../types.ts"; import { type BlockedReason, type BlockedSubject, blockedSubject } from "./scheduler.ts"; import { closedError, scanAll } from "./util.ts"; @@ -57,7 +56,7 @@ export type TaskGraph = { export type TaskGraphWatch = WatchHandle; type AnyTaskRecord = TaskRecord; -type Observer = CommittedStateSource | CommittedWatch; +type Observer = CommittedWatch; type Mount = { value: TaskGraph; readonly observers: Set; @@ -98,7 +97,7 @@ export class TaskGraphView { this.#closed = true; for (const observer of [...(this.#mount?.observers ?? [])]) { contained( - () => observer.closeSession(this.#session.failure), + () => observer.end(this.#session.failure), (error) => session.report(error), ); } @@ -106,20 +105,6 @@ export class TaskGraphView { }); } - /** A disposable read-only Chord state of the graph. */ - async state(context: Context): Promise> { - const { observer, detach } = await this.#attach( - (value, release) => new CommittedStateSource(value, release), - context, - ); - try { - return replicatedState(observer, { onError: (error) => this.#session.report(error) }); - } catch (error) { - detach(); - throw error; - } - } - /** A serialized exact-frame watch of the graph; cancelling `context` stops it. */ async watch(context: Context): Promise { const { observer } = await this.#attach( diff --git a/packages/durable/src/harness/types.ts b/packages/durable/src/harness/types.ts index 289b5c4e1..6935fb797 100644 --- a/packages/durable/src/harness/types.ts +++ b/packages/durable/src/harness/types.ts @@ -1,4 +1,4 @@ -import type { AttachedReplicatedState, Context, JsonValue } from "@earendil-works/chord"; +import type { Context, JsonValue, WatchHandle } from "@earendil-works/chord"; import type { AssistantMessage, CacheRetention, @@ -41,9 +41,8 @@ import type { TaskRecord, TaskState, Tx, - WatchHandle, } from "../types.ts"; -import type { TaskGraph, TaskGraphWatch } from "./task-graph.ts"; +import type { TaskGraphWatch } from "./task-graph.ts"; import type { UsageState } from "./usage.ts"; import type { ConversationView } from "./view.ts"; @@ -558,7 +557,7 @@ export type HarnessSettings = { */ readonly contextRetentionMs?: number; /** - * How long a loaded document that no watch or document state observes stays in memory after its last read or write; + * How long a loaded document that no watch observes stays in memory after its last read or write; * the next access loads it from Storage. Observed documents always stay. Default ten minutes; `0` drops one right * after use; `Infinity` keeps them. Anything else that is not a number of milliseconds means the default. */ @@ -771,9 +770,7 @@ export interface Conversation { * owned, transitively, by its non-background tasks. */ waitForIdle(context: Context): Promise; - /** The structural view (spec §9.3) as a disposable read-only Chord state. */ - viewState(context: Context): Promise>; - /** The structural view as a serialized exact-frame watch with bounded pending frames. */ + /** The structural view (spec §9.3) as a serialized exact-frame watch with bounded pending frames. */ watch(context: Context): Promise; } @@ -830,9 +827,10 @@ export interface Harness extends Session { waitForIdle(context: Context): Promise; /** Session total: every conversation's `pi.usage` summed. */ usage(context: Context): Promise; - /** Every live task with its owner edge, status, and owned conversations (spec §9.5), as a disposable Chord state. */ - taskGraph(context: Context): Promise>; - /** The task graph as a serialized exact-frame watch with bounded pending frames. */ + /** + * Every live task with its owner edge, status, and owned conversations (spec §9.5), as a serialized exact-frame + * watch with bounded pending frames. + */ watchTaskGraph(context: Context): Promise; } diff --git a/packages/durable/src/harness/view.ts b/packages/durable/src/harness/view.ts index c109c1629..813d10ebb 100644 --- a/packages/durable/src/harness/view.ts +++ b/packages/durable/src/harness/view.ts @@ -1,7 +1,7 @@ -import { type AttachedReplicatedState, type Context, type JsonValue, replicatedState } from "@earendil-works/chord"; +import type { Context, JsonValue, WatchHandle } from "@earendil-works/chord"; import { withoutAbortSignal } from "@earendil-works/chord/context"; import { applyImmutable, type NonEmptyPath, type Op, type Path } from "@earendil-works/chord/delta"; -import { CommittedStateSource, CommittedWatch } from "../session/observation.ts"; +import { CommittedWatch } from "../session/observation.ts"; import { contained, type SessionImpl } from "../session/session.ts"; import type { CommitPublication, @@ -12,7 +12,6 @@ import type { EntryRecord, JsonObject, Storage, - WatchHandle, } from "../types.ts"; import { AgentDoc } from "./agent.ts"; import { activeEntries, captureContextBounds } from "./context.ts"; @@ -42,7 +41,8 @@ export type ViewObserver = { publication: CommitPublication, context: Context, ): void; - closeSession(failure: { readonly error: unknown } | undefined): void; + /** The Session closed, or failed with `failure`. */ + end(failure: { readonly error: unknown } | undefined): void; }; const MOUNTED = [ @@ -94,7 +94,7 @@ export class ConversationViews { for (const mount of this.#mounts.values()) for (const observer of [...mount.observers]) { contained( - () => observer.closeSession(this.#session.failure), + () => observer.end(this.#session.failure), (error) => this.report(error), ); } @@ -107,21 +107,6 @@ export class ConversationViews { this.#session.report(error); } - /** A disposable read-only Chord state of the view. */ - async state(id: ConversationId, context: Context): Promise> { - const { observer, detach } = await this.attach( - id, - (value, release) => new CommittedStateSource(value, release), - context, - ); - try { - return replicatedState(observer, { onError: (error) => this.#session.report(error) }); - } catch (error) { - detach(); - throw error; - } - } - /** A serialized exact-frame watch of the view; cancelling `context` stops it. */ async watch(id: ConversationId, context: Context): Promise> { const { observer } = await this.attach( diff --git a/packages/durable/src/index.ts b/packages/durable/src/index.ts index 95d9606ae..5012b44d1 100644 --- a/packages/durable/src/index.ts +++ b/packages/durable/src/index.ts @@ -1,3 +1,4 @@ +export type { WatchEnd, WatchHandle } from "@earendil-works/chord"; export { defineDoc, defineDocFamily } from "./documents.ts"; export { AssistantEntry, @@ -155,7 +156,6 @@ export type { DocumentReader, DocumentRecord, DocumentSemantics, - DocumentState, DocumentWatch, Entry, EntryDraft, @@ -210,7 +210,5 @@ export type { Tx, TypedEntry, TypedEntryDraft, - WatchEnd, - WatchHandle, } from "./types.ts"; export { ROOT_CONVERSATION_ID } from "./types.ts"; diff --git a/packages/durable/src/session/observation.ts b/packages/durable/src/session/observation.ts index 2edfb7465..a2adda0ab 100644 --- a/packages/durable/src/session/observation.ts +++ b/packages/durable/src/session/observation.ts @@ -1,167 +1,41 @@ -import type { - Context, - JsonValue, - ReplicatedStateSource, - ReplicatedStateSourceAttachment, - ReplicatedStateSourceFrame, -} from "@earendil-works/chord"; +import type { Context, JsonValue, WatchEnd, WatchHandle } from "@earendil-works/chord"; import { withoutAbortSignal } from "@earendil-works/chord/context"; import type { Op } from "@earendil-works/chord/delta"; import { errorMessage } from "../errors.ts"; -import type { JsonObject, WatchEnd, WatchHandle } from "../types.ts"; +import type { JsonObject } from "../types.ts"; export type ObservedDocumentValue = Readonly | null; /** Maximum exact committed frames retained behind one unavailable watch listener. */ const MAX_PENDING_WATCH_FRAMES = 100; -/** Canonical terminal update for a retired document incarnation. */ -export const RETIREMENT_OPERATIONS: readonly Op[] = [["r", null]]; - -/** - * Session-to-Chord bridge owned one-to-one by one attached state: a document, or a conversation view. A `null` value - * retires it. @internal - */ -export class CommittedStateSource implements ReplicatedStateSource { - readonly #attachments = new Set>(); - #release: (() => void) | undefined; - /** Released on disposal. */ - #value: T | undefined; - #cursor = 0; - #retired = false; - #closed = false; - - constructor(value: T, release: () => void) { - this.#value = value; - this.#release = release; - } - - attach(): ReplicatedStateSourceAttachment { - if (this.#closed) throw new Error("State source is closed"); - const attachment = new SessionSourceAttachment({ value: this.#value as T, cursor: this.#cursor }, () => { - this.#attachments.delete(attachment); - if (this.#attachments.size === 0) this.#finishDisposal(); - }); - this.#attachments.add(attachment); - return attachment; - } - - advance(value: T, ops: readonly Op[], context: Context): void { - if (this.#closed || this.#retired) return; - this.#value = value; - this.#cursor += 1; - if (value === null) this.#retired = true; - const frame: ReplicatedStateSourceFrame = { - cursor: this.#cursor, - value, - ops, - context, - }; - for (const attachment of [...this.#attachments]) attachment.publish(frame); - } - - /** Stop publishing; the attached states keep their last value. A failure ends them the same way. */ - closeSession(): void { - if (this.#closed) return; - for (const attachment of [...this.#attachments]) attachment.dispose(); - this.#finishDisposal(); - } - - #finishDisposal(): void { - if (this.#closed) return; - this.#closed = true; - this.#value = undefined; - const release = this.#release; - this.#release = undefined; - release?.(); - } -} - -class SessionSourceAttachment implements ReplicatedStateSourceAttachment { - readonly snapshot: { readonly value: T; readonly cursor: number }; - #release: (() => void) | undefined; - readonly #frames: ReplicatedStateSourceFrame[] = []; - #listener: ((frame: ReplicatedStateSourceFrame) => void) | undefined; - #activated = false; - #disposed = false; - #scheduled = false; - #delivering = false; - - constructor(snapshot: { readonly value: T; readonly cursor: number }, release: () => void) { - this.snapshot = snapshot; - this.#release = release; - } - - activate(listener: (frame: ReplicatedStateSourceFrame) => void): void { - if (this.#activated) throw new Error("State attachment is already active"); - if (this.#disposed) throw new Error("State attachment is disposed"); - this.#activated = true; - this.#listener = listener; - this.#drain(); - } - - publish(frame: ReplicatedStateSourceFrame): void { - if (this.#disposed) return; - this.#frames.push(frame); - if (!this.#activated || this.#delivering || this.#scheduled) return; - this.#scheduled = true; - queueMicrotask(() => { - this.#scheduled = false; - if (this.#disposed) return; - try { - this.#drain(); - } catch { - // Chord's frame listener contains source-contract failures. Isolate an - // unexpected direct listener failure to this attachment as well. - this.dispose(); - } - }); - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#frames.length = 0; - this.#listener = undefined; - const release = this.#release; - this.#release = undefined; - release?.(); - } - - #drain(): void { - const listener = this.#listener; - if (listener === undefined || this.#delivering || this.#disposed) return; - this.#delivering = true; - try { - while (!this.#disposed) { - const frame = this.#frames.shift(); - if (frame === undefined) break; - listener(frame); - } - } finally { - this.#delivering = false; - } - } -} +/** Canonical terminal frame of a retired document incarnation. */ +const RETIREMENT_OPERATIONS: readonly Op[] = [["r", null]]; type WatchFrame = { readonly value: T; readonly ops: readonly Op[]; readonly context: Context; + /** The retirement frame: the watch ends `retired` once it is delivered. */ + readonly retires?: true; }; +type WatchListener = (value: T, ops: readonly Op[], context: Context) => Promise; + /** - * Serialized exact-frame watch bound to one document incarnation or conversation view. A `null` value retires it. + * Serialized exact-frame watch bound to one document incarnation or conversation view. Its source ends it explicitly: + * `retire()` for a retired document, `end()` for a closed or failed Session. Termination drops every reference the + * watch holds into its source, so a stopped watch that is still referenced keeps nothing but its last value alive. * @internal */ export class CommittedWatch implements WatchHandle { - readonly #detach: () => void; - readonly #report: (error: unknown) => void; - readonly #replace: (() => T) | undefined; + #detach: (() => void) | undefined; + #report: ((error: unknown) => void) | undefined; + #replace: (() => T) | undefined; readonly #closed = Promise.withResolvers(); readonly #pending: WatchFrame[] = []; #value: T; - #listener: ((value: T, ops: readonly Op[], context: Context) => Promise) | undefined; + #listener: WatchListener | undefined; #started = false; #scheduled = false; #running = false; @@ -190,7 +64,7 @@ export class CommittedWatch implements WatchHandle return this.#closed.promise; } - start(listener: (value: T, ops: readonly Op[], context: Context) => Promise): void { + start(listener: WatchListener): void { if (this.#started) throw new Error("Watch is already started"); if (this.#end !== undefined) throw new Error("Watch is stopped"); this.#started = true; @@ -216,16 +90,13 @@ export class CommittedWatch implements WatchHandle this.#terminate({ reason: "cancelled" }); } - /** End with `session_closed`, or with `session_failed` and the error when the Session failed. */ - closeSession(failure?: { readonly error: unknown }): void { - this.#terminate( - failure === undefined ? { reason: "session_closed" } : { reason: "session_failed", error: failure.error }, - ); + /** The source closed: end with `ended`, or with `failed` and the error when the Session failed. */ + end(failure?: { readonly error: unknown }): void { + this.#terminate(failure === undefined ? { reason: "ended" } : { reason: "failed", error: failure.error }); } advance(value: T, ops: readonly Op[], context: Context): void { if (this.#end !== undefined || this.#retired) return; - if (value === null) this.#retired = true; if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) { this.#pending.length = 0; const replacement = this.#replace?.() ?? value; @@ -236,6 +107,18 @@ export class CommittedWatch implements WatchHandle if (this.#started) this.#schedule(); } + /** + * The document incarnation retired: queue the last frame, `[["r", null]]` with value `null`, after which the watch + * ends `retired`. An overflowing backlog is dropped, since the frame replaces the root anyway. + */ + retire(this: CommittedWatch, context: Context): void { + if (this.#end !== undefined || this.#retired) return; + this.#retired = true; + if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) this.#pending.length = 0; + this.#pending.push({ value: null, ops: RETIREMENT_OPERATIONS, context, retires: true }); + if (this.#started) this.#schedule(); + } + #schedule(): void { if (this.#scheduled || this.#running || this.#end !== undefined) return; this.#scheduled = true; @@ -251,19 +134,20 @@ export class CommittedWatch implements WatchHandle try { while (this.#end === undefined) { const frame = this.#pending.shift(); - if (frame === undefined) break; + const listener = this.#listener; + if (frame === undefined || listener === undefined) break; this.#value = frame.value; const deliveryContext = withoutAbortSignal(frame.context); try { - await this.#listener!(frame.value, frame.ops, deliveryContext); + await listener(frame.value, frame.ops, deliveryContext); } catch (error) { if (this.#end === undefined) { - this.#report(error); + this.#report?.(error); this.#terminate({ reason: "listener_error", error: toError(error) }); } break; } - if (frame.value === null) { + if (frame.retires === true) { this.#terminate({ reason: "retired" }); break; } @@ -274,15 +158,25 @@ export class CommittedWatch implements WatchHandle } } - /** End the watch, once: detach, drop pending frames, and settle `closed`. A callback already running keeps running. */ + /** + * End the watch, once: detach, drop pending frames and every reference into the source, and settle `closed`. A + * callback already running keeps running. + */ #terminate(end: WatchEnd): void { if (this.#end !== undefined) return; this.#end = end; - this.#detach(); + const detach = this.#detach; + this.#detach = undefined; + this.#report = undefined; + this.#replace = undefined; + this.#listener = undefined; this.#pending.length = 0; if (this.#cancellationListener !== undefined) { this.#cancellationSignal?.removeEventListener("abort", this.#cancellationListener); } + this.#cancellationSignal = undefined; + this.#cancellationListener = undefined; + detach?.(); this.#closed.resolve(end); } } diff --git a/packages/durable/src/session/session.ts b/packages/durable/src/session/session.ts index b22e9c56a..d87507e54 100644 --- a/packages/durable/src/session/session.ts +++ b/packages/durable/src/session/session.ts @@ -1,4 +1,4 @@ -import { type Context, type JsonValue, replicatedState } from "@earendil-works/chord"; +import type { Context, JsonValue } from "@earendil-works/chord"; import { awaitWithContext, BACKGROUND_CONTEXT, withoutAbortSignal } from "@earendil-works/chord/context"; import { type Op, track } from "@earendil-works/chord/delta"; import { @@ -27,7 +27,6 @@ import type { DocumentPoint, DocumentQuery, DocumentRecord, - DocumentState, DocumentWatch, EntryId, EntryQuery, @@ -57,12 +56,7 @@ import type { TaskRecord, Tx, } from "../types.ts"; -import { - CommittedStateSource, - CommittedWatch, - type ObservedDocumentValue, - RETIREMENT_OPERATIONS, -} from "./observation.ts"; +import { CommittedWatch, type ObservedDocumentValue } from "./observation.ts"; import { type LoadedDocument, Transaction, type TransactionHost, type TransactionScope } from "./transaction.ts"; /** The default source of a report: a listener, which never fails what ran it. */ @@ -105,7 +99,7 @@ export function documentRetention(ms: number | undefined): number { /** * Session kernel: one mutation line, the loaded document tracker cache, and committed publication. * - * The cache is bounded by use: a document with no watch or document state attached leaves it once it was not read or + * The cache is bounded by use: a document with no watch attached leaves it once it was not read or * written for the retention period, and the next access loads it from Storage. Eviction runs as a job on the mutation * line, so no transaction ever holds a tracker that leaves. * @@ -322,64 +316,6 @@ export class SessionImpl implements Session { return loaded.tracker.value; } - documentState( - token: SessionDocToken, - context: Context, - ): Promise | undefined>; - documentState( - token: ConversationDocToken, - conversationId: ConversationId, - context: Context, - ): Promise | undefined>; - documentState( - token: TaskDocToken, - taskId: TaskId, - context: Context, - ): Promise | undefined>; - documentState( - token: SessionDocFamilyToken, - key: string, - context: Context, - ): Promise | undefined>; - documentState( - token: ConversationDocFamilyToken, - conversationId: ConversationId, - key: string, - context: Context, - ): Promise | undefined>; - documentState( - token: TaskDocFamilyToken, - taskId: TaskId, - key: string, - context: Context, - ): Promise | undefined>; - documentState(token: AnyDocToken, ...args: readonly unknown[]): Promise | undefined> { - try { - this.assertUsable(); - const definition = token.definition; - const resolved = resolveAddress(definition, args); - const context = args[resolved.nextArgument] as Context; - return this.#enqueue(async () => { - this.#assertHealthy(); - const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context); - if (loaded === undefined) return undefined; - const { observer: source, detach } = this.#attachDocument( - definition, - loaded, - (value, release) => new CommittedStateSource(value, release), - ); - try { - return replicatedState(source, { onError: (error) => this.report(error) }) as DocumentState; - } catch (error) { - detach(); - throw error; - } - }); - } catch (error) { - return Promise.reject(error); - } - } - watchDoc(token: SessionDocToken, context: Context): Promise | undefined>; watchDoc( token: ConversationDocToken, @@ -426,12 +362,7 @@ export class SessionImpl implements Session { const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context); if (cancelled) throw cancellationError(signal!); if (loaded === undefined) return undefined; - return this.#attachDocument( - definition, - loaded, - (value, release) => - new CommittedWatch(value, release, (error) => this.report(error)), - ).observer; + return this.#attachDocument(definition, loaded); }); if (watch === undefined) return undefined; if (cancelled) { @@ -560,7 +491,7 @@ export class SessionImpl implements Session { return this.#enqueue(async () => { if (this.#failure !== undefined) return; const batch = writes(); - if (batch.length > 0) await this.#storage.commit(batch, BACKGROUND_CONTEXT); + if (batch.length > 0) await this.#storage.commit(batch, () => {}, BACKGROUND_CONTEXT); }); } @@ -744,23 +675,36 @@ export class SessionImpl implements Session { this.#sweepDocuments(); return result; } + // Storage adopts the batch synchronously once it is durable, before its next commit; a throw here would reach + // Storage, so the callback keeps its own error. + let adoption: { readonly documents: DocumentCommitChange[] } | { readonly error: unknown } | undefined; + const adopt = (committed: Seq): void => { + try { + adoption = { documents: tx.adopt(committed) }; + } catch (error) { + adoption = { error }; + } + }; let seq: Seq; try { // Once admitted, caller cancellation does not interrupt Storage settlement. - seq = await this.#storage.commit(writes, withoutAbortSignal(context)); + seq = await this.#storage.commit(writes, adopt, withoutAbortSignal(context)); } catch (error) { - // The guard has failed the Session. - tx.discard(); + // The guard has failed the Session. A rejection after adoption leaves nothing staged to discard. + if (adoption === undefined) tx.discard(); throw error; } - let documents: DocumentCommitChange[]; - try { - documents = tx.adopt(seq); - } catch (error) { - // Storage already committed; a failed adoption leaves memory behind durable state. + if (adoption === undefined) { + const error = new Error("Storage committed without adopting the batch"); this.fail(error); throw error; } + if ("error" in adoption) { + // Storage already committed; a failed adoption leaves memory behind durable state. + this.fail(adoption.error); + throw adoption.error; + } + const documents = adoption.documents; for (const document of documents) this.#touchDocument(addressId(document.record)); this.#publish(seq, writes, documents, context); this.#sweepDocuments(); @@ -818,14 +762,13 @@ export class SessionImpl implements Session { } /** - * Attach an observer to one committed incarnation: check the definition, then forward this incarnation's committed - * changes and close. `detach` removes both subscriptions. + * Attach a watch to one committed incarnation: check the definition, then forward this incarnation's committed + * changes, its retirement, and close. The watch's detach removes both subscriptions. */ - #attachDocument( + #attachDocument( definition: AnyDocToken["definition"], loaded: LoadedDocument, - create: (value: JsonObject, detach: () => void) => O, - ): { observer: O; detach: () => void } { + ): CommittedWatch { checkRecordScope(definition, loaded.record); checkRecordVersion(definition, loaded.record, loaded.storedVersion); let unsubscribeCommit = (): void => {}; @@ -837,29 +780,27 @@ export class SessionImpl implements Session { unsubscribeClose(); unpin(); }; - let observer: O; - try { - observer = create(loaded.tracker.value, detach); - } catch (error) { - unpin(); - throw error; - } + const observer = new CommittedWatch(loaded.tracker.value, detach, (error) => + this.report(error), + ); const observed = { version: loaded.valueVersion }; unsubscribeCommit = this.observeCommits((publication, context) => { for (const change of publication.changes) { if (change.type !== "document" || change.record.id !== loaded.record.id) continue; - // A document state's frames carry no caller cancellation; a watch observes its own cancellation. - const frameContext = observer instanceof CommittedStateSource ? withoutAbortSignal(context) : context; + if (change.value === null) { + // A retired incarnation never changes again: its watch delivers the retirement but pins nothing. + unpin(); + observer.retire(context); + continue; + } const ops = observedOperations(observed, change); // A migration-only base changes nothing for an observer of the new version. if (ops.length === 0) continue; - // A retired incarnation never changes again: its observers keep their last value but pin nothing. - if (change.value === null) unpin(); - observer.advance(change.value, ops, frameContext); + observer.advance(change.value, ops, context); } }); - unsubscribeClose = this.subscribeClose(() => observer.closeSession(this.#failure)); - return { observer, detach }; + unsubscribeClose = this.subscribeClose(() => observer.end(this.#failure)); + return observer; } async #loadDocument( @@ -935,7 +876,6 @@ function observedOperations( observed: { version: number }, change: Extract, ): readonly Op[] { - if (change.value === null) return RETIREMENT_OPERATIONS; if (change.version === observed.version) return change.ops; observed.version = change.version!; return [["r", change.value]]; @@ -971,8 +911,8 @@ class GuardedStorage implements Storage { } /** Never exempt, whatever it throws: once admitted, whether a failed batch committed is unknown. */ - commit(writes: readonly StorageWrite[], context: Context): Promise { - return this.#call(undefined, () => this.#storage.commit(writes, context)); + commit(writes: readonly StorageWrite[], adopt: (seq: Seq) => void, context: Context): Promise { + return this.#call(undefined, () => this.#storage.commit(writes, adopt, context)); } mintId>(): Promise { diff --git a/packages/durable/src/storage/indexeddb.ts b/packages/durable/src/storage/indexeddb.ts index 6b2121444..8dbc255bb 100644 --- a/packages/durable/src/storage/indexeddb.ts +++ b/packages/durable/src/storage/indexeddb.ts @@ -417,10 +417,10 @@ export class IndexedDbStorage implements Storage { await requestResult(factory.deleteDatabase(name)); } - async commit(writes: readonly StorageWrite[], _context: Context): Promise { + async commit(writes: readonly StorageWrite[], adopt: (seq: Seq) => void, _context: Context): Promise { this.assertOpen(); const documentActions = this.prepareDocumentActions(writes); - return this.transaction(ALL_STORES, "readwrite", async (transaction) => { + const seq = await this.transaction(ALL_STORES, "readwrite", async (transaction) => { const metaStore = transaction.objectStore(STORES.meta); const metadata = await requestResult(metaStore.get(META_KEY) as IDBRequest); if (metadata === undefined) throw new Error("Durable IndexedDB metadata is missing"); @@ -437,6 +437,9 @@ export class IndexedDbStorage implements Storage { await requestResult(metaStore.put({ key: META_KEY, nextId, nextSeq: seq + 1 } satisfies MetaRow)); return seq; }); + // The transaction completed: the batch is durable. + adopt(seq); + return seq; } async mintId>(): Promise { diff --git a/packages/durable/src/storage/jsonl/storage.ts b/packages/durable/src/storage/jsonl/storage.ts index 4b03fd5be..56b219e00 100644 --- a/packages/durable/src/storage/jsonl/storage.ts +++ b/packages/durable/src/storage/jsonl/storage.ts @@ -275,7 +275,7 @@ export class JsonlStorage implements Storage { return storage; } - async commit(writes: readonly StorageWrite[], context: Context): Promise { + async commit(writes: readonly StorageWrite[], adopt: (seq: Seq) => void, context: Context): Promise { this.assertUsable(); const prepared = this.memory.prepareCommit(writes); const encoded = this.encodeCommit(prepared.seq, prepared.writes); @@ -302,6 +302,8 @@ export class JsonlStorage implements Storage { if (!marker.ok) throw this.poison(errorFromFile(`append to ${MAIN_FILE}`, marker.error)); const seq = prepared.apply(); this.adoptSidecarState(prepared.writes); + // The marker is appended: the batch is durable. Reclaiming sidecars comes after adoption. + adopt(seq); await this.reclaimSidecars(reclamations, context); return seq; } diff --git a/packages/durable/src/storage/memory.ts b/packages/durable/src/storage/memory.ts index 7f379cc29..dd72b2e3a 100644 --- a/packages/durable/src/storage/memory.ts +++ b/packages/durable/src/storage/memory.ts @@ -260,8 +260,10 @@ export class MemoryStorage implements Storage { private nextSeq = 1; private closed = false; - async commit(writes: readonly StorageWrite[], _context: Context): Promise { - return this.prepareCommit(writes).apply(); + async commit(writes: readonly StorageWrite[], adopt: (seq: Seq) => void, _context: Context): Promise { + const seq = this.prepareCommit(writes).apply(); + adopt(seq); + return seq; } /** Validate and detach one commit without changing observable state. */ diff --git a/packages/durable/src/storage/sqlite/storage.ts b/packages/durable/src/storage/sqlite/storage.ts index 7dc590382..5576363ed 100644 --- a/packages/durable/src/storage/sqlite/storage.ts +++ b/packages/durable/src/storage/sqlite/storage.ts @@ -184,7 +184,7 @@ export class SqliteStorage implements Storage { } } - async commit(writes: readonly StorageWrite[], _context: Context): Promise { + async commit(writes: readonly StorageWrite[], adopt: (seq: Seq) => void, _context: Context): Promise { this.assertOpen(); const documentActions = this.prepareDocumentActions(writes); const candidateNextId = this.candidateNextId(writes); @@ -206,6 +206,8 @@ export class SqliteStorage implements Storage { return committedSeq; }); this.nextId = Math.max(this.nextId, candidateNextId); + // `COMMIT` returned: the batch is durable. + adopt(seq); return seq; } diff --git a/packages/durable/src/testing/storage-benchmark.ts b/packages/durable/src/testing/storage-benchmark.ts index 28c7a37d1..d8e0e3ad4 100644 --- a/packages/durable/src/testing/storage-benchmark.ts +++ b/packages/durable/src/testing/storage-benchmark.ts @@ -14,6 +14,9 @@ import type { } from "../types.ts"; import { ROOT_CONVERSATION_ID } from "../types.ts"; +/** Storage-level commits here have no in-memory owner to adopt them. */ +const adopt = (): void => {}; + export type StorageBenchmarkScale = { readonly name: string; readonly entryCount: number; @@ -89,7 +92,7 @@ export async function seedStorageBenchmark( storage: Storage, scale: StorageBenchmarkScale = TIMING_SCALE, ): Promise { - await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], BACKGROUND_CONTEXT); + await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], adopt, BACKGROUND_CONTEXT); let firstEntryId: EntryId | undefined; for (let start = 0; start < scale.entryCount; start += BATCH_SIZE) { @@ -108,7 +111,7 @@ export async function seedStorageBenchmark( }, }); } - await storage.commit(writes, BACKGROUND_CONTEXT); + await storage.commit(writes, adopt, BACKGROUND_CONTEXT); } for (let start = 0; start < scale.taskCount; start += BATCH_SIZE) { @@ -116,7 +119,7 @@ export async function seedStorageBenchmark( for (let index = start; index < Math.min(start + BATCH_SIZE, scale.taskCount); index++) { writes.push({ type: "task", value: task(await storage.mintId>(), index) }); } - await storage.commit(writes, BACKGROUND_CONTEXT); + await storage.commit(writes, adopt, BACKGROUND_CONTEXT); } let exactDocumentId: DocumentId | undefined; @@ -131,7 +134,7 @@ export async function seedStorageBenchmark( content: { kind: "base", version: 1, value: { index, text: "x".repeat(128) } }, }); } - await storage.commit(writes, BACKGROUND_CONTEXT); + await storage.commit(writes, adopt, BACKGROUND_CONTEXT); } const replayEntries = await Promise.all( @@ -153,6 +156,7 @@ export async function seedStorageBenchmark( }, content: { kind: "base" as const, version: 1, value: { count: 0, text: "x".repeat(64) } }, })), + adopt, BACKGROUND_CONTEXT, ); for (let count = 1; count <= REPLAY_TAILS.at(-1)!; count++) { @@ -164,6 +168,7 @@ export async function seedStorageBenchmark( id, content: { kind: "delta" as const, version: 1, ops: [["s", ["count"], count] as const] }, })), + adopt, BACKGROUND_CONTEXT, ); } @@ -184,6 +189,7 @@ export async function seedStorageBenchmark( content: { kind: "base", version: 1, value: { count: 0 } }, }, ], + adopt, BACKGROUND_CONTEXT, ); let ancientAt: Seq | undefined; @@ -196,6 +202,7 @@ export async function seedStorageBenchmark( content: { kind: "delta", version: 1, ops: [["s", ["count"], count]] }, }, ], + adopt, BACKGROUND_CONTEXT, ); } @@ -207,6 +214,7 @@ export async function seedStorageBenchmark( content: { kind: "base", version: 1, value: { count: HISTORY_SEGMENT_LENGTH } }, }, ], + adopt, BACKGROUND_CONTEXT, ); if (ancientAt === undefined) throw new Error("Benchmark history seed produced no commits"); @@ -220,6 +228,7 @@ export async function seedStorageBenchmark( content: { kind: "delta", version: 1, ops: [["s", ["count"], count]] }, }, ], + adopt, BACKGROUND_CONTEXT, ); } @@ -237,6 +246,7 @@ export async function seedStorageBenchmark( value: { id: conversationId, parent: { conversationId: parentConversationId, at: parentAt } }, }, ], + adopt, BACKGROUND_CONTEXT, ); const ids = await Promise.all(Array.from({ length: ENTRIES_PER_FORK }, () => storage.mintId())); @@ -250,6 +260,7 @@ export async function seedStorageBenchmark( data: { depth, index }, }, })), + adopt, BACKGROUND_CONTEXT, ); parentConversationId = conversationId; @@ -389,7 +400,7 @@ export type StorageWriteBenchmark = { /** Seed the common state expected by every write benchmark sample. */ export async function seedStorageWriteBenchmark(storage: Storage): Promise { - await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], BACKGROUND_CONTEXT); + await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], adopt, BACKGROUND_CONTEXT); await storage.commit( await Promise.all( Array.from({ length: 100 }, async (_, index) => ({ @@ -402,6 +413,7 @@ export async function seedStorageWriteBenchmark(storage: Storage): Promise }, })), ), + adopt, BACKGROUND_CONTEXT, ); } @@ -424,6 +436,7 @@ export const STORAGE_WRITE_BENCHMARKS: readonly StorageWriteBenchmark[] = [ }, }, ], + adopt, BACKGROUND_CONTEXT, ); return 1; @@ -447,7 +460,7 @@ export const STORAGE_WRITE_BENCHMARKS: readonly StorageWriteBenchmark[] = [ }), ), ); - await storage.commit(writes, BACKGROUND_CONTEXT); + await storage.commit(writes, adopt, BACKGROUND_CONTEXT); return writes.length; }, }, @@ -482,7 +495,7 @@ export const STORAGE_WRITE_BENCHMARKS: readonly StorageWriteBenchmark[] = [ content: { kind: "base", version: 1, value: { entryId, taskId } }, }, ]; - await storage.commit(writes, BACKGROUND_CONTEXT); + await storage.commit(writes, adopt, BACKGROUND_CONTEXT); return writes.length; }, }, diff --git a/packages/durable/src/testing/storage-conformance.ts b/packages/durable/src/testing/storage-conformance.ts index 12cb75ac9..58dcd599b 100644 --- a/packages/durable/src/testing/storage-conformance.ts +++ b/packages/durable/src/testing/storage-conformance.ts @@ -13,6 +13,7 @@ import { type Page, ROOT_CONVERSATION_ID, type ScanOrder, + type Seq, type Storage, type StorageWrite, type SubmissionId, @@ -23,11 +24,14 @@ import { } from "../types.ts"; import type { StorageConformanceAssertions, StorageConformanceCase, StorageConformanceOptions } from "./types.ts"; +/** Storage-level commits here have no in-memory owner to adopt them. */ +const adopt = (): void => {}; + const context = BACKGROUND_CONTEXT; type StoredTask = TaskRecord; async function createRoot(storage: Storage): Promise { - await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context); + await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], adopt, context); return ROOT_CONVERSATION_ID; } @@ -87,8 +91,43 @@ function assertionFacade(assertions: StorageConformanceAssertions) { }); } +/** + * `storage` with every commit checked against the adoption contract: `adopt` runs exactly once, with the committed + * sequence, before the commit resolves (so before the next commit starts), and never for a commit that rejects. The + * cases fail commits only before they are durable. + */ +function checkAdoption(storage: Storage, assertions: StorageConformanceAssertions): Storage { + const commit: Storage["commit"] = async (writes, adopt, commitContext) => { + const adopted: Seq[] = []; + let settled = false; + const recording = (seq: Seq): void => { + assertions.ok(!settled, "Storage.commit() adopted after it settled"); + adopted.push(seq); + adopt(seq); + }; + let seq: Seq; + try { + seq = await storage.commit(writes, recording, commitContext); + } catch (error) { + settled = true; + assertions.deepEqual(adopted, []); + throw error; + } + settled = true; + assertions.deepEqual(adopted, [seq]); + return seq; + }; + return new Proxy(storage, { + get(target, property) { + if (property === "commit") return commit; + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + function createCase(options: StorageConformanceOptions, name: string, test: ConformanceTest): StorageConformanceCase { - return { name, run: () => options.withStorage(test) }; + return { name, run: () => options.withStorage((storage) => test(checkAdoption(storage, options.assertions))) }; } /** Creates runner-independent cases. `withStorage` must call and await its callback exactly once per case. */ @@ -100,10 +139,51 @@ export function createStorageConformance(options: StorageConformanceOptions): re await expect(createRoot(storage)).resolves.toBe(ROOT_CONVERSATION_ID); expect(await storage.conversation(ROOT_CONVERSATION_ID, context)).toEqual({ id: ROOT_CONVERSATION_ID }); await expect( - storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context), + storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], adopt, context), ).rejects.toThrow(`ID ${ROOT_CONVERSATION_ID} already belongs to conversation`); }), + createCase(options, "adopts a durable commit once before resolving, and never a failed one", async (storage) => { + const rootId = await createRoot(storage); + const events: string[] = []; + const entryId = await storage.mintId(); + let read: ReturnType | undefined; + const seq = await storage + .commit( + [{ type: "entry", value: entry(entryId, rootId) }], + (adopted) => { + events.push(`adopt ${adopted}`); + // The batch is durable when it is adopted: a read started now observes it. + read = storage.entry(entryId, context); + }, + context, + ) + .then((committed) => { + events.push(`resolve ${committed}`); + return committed; + }); + expect(events).toEqual([`adopt ${seq}`, `resolve ${seq}`]); + expect((await read)?.commitSeq).toBe(seq); + + events.length = 0; + await expect( + storage.commit( + [{ type: "conversation", value: { id: rootId } }], + (adopted) => events.push(`adopt ${adopted}`), + context, + ), + ).rejects.toThrow(`ID ${rootId} already belongs to conversation`); + expect(events).toEqual([]); + + const next = await storage.commit( + [{ type: "entry", value: entry(await storage.mintId(), rootId) }], + (adopted) => events.push(`adopt ${adopted}`), + context, + ); + expect(events).toEqual([`adopt ${next}`]); + expect(next).toBeGreaterThan(seq); + }), + createCase( options, "commits mixed table writes atomically and rolls all of them back on failure", @@ -127,6 +207,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re { type: "task", value: task }, { type: "submission", value: input }, ], + adopt, context, ); @@ -151,6 +232,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re { type: "entry", value: entry(transientEntryId, rootId, "assistant") }, { type: "conversation", value: { id: rootId } }, ], + adopt, context, ), ).rejects.toThrow(`ID ${rootId} already belongs to conversation`); @@ -160,6 +242,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re expect(await storage.entry(transientEntryId, context)).toBeUndefined(); const afterRollbackSeq = await storage.commit( [{ type: "entry", value: entry(await storage.mintId(), rootId, "after-rollback") }], + adopt, context, ); expect(afterRollbackSeq).toBeGreaterThan(initialSeq); @@ -193,6 +276,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re { type: "task", value: storedTask }, { type: "submission", value: storedInput }, ], + adopt, context, ); @@ -229,7 +313,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re const data = JSON.parse( '{"__proto__":{"polluted":false},"constructor":{"label":"stored"},"toString":"value"}', ) as Record; - await storage.commit([{ type: "entry", value: entry(entryId, rootId, "note", { data }) }], context); + await storage.commit([{ type: "entry", value: entry(entryId, rootId, "note", { data }) }], adopt, context); (Reflect.get(data, "__proto__") as Record).polluted = true; (Reflect.get(data, "constructor") as Record).label = "mutated"; @@ -261,6 +345,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }), }, ], + adopt, context, ); @@ -281,13 +366,14 @@ export function createStorageConformance(options: StorageConformanceOptions): re { type: "entry", value: entry(middleId, rootId) }, { type: "entry", value: entry(newestId, rootId) }, ], + adopt, context, ); const first = await storage.scanEntries({ conversationId: rootId }, 2, undefined, context); expect(first.items.map(({ id }) => id)).toEqual([newestId, middleId]); const appendedId = await storage.mintId(); - await storage.commit([{ type: "entry", value: entry(appendedId, rootId) }], context); + await storage.commit([{ type: "entry", value: entry(appendedId, rootId) }], adopt, context); const second = await storage.scanEntries({ conversationId: rootId }, 2, first.next, context); expect(second.items.map(({ id }) => id)).toEqual([oldestId]); expect(second.next).toBeUndefined(); @@ -302,6 +388,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re { type: "conversation", value: { id: thirdId } }, { type: "conversation", value: { id: secondId } }, ], + adopt, context, ); @@ -338,6 +425,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re value: { id: thirdId, owner: { conversationId: otherOwnerId, taskId: firstTaskId } }, }, ], + adopt, context, ); @@ -378,6 +466,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }, { type: "entry", value: entry(rootExcludedSameCommit, rootId) }, ], + adopt, context, ); const childId = await storage.mintId(); @@ -388,6 +477,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re value: { id: childId, parent: { conversationId: rootId, at: rootForkPoint } }, }, ], + adopt, context, ); const childForkPoint = await storage.mintId(); @@ -397,10 +487,11 @@ export function createStorageConformance(options: StorageConformanceOptions): re { type: "entry", value: entry(childForkPoint, childId, "note") }, { type: "entry", value: entry(childExcluded, childId) }, ], + adopt, context, ); const rootExcludedLater = await storage.mintId(); - await storage.commit([{ type: "entry", value: entry(rootExcludedLater, rootId) }], context); + await storage.commit([{ type: "entry", value: entry(rootExcludedLater, rootId) }], adopt, context); const grandchildId = await storage.mintId(); await storage.commit( [ @@ -409,6 +500,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re value: { id: grandchildId, parent: { conversationId: childId, at: childForkPoint } }, }, ], + adopt, context, ); const grandchildHead = await storage.mintId(); @@ -421,10 +513,11 @@ export function createStorageConformance(options: StorageConformanceOptions): re }, { type: "entry", value: entry(grandchildTail, grandchildId) }, ], + adopt, context, ); const childExcludedLater = await storage.mintId(); - await storage.commit([{ type: "entry", value: entry(childExcludedLater, childId) }], context); + await storage.commit([{ type: "entry", value: entry(childExcludedLater, childId) }], adopt, context); const first = await storage.scanEntries({ conversationId: grandchildId }, 2, undefined, context); expect(first.items.map(({ id }) => id)).toEqual([grandchildTail, grandchildHead]); @@ -544,6 +637,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re value: { id: submissionId, conversationId: rootId, type: "input", status: "queued" }, }, ], + adopt, context, ); conversationIds.push(conversationId); @@ -592,6 +686,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re { type: "task", value: second }, { type: "task", value: third }, ], + adopt, context, ); @@ -600,7 +695,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re state: { status: "running", checkpoint: { phase: "effect", attempt: 1 } }, abortRequested: true, }; - await storage.commit([{ type: "task", value: running }], context); + await storage.commit([{ type: "task", value: running }], adopt, context); expect(await storage.task(firstId, context)).toEqual(running); const terminal: StoredTask = { id: firstId, @@ -612,7 +707,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re background: false, abortRequested: true, }; - await storage.commit([{ type: "task", value: terminal }], context); + await storage.commit([{ type: "task", value: terminal }], adopt, context); expect(await storage.task(firstId, context)).toEqual(terminal); const pendingPage = await storage.scanTasks({ status: "pending" }, 1, undefined, context); @@ -648,7 +743,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re state: { status: "completing", outcome: { status: "failed", error: { message: "held" } } }, }; const writes = [owner, waiting, completing].map((value) => ({ type: "task", value }) as const); - await storage.commit(writes, context); + await storage.commit(writes, adopt, context); expect(await storage.task(waitingId, context)).toEqual(waiting); expect(await storage.task(completingId, context)).toEqual(completing); const scan = async (status: StoredTask["state"]["status"]) => @@ -660,7 +755,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re ...completing, state: { status: "terminal", outcome: completing.state.outcome! }, }; - await storage.commit([{ type: "task", value: terminal }], context); + await storage.commit([{ type: "task", value: terminal }], adopt, context); expect(await scan("completing")).toEqual([]); expect(await scan("terminal")).toEqual([terminal]); }), @@ -673,13 +768,13 @@ export function createStorageConformance(options: StorageConformanceOptions): re state: { status: "sleeping", checkpoint: { phase: "next" }, until: 61_000 }, memos: { kept: true }, }; - await storage.commit([{ type: "task", value: sleeping }], context); + await storage.commit([{ type: "task", value: sleeping }], adopt, context); expect(await storage.task(sleepingId, context)).toEqual(sleeping); const scan = async (status: StoredTask["state"]["status"]) => (await storage.scanTasks({ status }, 10, undefined, context)).items; expect(await scan("sleeping")).toEqual([sleeping]); const running: StoredTask = { ...sleeping, state: { status: "running", checkpoint: { phase: "next" } } }; - await storage.commit([{ type: "task", value: running }], context); + await storage.commit([{ type: "task", value: running }], adopt, context); expect(await scan("sleeping")).toEqual([]); expect(await scan("running")).toEqual([running]); }), @@ -690,7 +785,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re async (storage) => { const rootId = await createRoot(storage); const secondConversationId = await storage.mintId(); - await storage.commit([{ type: "conversation", value: { id: secondConversationId } }], context); + await storage.commit([{ type: "conversation", value: { id: secondConversationId } }], adopt, context); const firstId = await storage.mintId(); const secondId = await storage.mintId(); const otherConversationId = await storage.mintId(); @@ -721,6 +816,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re { type: "submission", value: second }, { type: "submission", value: otherConversation }, ], + adopt, context, ); expect(await storage.submissionByRequest(rootId, "same", context)).toEqual(first); @@ -731,7 +827,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re status: "placed", entry: await storage.mintId(), }; - await storage.commit([{ type: "submission", value: placedSecond }], context); + await storage.commit([{ type: "submission", value: placedSecond }], adopt, context); expect(await storage.submission(secondId, context)).toEqual(placedSecond); expect(await storage.submissionByRequest(rootId, "other", context)).toEqual(placedSecond); @@ -783,6 +879,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re { type: "submission", value: queuedDone }, { type: "submission", value: queuedFailed }, ], + adopt, context, ); @@ -802,6 +899,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re { type: "submission", value: done }, { type: "submission", value: unanswered }, ], + adopt, context, ); expect(await storage.submission(doneId, context)).toEqual(done); @@ -829,6 +927,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re content: { kind: "base", version: 1, value: initial }, }, ], + adopt, context, ); const appended = ["b"]; @@ -838,6 +937,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re ]; const changedAt = await storage.commit( [{ type: "document.change", id: firstId, content: { kind: "delta", version: 1, ops } }], + adopt, context, ); @@ -865,6 +965,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re content: { kind: "base", version: 2, value: { items: ["checkpoint"], nested: { count: 3 } } }, }, ], + adopt, context, ); const replacedAt = await storage.commit( @@ -879,6 +980,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }, }, ], + adopt, context, ); expect(await storage.document(firstId, changedAt, context)).toMatchObject({ @@ -911,6 +1013,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re content: { kind: "delta", version: 2, ops: [["s", ["retiring"], true]] }, }, ], + adopt, context, ); const address = { kind: firstRecord.kind, scope: firstRecord.scope }; @@ -949,6 +1052,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }; const createdAt = await storage.commit( [{ type: "document.create", record, content: { kind: "base", version: 1, value: initial } }], + adopt, context, ); const beforeReplacement = structuredClone(initial); @@ -972,6 +1076,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }, }, ], + adopt, context, ); } @@ -989,6 +1094,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re content: { kind: "delta", version: 1, ops: [["r", replacement]] }, }, ], + adopt, context, ); replacement.rows[0]!.value = -999; @@ -1013,6 +1119,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }, }, ], + adopt, context, ); } @@ -1038,6 +1145,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re { type: "conversation", value: { id: childId } }, { type: "conversation", value: { id: secondChildId } }, ], + adopt, context, ); const sourceId = await storage.mintId(); @@ -1056,6 +1164,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re content: { kind: "base", version: 2, value: { count: 1, rows: [{ value: "base" }] } }, }, ], + adopt, context, ); await storage.commit( @@ -1073,6 +1182,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }, }, ], + adopt, context, ); const historicalCopyId = await storage.mintId(); @@ -1104,6 +1214,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }, { type: "document.retire", id: retiredCopyId }, ], + adopt, context, ); expect(await storage.document(historicalCopyId, "current", context)).toMatchObject({ @@ -1125,6 +1236,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }, { type: "document.retire", id: sourceId }, ], + adopt, context, ); expect((await storage.document(currentCopyId, "current", context))?.value).toEqual({ @@ -1149,6 +1261,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re content: { kind: "base", version: 4, value: { retained: "copy" } }, }, ], + adopt, context, ); await storage.commit( @@ -1163,6 +1276,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re source: { id: latestSourceId, at: "current" }, }, ], + adopt, context, ); await storage.commit( @@ -1174,6 +1288,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }, { type: "document.retire", id: latestSourceId }, ], + adopt, context, ); expect(await storage.document(latestCopyId, "current", context)).toMatchObject({ @@ -1193,6 +1308,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }, { type: "document.retire", id: currentCopyId }, ], + adopt, context, ); } catch (error) { @@ -1217,6 +1333,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re source: { id: currentCopyId, at: "current" }, }, ], + adopt, context, ); } catch (error) { @@ -1240,14 +1357,17 @@ export function createStorageConformance(options: StorageConformanceOptions): re } satisfies DocumentCreate; await storage.commit( [{ type: "document.create", record, content: { kind: "base", version: 1, value: { count: 1 } } }], + adopt, context, ); await storage.commit( [{ type: "document.change", id, content: { kind: "delta", version: 1, ops: [["s", ["count"], 2]] } }], + adopt, context, ); const migratedAt = await storage.commit( [{ type: "document.change", id, content: { kind: "base", version: 2, value: { count: 3 } } }], + adopt, context, ); expect(await storage.document(id, "current", context)).toMatchObject({ version: 2, value: { count: 3 } }); @@ -1258,11 +1378,12 @@ export function createStorageConformance(options: StorageConformanceOptions): re await expect( storage.commit( [{ type: "document.change", id, content: { kind: "delta", version: 1, ops: [["s", ["count"], 4]] } }], + adopt, context, ), ).rejects.toThrow("version transition requires a base"); expect((await storage.document(id, "current", context))?.value).toEqual({ count: 3 }); - await storage.commit([{ type: "document.retire", id }], context); + await storage.commit([{ type: "document.retire", id }], adopt, context); expect(await storage.document(id, "current", context)).toBeUndefined(); }, ), @@ -1340,6 +1461,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re content: { kind: "base", version: 1, value: { owner: "other" } }, }, ], + adopt, context, ); @@ -1420,6 +1542,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re } satisfies DocumentCreate; await storage.commit( [{ type: "document.create", record, content: { kind: "base", version: 1, value: { value: 1 } } }], + adopt, context, ); await expect( @@ -1432,6 +1555,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }, { type: "document.change", id: firstId, content: { kind: "delta", version: 1, ops: [] } }, ], + adopt, context, ), ).rejects.toThrow("already has a current incarnation"); @@ -1455,6 +1579,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re }, { type: "document.retire", id: emptyId }, ], + adopt, context, ); expect(await storage.document(emptyId, "current", context)).toBeUndefined(); @@ -1500,6 +1625,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re { type: "submission", value: submission }, { type: "document.create", record, content: { kind: "base", version: 1, value: { count: 1 } } }, ], + adopt, context, ); @@ -1523,6 +1649,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re content: { kind: "base", version: 1, value: { count: 2 } }, }, ], + adopt, context, ), ).rejects.toThrow("already has a current incarnation"); @@ -1543,6 +1670,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re content: { kind: "delta", version: 1, ops: [["s", ["count"], 3]] }, }, ], + adopt, context, ); expect(afterRollbackSeq).toBeGreaterThan(baselineSeq); @@ -1606,6 +1734,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re content: { kind: "base", version: 1, value: { identity: "second key" } }, }, ], + adopt, context, ); @@ -1654,17 +1783,19 @@ export function createStorageConformance(options: StorageConformanceOptions): re createCase(options, "keeps one global record ID namespace and rejects exhausted ID minting", async (storage) => { const rootId = await createRoot(storage); const explicitEntryId = idFromNumber(100); - await storage.commit([{ type: "entry", value: entry(explicitEntryId, rootId) }], context); + await storage.commit([{ type: "entry", value: entry(explicitEntryId, rootId) }], adopt, context); expect(await storage.mintId()).toBe(101); await expect( storage.commit( [{ type: "task", value: pendingTask(idFromNumber>(explicitEntryId), rootId) }], + adopt, context, ), ).rejects.toThrow(`ID ${explicitEntryId} already belongs to entry`); await storage.commit( [{ type: "entry", value: entry(idFromNumber(Number.MAX_SAFE_INTEGER), rootId, "last-id") }], + adopt, context, ); await expect(storage.mintId()).rejects.toThrow("ID space is exhausted"); @@ -1675,7 +1806,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re await createRoot(storage); await storage.close(context); await expect(storage.conversation(ROOT_CONVERSATION_ID, context)).rejects.toThrow("closed"); - await expect(storage.commit([] satisfies StorageWrite[], context)).rejects.toThrow("closed"); + await expect(storage.commit([] satisfies StorageWrite[], adopt, context)).rejects.toThrow("closed"); await expect(storage.mintId()).rejects.toThrow("closed"); }), ]; diff --git a/packages/durable/src/types.ts b/packages/durable/src/types.ts index fe69f8e20..24c44f9ec 100644 --- a/packages/durable/src/types.ts +++ b/packages/durable/src/types.ts @@ -1,4 +1,4 @@ -import type { AttachedReplicatedState, Context, Draft, JsonValue } from "@earendil-works/chord"; +import type { Context, Draft, JsonValue, WatchHandle } from "@earendil-works/chord"; import type { Op } from "@earendil-works/chord/delta"; import type { Message, Models } from "@earendil-works/pi-ai"; import type { TelemetryCarrier } from "@earendil-works/pi-telemetry"; @@ -267,7 +267,7 @@ export interface TaskRuntime extends DocumentObserver * Commit on the Session line after rereading the task. Rejects when the task is terminal, the invocation ended, the * Harness is closing, or, in a run invocation, the task carries an abort mark. A returned state replaces the task's * state in the same commit; returning nothing leaves it unchanged. `tx.createTask()` defaults to the task's - * conversation. Inside the callback read only through `tx`: `snapshot()`, `documentState()`, `watchDoc()`, `getTask()`, and the other Session and runtime reads may queue on the line the callback holds, and then never resolve. + * conversation. Inside the callback read only through `tx`: `snapshot()`, `watchDoc()`, `getTask()`, and the other Session and runtime reads may queue on the line the callback holds, and then never resolve. */ commit( change: ( @@ -882,7 +882,7 @@ export type DocumentCommitChange = readonly ops: readonly Op[]; } | { - /** Definition-free child initialization; consumers hydrate through state or watch acquisition. */ + /** Definition-free child initialization; consumers hydrate through watch acquisition. */ readonly type: "document.copy"; readonly record: DocumentRecord; readonly conversationId: ConversationId; @@ -1004,28 +1004,10 @@ export interface Tx { ): Promise; } -/** Disposable, read-only Chord state bound to one committed document incarnation. */ -export type DocumentState = AttachedReplicatedState | null>; - -/** Terminal result of one document watch. */ -export type WatchEnd = - | { readonly reason: "stopped" | "cancelled" | "session_closed" | "retired" } - | { readonly reason: "listener_error"; readonly error: Error } - /** The Session failed; `error` is what failed it, usually a storage error (see `SessionFailed`). */ - | { readonly reason: "session_failed"; readonly error: unknown }; - -/** Serialized exact-frame observation of an immutable value with bounded pending delivery. */ -export interface WatchHandle { - /** Acquisition revision before start; latest delivered immutable revision afterward. */ - readonly value: T; - /** Install the sole asynchronous listener. Never invokes it inline. */ - start(listener: (value: T, ops: readonly Op[], context: Context) => Promise): void; - /** Idempotently stop future callbacks and return this watch's terminal result. */ - stop(): Promise; - /** Settle when the watch terminates; an already-running callback remains caller-owned. */ - readonly closed: Promise; -} - +/** + * Watch of one committed document incarnation. A retirement delivers `[["r", null]]` with value `null`, then ends the + * watch as `retired`; the Session's close ends it as `ended`, its failure as `failed` with the error that failed it. + */ export type DocumentWatch = WatchHandle | null>; /** Committed document reads. */ @@ -1069,7 +1051,7 @@ export type SessionEnd = { readonly reason: "closed" } | { readonly reason: "fai /** Owner of one mutation line, its records, and its tracked documents. */ export interface Session extends DocumentObserver { /** - * Run one atomic transaction on the Session mutation line. Inside the callback read only through `tx`: `snapshot()`, `documentState()`, `watchDoc()`, `getTask()`, and the other Session and runtime reads may queue on the line the callback holds, and then never resolve. + * Run one atomic transaction on the Session mutation line. Inside the callback read only through `tx`: `snapshot()`, `watchDoc()`, `getTask()`, and the other Session and runtime reads may queue on the line the callback holds, and then never resolve. */ commit(change: (tx: Tx) => T | Promise, context: Context): Promise; /** Seal admission, settle admitted commits, then close storage. */ @@ -1116,38 +1098,6 @@ export interface Session extends DocumentObserver { context: Context, ): Promise | undefined>; - documentState( - token: SessionDocToken, - context: Context, - ): Promise | undefined>; - documentState( - token: ConversationDocToken, - conversationId: ConversationId, - context: Context, - ): Promise | undefined>; - documentState( - token: TaskDocToken, - taskId: TaskId, - context: Context, - ): Promise | undefined>; - documentState( - token: SessionDocFamilyToken, - key: string, - context: Context, - ): Promise | undefined>; - documentState( - token: ConversationDocFamilyToken, - conversationId: ConversationId, - key: string, - context: Context, - ): Promise | undefined>; - documentState( - token: TaskDocFamilyToken, - taskId: TaskId, - key: string, - context: Context, - ): Promise | undefined>; - snapshotAsOf( token: RewindableConversationDocToken, conversationId: ConversationId, @@ -1178,13 +1128,17 @@ export interface Session extends DocumentObserver { */ export interface Storage { /** - * Atomically persist one batch and return its sequence. Once resolved, later reads through this storage observe it. + * Atomically persist one batch and return its sequence. Once the batch is durable, call `adopt(seq)` exactly once, + * synchronously and before this storage's next commit starts, then resolve with `seq`; a commit that fails before it + * is durable never calls `adopt`. `adopt` is the caller's in-memory adoption of the batch, so memory always shows a + * complete prefix of the log; it must not throw. Once resolved, later reads through this storage observe the batch. */ - commit(writes: readonly StorageWrite[], context: Context): Promise; + commit(writes: readonly StorageWrite[], adopt: (seq: Seq) => void, context: Context): Promise; /** - * Return a fresh branded candidate from the Session-global numeric ID namespace, greater than every ID minted or stored - * before, also across reopen: scans order by ID as creation order, and the Harness relies on it. + * Return a fresh branded candidate from the Session-global numeric ID namespace, greater than every ID minted since + * this storage was opened and every ID stored, also across reopen: scans order by ID as creation order, and the + * Harness relies on it. */ mintId>(): Promise; diff --git a/packages/durable/test/examples/04-chord-state.ts b/packages/durable/test/examples/04-chord-state.ts deleted file mode 100644 index 75d3e3b7b..000000000 --- a/packages/durable/test/examples/04-chord-state.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Expose a document through Chord. -// Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/04-chord-state.ts -import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import { createSession, defineDoc, MemoryStorage } from "../../src/index.ts"; - -const context = BACKGROUND_CONTEXT; -const session = createSession(new MemoryStorage()); - -const Notes = defineDoc<{ text: string }>({ - checkpointWhen: () => false, - kind: "example.notes", - version: 1, - scope: "conversation", - history: "rewindable", - fork: "asOf", - initial: () => ({ text: "" }), -}); -const chat = await session.commit(async (tx) => { - const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } }); - (await tx.doc(Notes, conversation.id)).text = "first"; - return conversation; -}, context); - -// documentState() never creates a document. It returns a hydrated read-only -// Chord state bound to the current concrete incarnation. -const notesState = await session.documentState(Notes, chat.id, context); -if (notesState === undefined) throw new Error("notes are absent"); -const stopNotes = notesState.subscribe((value, _deliveryContext, delivery) => { - console.log("Chord notes:", delivery.kind, delivery.sequence, value); -}); -await session.commit(async (tx) => { - (await tx.doc(Notes, chat.id)).text = "published through Chord"; -}, context); -stopNotes(); -notesState.dispose(); - -await session.close(context); diff --git a/packages/durable/test/examples/21-late-join.ts b/packages/durable/test/examples/21-late-join.ts index da65dd01b..76d99530b 100644 --- a/packages/durable/test/examples/21-late-join.ts +++ b/packages/durable/test/examples/21-late-join.ts @@ -56,14 +56,14 @@ const submission = await root.submit({ type: "input", content: "Count to ten, th await pause(500); // Structural client: the view holds the committed transcript and documents, including the running tool's output. -const view = await root.viewState(context); +const view = await root.watch(context); const live = view.value.docs["pi.live"] as LiveState; console.log( "view entries:", view.value.entries.map((entry) => entry.kind), ); console.log("view tool slot:", live.tools?.[0]?.status, JSON.stringify(live.tools?.[0]?.output)); -view.subscribe((value) => { +view.start(async (value) => { const slot = (value.docs["pi.live"] as LiveState).tools?.[0]; if (slot?.status === "running") console.log("view output now:", JSON.stringify(slot.output)); }); @@ -96,5 +96,5 @@ await submission.wait(context); await harness.waitForIdle(context); await pause(0); await stream.stop(); -view.dispose(); +await view.stop(); await harness.close(context); diff --git a/packages/durable/test/examples/24-child-tasks.ts b/packages/durable/test/examples/24-child-tasks.ts index 84c747c85..63d67759f 100644 --- a/packages/durable/test/examples/24-child-tasks.ts +++ b/packages/durable/test/examples/24-child-tasks.ts @@ -128,9 +128,9 @@ console.log("The customer cancels:"); id = await checkout(root, ["visa-5", "visa-6", "visa-7", "visa-8"]); harness.resume(); await new Promise((resolve) => setTimeout(resolve, 20)); -const graph = await harness.taskGraph(context); +const graph = await harness.watchTaskGraph(context); printGraph(graph.value); -graph.dispose(); +await graph.stop(); await harness.abortTask(id, context); console.log(` checkout: ${(await harness.waitForTask(id, context)).state.outcome.status}`); diff --git a/packages/durable/test/harness-blocked.test.ts b/packages/durable/test/harness-blocked.test.ts index 0cd7aa484..6cd234cc6 100644 --- a/packages/durable/test/harness-blocked.test.ts +++ b/packages/durable/test/harness-blocked.test.ts @@ -104,7 +104,7 @@ describe("recovery at open", () => { ...rest, state: { status: "completing", outcome: { status: "completed", result: null } }, } as const; - await raw.commit([{ type: "task", value: completing }], context); + await raw.commit([{ type: "task", value: completing }], () => {}, context); await raw.close(context); // That pass's commit fails, as on a platform reset. @@ -158,7 +158,9 @@ describe("tasks that cannot run", () => { kind: "blocked", reason: "restart_limit", }); - const graph = await harness.taskGraph(context); + const graph = await harness.watchTaskGraph(context); + // A started watch keeps its value current. + graph.start(async () => {}); expect(graph.value.tasks[String(id)]?.blocked).toBe("restart_limit"); // Parked work needs no process: nothing is active. await eventually(() => !harness.wake().active); @@ -172,7 +174,7 @@ describe("tasks that cannot run", () => { await harness.retryTask(id, context); expect((await harness.waitForTask(id, context)).state.outcome.status).toBe("completed"); expect(state.started).toBe(4); - graph.dispose(); + await graph.stop(); await harness.close(context); }); @@ -282,7 +284,9 @@ describe("tasks that cannot run", () => { await eventually(() => state.started === 1); opened.kill(); // a crash: a close would not count opened = await openWith(path, registry, settings); - const graph = await opened.harness.taskGraph(context); + const graph = await opened.harness.watchTaskGraph(context); + // A started watch keeps its value current. + graph.start(async () => {}); opened.harness.resume(); await eventually(() => graph.value.tasks[String(id)]?.blocked === "restart_limit"); // Let a scheduling pass leave the parked task out. @@ -293,7 +297,7 @@ describe("tasks that cannot run", () => { opened.harness.resume(); expect((await opened.harness.waitForTask(id, context)).state.outcome.status).toBe("completed"); expect(state.started).toBe(2); - graph.dispose(); + await graph.stop(); await opened.harness.close(context); }); @@ -437,13 +441,15 @@ describe("tasks that cannot run", () => { const second = createRegistry(); second.install(defineExtension({ name: "v2", tasks: [V2] })); opened = await openWith(path, second); - const graph = await opened.harness.taskGraph(context); + const graph = await opened.harness.watchTaskGraph(context); + // A started watch keeps its value current. + graph.start(async () => {}); opened.harness.resume(); await eventually(() => graph.value.tasks[String(id)]?.blocked === "migration_failed"); await eventually(() => !opened.harness.wake().active); expect(opened.harness.wake()).toEqual({ active: false }); await eventually(() => opened.wakes.at(-1)?.active === false); - graph.dispose(); + await graph.stop(); await opened.harness.close(context); }); @@ -466,11 +472,13 @@ describe("tasks that cannot run", () => { await opened.harness.close(context); // The next open marks it `restart`; without its definition, it waits for one to clean up. opened = await openWith(path, createRegistry()); - const graph = await opened.harness.taskGraph(context); + const graph = await opened.harness.watchTaskGraph(context); + // A started watch keeps its value current. + graph.start(async () => {}); opened.harness.resume(); await eventually(async () => (await opened.harness.getTask(id, context))?.abortReason === "restart"); await eventually(() => graph.value.tasks[String(id)]?.blocked === "missing_task"); - graph.dispose(); + await graph.stop(); await opened.harness.close(context); }); @@ -485,7 +493,9 @@ describe("tasks that cannot run", () => { const registry = createRegistry(); const path = await sqlitePath(); const { harness, wakes } = await openWith(path, registry); - const graph = await harness.taskGraph(context); + const graph = await harness.watchTaskGraph(context); + // A started watch keeps its value current. + graph.start(async () => {}); const root = await harness.root(context); const id = await root.commit( (tx) => tx.createTask(Missing, null, { ownership: { kind: "conversation" } }), @@ -499,7 +509,7 @@ describe("tasks that cannot run", () => { await eventually(() => graph.value.tasks[String(id)]?.blocked === undefined); await eventually(() => wakes.some((wake) => wake.active)); expect((await harness.waitForTask(id, context)).state.outcome.status).toBe("completed"); - graph.dispose(); + await graph.stop(); await harness.close(context); }); }); diff --git a/packages/durable/test/harness-cancellation-barrier.test.ts b/packages/durable/test/harness-cancellation-barrier.test.ts index 5afd2a4f0..086787e43 100644 --- a/packages/durable/test/harness-cancellation-barrier.test.ts +++ b/packages/durable/test/harness-cancellation-barrier.test.ts @@ -1,5 +1,12 @@ import type { Context } from "@earendil-works/chord"; -import { createSession, defineTask, MemoryStorage, type StorageWrite, type TaskId } from "@earendil-works/pi-durable"; +import { + createSession, + defineTask, + MemoryStorage, + type Seq, + type StorageWrite, + type TaskId, +} from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; import { context, flush } from "./session-support.ts"; import { aborted, deferred, eventually, openTasks } from "./task-support.ts"; @@ -24,14 +31,14 @@ class HoldMarks extends MemoryStorage { target: TaskId | undefined; readonly held = deferred(); readonly release = deferred(); - override async commit(writes: readonly StorageWrite[], commitContext: Context) { + override async commit(writes: readonly StorageWrite[], adopt: (seq: Seq) => void, commitContext: Context) { if ( writes.some((write) => write.type === "task" && write.value.id === this.target && write.value.abortRequested) ) { this.held.resolve(); await this.release.promise; } - return super.commit(writes, commitContext); + return super.commit(writes, adopt, commitContext); } } @@ -128,6 +135,7 @@ describe("work below a cancelled owner", () => { value: { ...failedRecord, state: { status: "terminal", outcome: failure } } as typeof failedRecord, }, ], + () => {}, context, ); // The child's definition is missing: a restart mark would keep it waiting, a request orphans it. @@ -209,7 +217,11 @@ describe("work below a cancelled owner", () => { ); const record = (await storage.task(holder, context))!; const completing = { status: "completing", outcome: { status: "completed", result: null } } as const; - await storage.commit([{ type: "task", value: { ...record, state: completing } as typeof record }], context); + await storage.commit( + [{ type: "task", value: { ...record, state: completing } as typeof record }], + () => {}, + context, + ); const { harness } = await openTasks(storage, [Dormant]); try { harness.resume(); diff --git a/packages/durable/test/harness-compaction.test.ts b/packages/durable/test/harness-compaction.test.ts index cc4a85797..61f1f1d14 100644 --- a/packages/durable/test/harness-compaction.test.ts +++ b/packages/durable/test/harness-compaction.test.ts @@ -1449,9 +1449,9 @@ describe("compaction estimates and interactions", () => { // The parent is untouched. expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); // The fork's view keeps the parent entries the summary kept. - const state = await fork.viewState(context); - expect(state.value.entries).toEqual(view.entries); - state.dispose(); + const watch = await fork.watch(context); + expect(watch.value.entries).toEqual(view.entries); + await watch.stop(); await chat.harness.close(context); }); @@ -1896,12 +1896,13 @@ describe("compaction and the inbox", () => { it("keeps the kept entries mounted in the conversation view", async () => { const chat = await open(); await history(chat); - const state = await chat.root.viewState(context); + const watch = await chat.root.watch(context); + watch.start(async () => {}); chat.faux.summaries.push(summary()); await result(chat, await chat.root.compact(undefined, context)); - await waitFor(() => state.value.entries[0]?.kind === "pi.compaction"); - expect(state.value.entries).toEqual((await chat.root.context(context)).entries); - state.dispose(); + await waitFor(() => watch.value.entries[0]?.kind === "pi.compaction"); + expect(watch.value.entries).toEqual((await chat.root.context(context)).entries); + await watch.stop(); await chat.harness.close(context); }); }); diff --git a/packages/durable/test/harness-conversations.test.ts b/packages/durable/test/harness-conversations.test.ts index 30697772a..fb3b8b71f 100644 --- a/packages/durable/test/harness-conversations.test.ts +++ b/packages/durable/test/harness-conversations.test.ts @@ -469,9 +469,9 @@ describe("Harness lifecycle", () => { }, context); expect(await harness.snapshot(NoteDoc, root.id, context)).toEqual({ text: "second" }); expect(await harness.snapshotAsOf(NoteDoc, root.id, entry.id, context)).toEqual({ text: "first" }); - const state = await harness.documentState(NoteDoc, root.id, context); - expect(state?.value).toEqual({ text: "second" }); - state?.dispose(); + const watch = await harness.watchDoc(NoteDoc, root.id, context); + expect(watch?.value).toEqual({ text: "second" }); + await watch?.stop(); await harness.close(context); }); }); diff --git a/packages/durable/test/harness-events.test.ts b/packages/durable/test/harness-events.test.ts index 22a5d2d75..7171f7a86 100644 --- a/packages/durable/test/harness-events.test.ts +++ b/packages/durable/test/harness-events.test.ts @@ -593,7 +593,7 @@ describe("agent events", () => { const { harness, root } = await openChat(new MemoryStorage(), chatSetup()); const stream: AgentEventStream = await watchEvents(harness, root.id, context); await harness.close(context); - expect(await stream.closed).toEqual({ reason: "session_closed" }); + expect(await stream.closed).toEqual({ reason: "ended" }); }); it("starts a message at the first committed partial and ends it with the converted entry on abort", async () => { diff --git a/packages/durable/test/harness-inbox.test.ts b/packages/durable/test/harness-inbox.test.ts index 9c7a0505e..19a719286 100644 --- a/packages/durable/test/harness-inbox.test.ts +++ b/packages/durable/test/harness-inbox.test.ts @@ -731,7 +731,8 @@ describe("inbox", () => { class RecordingStorage extends MemoryStorage { override async commit( writes: Parameters[0], - callContext: Parameters[1], + adopt: Parameters[1], + callContext: Parameters[2], ) { for (const write of writes) { if (write.type !== "document.change" || write.id !== inboxId) continue; @@ -739,7 +740,7 @@ describe("inbox", () => { const empty = content.kind === "base" && (content.value as InboxState).items.length === 0; written.push({ kind: content.kind, empty }); } - return super.commit(writes, callContext); + return super.commit(writes, adopt, callContext); } } const setup = chatSetup(); diff --git a/packages/durable/test/harness-lifecycle.test.ts b/packages/durable/test/harness-lifecycle.test.ts index 91fce7ecb..be422e526 100644 --- a/packages/durable/test/harness-lifecycle.test.ts +++ b/packages/durable/test/harness-lifecycle.test.ts @@ -78,6 +78,7 @@ async function seedRunningTask(storage: Storage): Promise { { type: "conversation", value: { id: conversationId } }, { type: "task", value: { ...task, abortRequested: false } }, ], + () => {}, context, ); } @@ -274,7 +275,7 @@ describe("Harness close", () => { await harness.close(context); }); - it("publishes no frame to states and watches from a commit that settles during close", async () => { + it("publishes no frame to watches from a commit that settles during close", async () => { const Notes = defineDoc<{ text: string }>({ checkpointWhen: () => false, kind: "test.close-frames", @@ -288,27 +289,15 @@ describe("Harness close", () => { await harness.commit(async (tx) => { (await tx.doc(Notes)).text = "before"; }, context); - const docState = (await harness.documentState(Notes, context))!; const docWatch = (await harness.watchDoc(Notes, context))!; - const viewState = await root.viewState(context); const viewWatch = await root.watch(context); - const graphState = await harness.taskGraph(context); const graphWatch = await harness.watchTaskGraph(context); const frames: string[] = []; - graphState.subscribe((_value, _context, delivery) => { - if (delivery.kind === "update") frames.push("graphState"); - }); graphWatch.start(async () => void frames.push("graphWatch")); - docState.subscribe((_value, _context, delivery) => { - if (delivery.kind === "update") frames.push("docState"); - }); - viewState.subscribe((_value, _context, delivery) => { - if (delivery.kind === "update") frames.push("viewState"); - }); docWatch.start(async () => void frames.push("docWatch")); viewWatch.start(async () => void frames.push("viewWatch")); - const docValue = docState.value; - const viewValue = viewState.value; + const docValue = docWatch.value; + const viewValue = viewWatch.value; const held = storage.holdCommits(); const committing = harness.commit(async (tx) => { @@ -326,12 +315,12 @@ describe("Harness close", () => { await closing; await flush(); expect(frames).toEqual([]); - expect(docState.value).toBe(docValue); - expect(viewState.value).toBe(viewValue); - expect(graphState.value).toEqual({ tasks: {} }); - expect(await graphWatch.closed).toEqual({ reason: "session_closed" }); - expect(await docWatch.closed).toEqual({ reason: "session_closed" }); - expect(await viewWatch.closed).toEqual({ reason: "session_closed" }); + expect(docWatch.value).toBe(docValue); + expect(viewWatch.value).toBe(viewValue); + expect(graphWatch.value).toEqual({ tasks: {} }); + expect(await graphWatch.closed).toEqual({ reason: "ended" }); + expect(await docWatch.closed).toEqual({ reason: "ended" }); + expect(await viewWatch.closed).toEqual({ reason: "ended" }); // The commit itself settled. expect(storage.commits.at(-1)).toContainEqual(expect.objectContaining({ type: "entry" })); }); @@ -385,12 +374,12 @@ describe("Harness close", () => { find.release(); await expect(viewWatch).rejects.toThrow("cancelled"); // No observer kept the mount: the next observer builds a new one, and the one after that another. - const first = await root.viewState(context); + const first = await root.watch(context); const firstValue = first.value; - first.dispose(); - const second = await root.viewState(context); + await first.stop(); + const second = await root.watch(context); expect(second.value).not.toBe(firstValue); - second.dispose(); + await second.stop(); await harness.close(context); }); @@ -418,9 +407,7 @@ describe("Harness close", () => { reset: () => root.reset(undefined, context), abort: () => root.abort(context), conversationIdle: () => root.waitForIdle(context), - viewState: () => root.viewState(context), watch: () => root.watch(context), - taskGraph: () => harness.taskGraph(context), watchTaskGraph: () => harness.watchTaskGraph(context), status: () => submission.status(context), wait: () => submission.wait(context), @@ -488,13 +475,10 @@ describe("Harness close", () => { snapshot: settle(harness.snapshot(Notes, context)), settledWait: settle(write.wait(context)), absentWatch: settle(harness.watchDoc(Absent, context)), - absentState: settle(harness.documentState(Absent, context)), waitForTask: settle(harness.waitForTask(taskId, context)), - documentState: settle(harness.documentState(Notes, context)), watchDoc: settle(harness.watchDoc(Notes, context)), - viewState: settle(root.viewState(context)), watch: settle(root.watch(context)), - taskGraph: settle(harness.taskGraph(context)), + watchTaskGraph: settle(harness.watchTaskGraph(context)), }; const closing = harness.close(context); held.release(); @@ -506,13 +490,10 @@ describe("Harness close", () => { snapshot: "resolved", settledWait: "resolved", absentWatch: "undefined", - absentState: "undefined", waitForTask: "closed", - documentState: "closed", watchDoc: "closed", - viewState: "closed", watch: "closed", - taskGraph: "closed", + watchTaskGraph: "closed", }); await closing; }); @@ -551,16 +532,13 @@ describe("scheduling on a paused Harness", () => { await harness.usage(context); await harness.conversation(root.id, context); await harness.snapshot(Notes, context); - (await harness.documentState(Notes, context))!.dispose(); await (await harness.watchDoc(Notes, context))!.stop(); const submission = (await harness.submission(submissionId, context))!; await submission.status(context); await root.agent(context); await root.context(context); await root.entries({}, 10, undefined, context); - (await root.viewState(context)).dispose(); await (await root.watch(context)).stop(); - (await harness.taskGraph(context)).dispose(); await (await harness.watchTaskGraph(context)).stop(); await flush(); await flush(); diff --git a/packages/durable/test/harness-live-deltas.test.ts b/packages/durable/test/harness-live-deltas.test.ts index 488c1cd69..df5593384 100644 --- a/packages/durable/test/harness-live-deltas.test.ts +++ b/packages/durable/test/harness-live-deltas.test.ts @@ -283,13 +283,14 @@ describe("pi.live deltas", () => { class RecordingStorage extends MemoryStorage { override async commit( writes: Parameters[0], - context: Parameters[1], + adopt: Parameters[1], + context: Parameters[2], ) { let kind: "base" | "delta" | undefined; for (const write of writes) { if (write.type === "document.change" && write.id === liveId) kind = write.content.kind; } - const seq = await super.commit(writes, context); + const seq = await super.commit(writes, adopt, context); if (kind !== undefined) written.set(seq, kind); return seq; } diff --git a/packages/durable/test/harness-nested-tools.test.ts b/packages/durable/test/harness-nested-tools.test.ts index 59c05560b..5d68eb34a 100644 --- a/packages/durable/test/harness-nested-tools.test.ts +++ b/packages/durable/test/harness-nested-tools.test.ts @@ -115,11 +115,11 @@ describe("nested tool calls", () => { // Per document written, the keys of the nested calls it holds. const written: { kind: string; key: string | undefined }[] = []; const commit = storage.commit.bind(storage); - storage.commit = (writes, commitContext) => { + storage.commit = (writes, adopt, commitContext) => { for (const write of writes) { if (write.type === "document.create") written.push({ kind: write.record.kind, key: write.record.key }); } - return commit(writes, commitContext); + return commit(writes, adopt, commitContext); }; const { harness, root } = await openChat(storage, setup); await (await root.submit({ type: "input", content: "go" }, context)).wait(context); @@ -1299,6 +1299,7 @@ describe("tool task version 1 records", () => { const state = { ...record.state, checkpoint } as typeof record.state; await storage.commit( [{ type: "task", value: { ...record, version: 1, input, state } as typeof record }], + () => {}, context, ); await storage.close(context); diff --git a/packages/durable/test/harness-ownership.test.ts b/packages/durable/test/harness-ownership.test.ts index 40430cdc8..d363f9c76 100644 --- a/packages/durable/test/harness-ownership.test.ts +++ b/packages/durable/test/harness-ownership.test.ts @@ -940,14 +940,14 @@ describe("ownership", () => { const storage = await openNodeSqliteStorage(path); let rejectMark: TaskId | undefined; const commit = storage.commit.bind(storage); - storage.commit = async (writes, ctx) => { + storage.commit = async (writes, adopt, ctx) => { if ( writes.some((write) => write.type === "task" && write.value.id === rejectMark && write.value.abortRequested) ) { rejectMark = undefined; throw new Error("disk gone"); } - return commit(writes, ctx); + return commit(writes, adopt, ctx); }; opened = await openHarness(storage); const child = (await opened.harness.conversation(tree.child, context))!; @@ -973,7 +973,8 @@ describe("ownership", () => { class Failing extends ControlledStorage { override async commit( writes: Parameters[0], - ctx: Parameters[1], + adopt: Parameters[1], + ctx: Parameters[2], ) { const marks = writes.some( (write) => write.type === "task" && write.value.id === failMark && write.value.abortRequested, @@ -982,7 +983,7 @@ describe("ownership", () => { failMark = undefined; throw failure; } - return super.commit(writes, ctx); + return super.commit(writes, adopt, ctx); } } const storage = new Failing(); diff --git a/packages/durable/test/harness-scheduler-indexes.test.ts b/packages/durable/test/harness-scheduler-indexes.test.ts index f0c558b3e..c42efd96e 100644 --- a/packages/durable/test/harness-scheduler-indexes.test.ts +++ b/packages/durable/test/harness-scheduler-indexes.test.ts @@ -107,13 +107,13 @@ describe("scheduler indexes", () => { const storage = await openNodeSqliteStorage(path); const reasons: (string | undefined)[] = []; const commit = storage.commit.bind(storage); - storage.commit = (writes, commitContext) => { + storage.commit = (writes, adopt, commitContext) => { for (const write of writes) { if (write.type === "task" && write.value.id === ids.leaf && write.value.abortRequested) { reasons.push(write.value.abortReason); } } - return commit(writes, commitContext); + return commit(writes, adopt, commitContext); }; const { harness } = await openTasks(storage, [Hold]); harness.resume(); @@ -128,12 +128,12 @@ describe("scheduler indexes", () => { const endedIn = new Map(); let commits = 0; const commit = storage.commit.bind(storage); - storage.commit = (writes, commitContext) => { + storage.commit = (writes, adopt, commitContext) => { commits++; for (const write of writes) { if (write.type === "task" && write.value.state.status === "terminal") endedIn.set(write.value.id, commits); } - return commit(writes, commitContext); + return commit(writes, adopt, commitContext); }; const opened = await openTasks(storage, [Hold]); const harness = opened.harness; diff --git a/packages/durable/test/harness-sleep.test.ts b/packages/durable/test/harness-sleep.test.ts index b665387de..b9ad47cf6 100644 --- a/packages/durable/test/harness-sleep.test.ts +++ b/packages/durable/test/harness-sleep.test.ts @@ -94,7 +94,9 @@ describe("sleeping tasks", () => { now: () => clock, onWake: (wake) => wakes.push(wake), }); - const graph = await harness.taskGraph(context); + const graph = await harness.watchTaskGraph(context); + // A started watch keeps its value current. + graph.start(async () => {}); const id = await startNap(harness, Nap); harness.resume(); await eventually(async () => (await stateOf(harness, id))?.status === "sleeping"); @@ -133,7 +135,7 @@ describe("sleeping tasks", () => { for (let index = 1; index < wakes.length; index++) expect(wakes[index]).not.toEqual(wakes[index - 1]); expect(wakes).toContainEqual({ active: true }); expect(wakes).toContainEqual({ active: false, at: 2_000 }); - graph.dispose(); + await graph.stop(); await harness.close(context); }); diff --git a/packages/durable/test/harness-storage-failure.test.ts b/packages/durable/test/harness-storage-failure.test.ts index f488fc188..9169e62c7 100644 --- a/packages/durable/test/harness-storage-failure.test.ts +++ b/packages/durable/test/harness-storage-failure.test.ts @@ -106,8 +106,8 @@ describe("a failed Storage call", () => { const failed = { name: "SessionFailed", cause: failure }; await expect(task).rejects.toMatchObject(failed); await expect(idle).rejects.toMatchObject(failed); - expect(await watch.closed).toEqual({ reason: "session_failed", error: failure }); - expect(await stream.closed).toEqual({ reason: "session_failed", error: failure }); + expect(await watch.closed).toEqual({ reason: "failed", error: failure }); + expect(await stream.closed).toEqual({ reason: "failed", error: failure }); // Every later call, and a wait that starts now. await expect(harness.getTask(id, context)).rejects.toMatchObject(failed); await expect(harness.waitForIdle(context)).rejects.toMatchObject(failed); @@ -229,7 +229,7 @@ describe("a failed Storage call", () => { await harness.close(context); }); - it("ends what a task holds with session_failed, not as cancelled by its signal", async () => { + it("ends what a task holds as failed, not as cancelled by its signal", async () => { const ends: unknown[] = []; const watching = deferred(); const Watcher = defineTask({ @@ -260,7 +260,7 @@ describe("a failed Storage call", () => { storage.failNextCommit(failure); await expect(root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context)).rejects.toBe(failure); await opened.harness.closed; - expect(ends).toEqual([{ reason: "session_failed", error: failure }, "SessionFailed"]); + expect(ends).toEqual([{ reason: "failed", error: failure }, "SessionFailed"]); await opened.harness.close(context); }); @@ -491,7 +491,7 @@ describe("listener and report errors", () => { publication: () => { throw failure; }, - closeSession: () => {}, + end: () => {}, }), context, ); @@ -504,7 +504,7 @@ describe("listener and report errors", () => { while (seen.length === 0) await new Promise((resolve) => setTimeout(resolve, 1)); expect(reports).toEqual([failure]); await harness.close(context); - expect(await watch.closed).toEqual({ reason: "session_closed" }); + expect(await watch.closed).toEqual({ reason: "ended" }); }); it("contains a fulfilled thenable that calls only its fulfillment handler", async () => { diff --git a/packages/durable/test/harness-structured.test.ts b/packages/durable/test/harness-structured.test.ts index 16d191e0a..7bc47916d 100644 --- a/packages/durable/test/harness-structured.test.ts +++ b/packages/durable/test/harness-structured.test.ts @@ -1355,7 +1355,8 @@ describe("tool rounds", () => { class Failing extends ControlledStorage { override async commit( writes: Parameters[0], - ctx: Parameters[1], + adopt: Parameters[1], + ctx: Parameters[2], ) { const finalizes = writes.some( (write) => write.type === "task" && write.value.id === reject && write.value.state.status === "terminal", @@ -1364,7 +1365,7 @@ describe("tool rounds", () => { reject = undefined; throw failure; } - return super.commit(writes, ctx); + return super.commit(writes, adopt, ctx); } } const storage = new Failing(); @@ -1632,7 +1633,8 @@ describe("conversation abort and boundaries", () => { class Failing extends ControlledStorage { override async commit( writes: Parameters[0], - ctx: Parameters[1], + adopt: Parameters[1], + ctx: Parameters[2], ) { const finalizes = writes.some( (write) => write.type === "task" && write.value.id === reject && write.value.state.status === "terminal", @@ -1641,7 +1643,7 @@ describe("conversation abort and boundaries", () => { reject = undefined; throw failure; } - return super.commit(writes, ctx); + return super.commit(writes, adopt, ctx); } } const storage = new Failing(); diff --git a/packages/durable/test/harness-task-graph.test.ts b/packages/durable/test/harness-task-graph.test.ts index 3beb45e01..85dd66cd4 100644 --- a/packages/durable/test/harness-task-graph.test.ts +++ b/packages/durable/test/harness-task-graph.test.ts @@ -89,9 +89,9 @@ describe("task graph view", () => { const root = await harness.root(context); const seen: TaskGraph[] = []; const observe = async () => { - const opened = await harness.taskGraph(context); - opened.subscribe((value, _context, delivery) => { - if (delivery.kind === "update") seen.push(value); + const opened = await harness.watchTaskGraph(context); + opened.start(async (value) => { + seen.push(value); }); return opened; }; @@ -100,7 +100,7 @@ describe("task graph view", () => { /** The advanced value equals a fresh build from Storage once the last observer left. */ const rebuild = async () => { const advanced = graph.value; - graph.dispose(); + await graph.stop(); graph = await observe(); expect(graph.value).not.toBe(advanced); expect(graph.value).toEqual(advanced); @@ -161,7 +161,7 @@ describe("task graph view", () => { for (let index = 1; index < seen.length; index++) expect(seen[index]).not.toEqual(seen[index - 1]); // The commit that created the two conversations published one revision setting them. expect(seen.some((value) => value.tasks[String(parent)]?.conversations.length === 2)).toBe(true); - graph.dispose(); + await graph.stop(); await harness.close(context); }); @@ -195,7 +195,7 @@ describe("task graph view", () => { await eventually(async () => (await first.harness.inspect(context)).tasks.every((task) => task.state.kind === "running"), ); - const running = await first.harness.taskGraph(context); + const running = await first.harness.watchTaskGraph(context); expect(statuses(running.value)).toEqual({ [`test.graph-work#${foreground}`]: "running", [`test.graph-work#${background}`]: "running", @@ -263,7 +263,8 @@ describe("task graph view", () => { }); const { harness } = await openTasks(new MemoryStorage(), [Spawner]); const root = await harness.root(context); - const graph = await harness.taskGraph(context); + const graph = await harness.watchTaskGraph(context); + graph.start(async () => {}); const id = await root.commit(async (tx) => { const entry = await tx.appendEntry(root.id, { kind: "note" }); return tx.createTask(Spawner, { at: entry.id }, { ownership: { kind: "conversation" } }); @@ -272,10 +273,10 @@ describe("task graph view", () => { await eventually(() => (graph.value.tasks[String(id)]?.conversations.length ?? 0) === 2); const advanced = graph.value; expect(advanced.tasks[String(id)]!.conversations).toEqual([...created].sort((a, b) => a - b)); - graph.dispose(); - const rebuilt = await harness.taskGraph(context); + await graph.stop(); + const rebuilt = await harness.watchTaskGraph(context); expect(rebuilt.value).toEqual(advanced); - rebuilt.dispose(); + await rebuilt.stop(); gate.resolve(); await harness.waitForTask(id, context); await harness.close(context); @@ -299,13 +300,13 @@ describe("task graph view", () => { await blocking; await expect(cancelled).rejects.toThrow("cancelled"); // No observer kept the mount: each new observer builds a new revision. - const first = await harness.taskGraph(context); + const first = await harness.watchTaskGraph(context); const value = first.value; - first.dispose(); - const second = await harness.taskGraph(context); + await first.stop(); + const second = await harness.watchTaskGraph(context); expect(second.value).not.toBe(value); expect(second.value).toEqual(value); - second.dispose(); + await second.stop(); await harness.close(context); }); @@ -329,12 +330,12 @@ describe("task graph view", () => { const { harness } = await openTasks(new MemoryStorage(), [Memo]); const root = await harness.root(context); const id = await root.commit((tx) => tx.createTask(Memo, null, { ownership: { kind: "conversation" } }), context); - const first = await harness.taskGraph(context); - const second = await harness.taskGraph(context); + const first = await harness.watchTaskGraph(context); + const second = await harness.watchTaskGraph(context); expect(second.value).toBe(first.value); const updates: string[] = []; - first.subscribe((value, _context, delivery) => { - if (delivery.kind === "update") updates.push(value.tasks[String(id)]?.state.status ?? "gone"); + first.start(async (value) => { + updates.push(value.tasks[String(id)]?.state.status ?? "gone"); }); harness.resume(); await reached.promise; @@ -346,13 +347,13 @@ describe("task graph view", () => { await flush(); expect(updates).toEqual(["running", "gone"]); const last = first.value; - first.dispose(); - second.dispose(); + await first.stop(); + await second.stop(); // No observer is left, so the mount was dropped: a new observer builds a new revision. - const rebuilt = await harness.taskGraph(context); + const rebuilt = await harness.watchTaskGraph(context); expect(rebuilt.value).toEqual(last); expect(rebuilt.value).not.toBe(last); - rebuilt.dispose(); + await rebuilt.stop(); await harness.close(context); }); }); diff --git a/packages/durable/test/harness-tasks.test.ts b/packages/durable/test/harness-tasks.test.ts index f76cb8308..23c9acec8 100644 --- a/packages/durable/test/harness-tasks.test.ts +++ b/packages/durable/test/harness-tasks.test.ts @@ -1198,7 +1198,7 @@ describe("task close", () => { await reached.promise; await harness.close(context); await expect(fromListener).rejects.toThrow("closed"); - expect(watchEnd).toEqual({ reason: "session_closed" }); + expect(watchEnd).toEqual({ reason: "ended" }); }); it("writes no fault when close seals while the step after a failed phase is queued", async () => { diff --git a/packages/durable/test/harness-view.test.ts b/packages/durable/test/harness-view.test.ts index 44f12e91d..3370dde62 100644 --- a/packages/durable/test/harness-view.test.ts +++ b/packages/durable/test/harness-view.test.ts @@ -34,9 +34,9 @@ async function record(conversation: Conversation) { /** A freshly built view of `conversation`, for comparing with an advanced one. */ async function fresh(conversation: Conversation): Promise { - const state = await conversation.viewState(context); - const value = state.value; - state.dispose(); + const watch = await conversation.watch(context); + const value = watch.value; + await watch.stop(); return value; } @@ -246,20 +246,20 @@ describe("conversation view", () => { await harness.close(context); }); - it("keeps states and watches of one conversation independent and remounts after the last one detaches", async () => { + it("keeps watches of one conversation independent and remounts after the last one detaches", async () => { const { harness, root } = await openChat(new MemoryStorage(), chatSetup()); - const state = await root.viewState(context); + const kept = await record(root); const { frames, stop } = await record(root); await root.commit((tx) => tx.appendEntry(root.id, { kind: "one" }), context); await drained(); - expect(state.value.entries.map((entry) => entry.kind)).toEqual(["one"]); + expect(kept.frames.at(-1)!.value.entries.map((entry) => entry.kind)).toEqual(["one"]); await stop(); expect(frames).toHaveLength(1); await root.commit((tx) => tx.appendEntry(root.id, { kind: "two" }), context); await drained(); - expect(state.value.entries.map((entry) => entry.kind)).toEqual(["one", "two"]); - const last = state.value; - state.dispose(); + const last = kept.frames.at(-1)!.value; + expect(last.entries.map((entry) => entry.kind)).toEqual(["one", "two"]); + await kept.stop(); // No observer is left, so the mount was dropped: a new observer builds a new revision from committed state. const rebuilt = await fresh(root); expect(rebuilt).toEqual(last); @@ -267,15 +267,13 @@ describe("conversation view", () => { await harness.close(context); }); - it("ends states and watches at close and rejects later acquisition", async () => { + it("ends watches at close and rejects later acquisition", async () => { const { harness, root } = await openChat(new MemoryStorage(), chatSetup()); const watch = await root.watch(context); - const state = await root.viewState(context); await harness.close(context); - expect(await watch.closed).toEqual({ reason: "session_closed" }); - expect(state.value.entries).toEqual([]); + expect(await watch.closed).toEqual({ reason: "ended" }); + expect(watch.value.entries).toEqual([]); await expect(root.watch(context)).rejects.toThrow(); - await expect(root.viewState(context)).rejects.toThrow(); }); it("rejects an acquisition cancelled or closed while it waits for the Session line", async () => { @@ -308,9 +306,9 @@ describe("conversation view", () => { const { harness, root } = await openChat(new MemoryStorage(), chatSetup()); const failing = await root.watch(context); const { frames, stop } = await record(root); - const shared = await root.viewState(context); + const shared = await root.watch(context); expect(failing.value).toBe(shared.value); - shared.dispose(); + await shared.stop(); failing.start(async () => { throw new Error("listener failed"); }); diff --git a/packages/durable/test/indexeddb-storage.test.ts b/packages/durable/test/indexeddb-storage.test.ts index 40109deaf..352b7ca0e 100644 --- a/packages/durable/test/indexeddb-storage.test.ts +++ b/packages/durable/test/indexeddb-storage.test.ts @@ -22,6 +22,9 @@ import type { } from "../src/types.ts"; import { ROOT_CONVERSATION_ID } from "../src/types.ts"; +/** Storage-level commits here have no in-memory owner to adopt them. */ +const adopt = (): void => {}; + Object.defineProperty(globalThis, "IDBKeyRange", { configurable: true, value: FakeIDBKeyRange }); const context = BACKGROUND_CONTEXT; @@ -39,10 +42,10 @@ class ReopeningStorage implements Storage { this.indexedDB = indexedDB; } - async commit(writes: readonly StorageWrite[], commitContext: Context): Promise { - if (this.closed) return this.current.commit(writes, commitContext); + async commit(writes: readonly StorageWrite[], adopt: (seq: Seq) => void, commitContext: Context): Promise { + if (this.closed) return this.current.commit(writes, adopt, commitContext); try { - return await this.current.commit(writes, commitContext); + return await this.current.commit(writes, adopt, commitContext); } finally { await this.current.close(context); this.current = await IndexedDbStorage.open(this.name, { indexedDB: this.indexedDB }); @@ -132,7 +135,7 @@ function pendingTask(id: TaskId, conversationId = ROOT_CONVERSATION_I } async function createRoot(storage: Storage): Promise { - await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context); + await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], adopt, context); } it("persists minted IDs across reopen", async () => { @@ -163,6 +166,7 @@ it("rolls back table writes when a later document copy fails", async () => { } satisfies DocumentCreate; await storage.commit( [{ type: "document.create", record: source, content: { kind: "base", version: 1, value: { count: 1 } } }], + adopt, context, ); const taskId = await storage.mintId>(); @@ -177,6 +181,7 @@ it("rolls back table writes when a later document copy fails", async () => { source: { id: sourceId, at: "current" }, }, ], + adopt, context, ), ).rejects.toThrow("was rejected"); @@ -207,6 +212,7 @@ it("scans entries and head markers through fork ancestry", async () => { }, }, ], + adopt, context, ); const childId = await storage.mintId(); @@ -217,11 +223,13 @@ it("scans entries and head markers through fork ancestry", async () => { value: { id: childId, parent: { conversationId: ROOT_CONVERSATION_ID, at: rootMarker } }, }, ], + adopt, context, ); const childEntry = await storage.mintId(); await storage.commit( [{ type: "entry", value: { id: childEntry, conversationId: childId, kind: "child" } }], + adopt, context, ); expect( @@ -271,7 +279,7 @@ it("keeps one readwrite transaction active through many mixed writes", async () }, ); } - await storage.commit(writes, context); + await storage.commit(writes, adopt, context); expect((await storage.entry(lastEntryId!, context))?.entry.data).toEqual({ index: 39 }); expect(await storage.task(lastTaskId!, context)).toEqual(pendingTask(lastTaskId!)); expect((await storage.submission(lastSubmissionId!, context))?.requestId).toBe("bulk-39"); diff --git a/packages/durable/test/jsonl-storage.test.ts b/packages/durable/test/jsonl-storage.test.ts index cebfb0a69..8f16ae547 100644 --- a/packages/durable/test/jsonl-storage.test.ts +++ b/packages/durable/test/jsonl-storage.test.ts @@ -24,6 +24,9 @@ import type { } from "../src/types.ts"; import { ROOT_CONVERSATION_ID } from "../src/types.ts"; +/** Storage-level commits here have no in-memory owner to adopt them. */ +const adopt = (): void => {}; + const context = BACKGROUND_CONTEXT; type StoredTask = TaskRecord; const tempDirectories = new Set(); @@ -66,10 +69,10 @@ class ReopeningStorage implements Storage { this.directory = directory; } - async commit(writes: readonly StorageWrite[], commitContext: Context): Promise { - if (this.closed) return this.current.commit(writes, commitContext); + async commit(writes: readonly StorageWrite[], adopt: (seq: Seq) => void, commitContext: Context): Promise { + if (this.closed) return this.current.commit(writes, adopt, commitContext); try { - return await this.current.commit(writes, commitContext); + return await this.current.commit(writes, adopt, commitContext); } finally { await this.current.close(context); this.current = await JsonlStorage.open(this.directory, new NodeExecutionEnv({ cwd: this.directory }), context); @@ -160,7 +163,7 @@ function terminalTask(id: TaskId): StoredTask { } async function createRoot(storage: Storage): Promise { - await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context); + await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], adopt, context); } function sessionDocument(id: DocumentId, kind = "test.document"): DocumentCreate { @@ -329,7 +332,7 @@ describe("Pico JsonlStorage publication and recovery", () => { await createRoot(storage); const taskId = await storage.mintId>(); const documentId = await storage.mintId(); - await storage.commit([{ type: "task", value: pendingTask(taskId) }], context); + await storage.commit([{ type: "task", value: pendingTask(taskId) }], adopt, context); await storage.commit( [ { @@ -338,10 +341,12 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 0 } }, }, ], + adopt, context, ); await storage.commit( [{ type: "document.change", id: documentId, content: { kind: "delta", version: 1, ops: [] } }], + adopt, context, ); await storage.commit( @@ -352,13 +357,14 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 1 } }, }, ], + adopt, context, ); expect(await readLines(join(directory, `task-${taskId}.jsonl`))).toHaveLength(1); expect(await readLines(join(directory, `doc-${documentId}.jsonl`))).toHaveLength(1); - await storage.commit([{ type: "document.retire", id: documentId }], context); - await storage.commit([{ type: "task", value: terminalTask(taskId) }], context); + await storage.commit([{ type: "document.retire", id: documentId }], adopt, context); + await storage.commit([{ type: "task", value: terminalTask(taskId) }], adopt, context); expect(await readLines(join(directory, "main.jsonl"))).toHaveLength(7); expect(await fileExists(join(directory, `task-${taskId}.jsonl`))).toBe(false); @@ -379,6 +385,7 @@ describe("Pico JsonlStorage publication and recovery", () => { { type: "task", value: pendingTask(taskId, "first") }, { type: "task", value: pendingTask(taskId, "second") }, ], + adopt, context, ); expect(await readLines(join(directory, `task-${taskId}.jsonl`))).toHaveLength(2); @@ -400,6 +407,7 @@ describe("Pico JsonlStorage publication and recovery", () => { { type: "task", value: pendingTask(taskId, "first") }, { type: "task", value: pendingTask(taskId, `second-${"x".repeat(512)}`) }, ], + adopt, context, ), ).rejects.toThrow("poisoned"); @@ -432,6 +440,7 @@ describe("Pico JsonlStorage publication and recovery", () => { }, }, ], + adopt, context, ), ).rejects.toThrow(); @@ -440,6 +449,7 @@ describe("Pico JsonlStorage publication and recovery", () => { expect( await storage.commit( [{ type: "entry", value: { id, conversationId: ROOT_CONVERSATION_ID, kind: "good" } }], + adopt, context, ), ).toBe(2); @@ -477,6 +487,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { text: "β" } }, }, ], + adopt, context, ), ).rejects.toThrow("poisoned"); @@ -521,6 +532,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: {} }, }, ], + adopt, context, ), ).rejects.toThrow("poisoned"); @@ -552,6 +564,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: {} }, }, ], + adopt, context, ); const expected = [ @@ -571,6 +584,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { checkpoint: true } }, }, ], + adopt, context, ); expect(env.operations).toEqual([ @@ -595,14 +609,15 @@ describe("Pico JsonlStorage publication and recovery", () => { }, }, ], + adopt, context, ); expect(env.operations).toEqual(["append:main.jsonl"]); const taskId = await storage.mintId>(); - await storage.commit([{ type: "task", value: pendingTask(taskId) }], context); + await storage.commit([{ type: "task", value: pendingTask(taskId) }], adopt, context); env.clear(); - await storage.commit([{ type: "task", value: terminalTask(taskId) }], context); + await storage.commit([{ type: "task", value: terminalTask(taskId) }], adopt, context); expect(env.operations).toEqual([ "append:main.jsonl", ...(fsync ? ["flush:main.jsonl"] : []), @@ -632,6 +647,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 0 } }, }, ], + adopt, context, ); await storage.commit( @@ -642,6 +658,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "delta", version: 1, ops: [["s", ["count"], 1]] }, }, ], + adopt, context, ); @@ -655,6 +672,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 2 } }, }, ], + adopt, context, ), ).resolves.toBe(4); @@ -685,10 +703,11 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 1 } }, }, ], + adopt, context, ); env.fail({ operation: "remove", call: 1, mode }); - await expect(storage.commit([{ type: "document.retire", id }], context)).resolves.toBe(3); + await expect(storage.commit([{ type: "document.retire", id }], adopt, context)).resolves.toBe(3); expect(await storage.document(id, "current", context)).toBeUndefined(); await storage.close(context); @@ -715,6 +734,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 0 } }, }, ], + adopt, context, ); env.fail({ operation: "flush", call: 2, mode }); @@ -727,6 +747,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 2 } }, }, ], + adopt, context, ), ).resolves.toBe(3); @@ -769,6 +790,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 0 } }, }, ], + adopt, context, ); env.fail({ operation: "flush", call: 3, mode }); @@ -781,6 +803,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 2 } }, }, ], + adopt, context, ), ).resolves.toBe(3); @@ -793,6 +816,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "delta", version: 1, ops: [["s", ["count"], 3]] }, }, ], + adopt, context, ); await storage.close(context); @@ -810,9 +834,9 @@ describe("Pico JsonlStorage publication and recovery", () => { const storage = await openStorage(directory, env); await createRoot(storage); const id = await storage.mintId>(); - await storage.commit([{ type: "task", value: pendingTask(id) }], context); + await storage.commit([{ type: "task", value: pendingTask(id) }], adopt, context); env.fail({ operation: "remove", call: 1, mode }); - await expect(storage.commit([{ type: "task", value: terminalTask(id) }], context)).resolves.toBe(3); + await expect(storage.commit([{ type: "task", value: terminalTask(id) }], adopt, context)).resolves.toBe(3); expect(await storage.task(id, context)).toEqual(terminalTask(id)); await storage.close(context); @@ -836,6 +860,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 0 } }, }, ], + adopt, context, ); await storage.commit( @@ -846,6 +871,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 10 } }, }, ], + adopt, context, ); await storage.commit( @@ -856,6 +882,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "delta", version: 1, ops: [["s", ["count"], 11]] }, }, ], + adopt, context, ); expect(await readLines(join(directory, `doc-${id}.jsonl`))).toHaveLength(2); @@ -877,6 +904,7 @@ describe("Pico JsonlStorage publication and recovery", () => { }; const createdAt = await storage.commit( [{ type: "document.create", record, content: { kind: "base", version: 1, value: { count: 0 } } }], + adopt, context, ); const changedAt = await storage.commit( @@ -887,6 +915,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "delta", version: 1, ops: [["s", ["count"], 1]] }, }, ], + adopt, context, ); await storage.commit( @@ -897,9 +926,10 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 2 } }, }, ], + adopt, context, ); - await storage.commit([{ type: "document.retire", id }], context); + await storage.commit([{ type: "document.retire", id }], adopt, context); expect(await readLines(join(directory, `doc-${id}.jsonl`))).toHaveLength(3); const reopened = await openStorage(directory); @@ -942,6 +972,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: {} }, }, ], + adopt, context, ); const retiredAt = await storage.commit( @@ -951,6 +982,7 @@ describe("Pico JsonlStorage publication and recovery", () => { { type: "document.retire", id: taskDocumentId }, { type: "task", value: terminalTask(taskId) }, ], + adopt, context, ); for (const file of [ @@ -988,6 +1020,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { text: "kept" } }, }, ], + adopt, context, ); const sidecarPath = join(directory, `doc-${documentId}.jsonl`); @@ -1004,6 +1037,7 @@ describe("Pico JsonlStorage publication and recovery", () => { expect( await reopened.commit( [{ type: "document.change", id: documentId, content: { kind: "delta", version: 1, ops: [] } }], + adopt, context, ), ).toBe(3); @@ -1014,8 +1048,8 @@ describe("Pico JsonlStorage publication and recovery", () => { const storage = await openStorage(directory); await createRoot(storage); const taskId = await storage.mintId>(); - await storage.commit([{ type: "task", value: pendingTask(taskId) }], context); - await storage.commit([{ type: "task", value: terminalTask(taskId) }], context); + await storage.commit([{ type: "task", value: pendingTask(taskId) }], adopt, context); + await storage.commit([{ type: "task", value: terminalTask(taskId) }], adopt, context); const sidecarPath = join(directory, `task-${taskId}.jsonl`); expect(await fileExists(sidecarPath)).toBe(false); await writeFile( @@ -1033,7 +1067,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const reopened = await openStorage(directory); expect(await fileExists(sidecarPath)).toBe(false); expect(await reopened.task(taskId, context)).toEqual(terminalTask(taskId)); - expect(await reopened.commit([], context)).toBe(4); + expect(await reopened.commit([], adopt, context)).toBe(4); }); it("fails open when confirmed sidecar data is missing", async () => { @@ -1049,6 +1083,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: {} }, }, ], + adopt, context, ); await writeFile(join(directory, `doc-${documentId}.jsonl`), ""); @@ -1070,6 +1105,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: { count: 0 } }, }, ], + adopt, context, ); await storage.commit( @@ -1080,6 +1116,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "delta", version: 1, ops: [["s", ["count"], 1]] }, }, ], + adopt, context, ); const path = join(directory, `doc-${documentId}.jsonl`); @@ -1122,6 +1159,7 @@ describe("Pico JsonlStorage publication and recovery", () => { content: { kind: "base", version: 1, value: {} }, }, ], + adopt, context, ); const path = join(directory, `doc-${documentId}.jsonl`); diff --git a/packages/durable/test/memory-storage.test.ts b/packages/durable/test/memory-storage.test.ts index f01af3ed3..6312e9db7 100644 --- a/packages/durable/test/memory-storage.test.ts +++ b/packages/durable/test/memory-storage.test.ts @@ -9,7 +9,7 @@ registerStorageConformance({ describe, expect, it }, "MemoryStorage", (use) => u it("does not expose retained state through a prepared commit", async () => { const storage = new MemoryStorage(); - await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], BACKGROUND_CONTEXT); + await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], () => {}, BACKGROUND_CONTEXT); const entryId = idFromNumber(2); const prepared = storage.prepareCommit([ { diff --git a/packages/durable/test/nested-images-bench.ts b/packages/durable/test/nested-images-bench.ts index 4cb854520..f8c0e6d19 100644 --- a/packages/durable/test/nested-images-bench.ts +++ b/packages/durable/test/nested-images-bench.ts @@ -189,12 +189,12 @@ async function runScenario(scenario: Scenario): Promise { let commits = 0; const written = noWrites(); const commit = storage.commit.bind(storage); - storage.commit = (writes, commitContext) => { + storage.commit = (writes, adopt, commitContext) => { commits++; // SQLite commits synchronously, which can starve the timer; sample here too. sample(); for (const write of writes) written[write.type] += JSON.stringify(write).length; - return commit(writes, commitContext); + return commit(writes, adopt, commitContext); }; const harness = await Harness.open(storage, { models, registry }, context); const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } }); diff --git a/packages/durable/test/nested-tools-bench.ts b/packages/durable/test/nested-tools-bench.ts index b3af3c61c..57561608c 100644 --- a/packages/durable/test/nested-tools-bench.ts +++ b/packages/durable/test/nested-tools-bench.ts @@ -133,10 +133,10 @@ async function runScenario(scenario: Scenario): Promise<{ perRun: Run[]; closedB let commits = 0; const written = noWrites(); const commit = storage.commit.bind(storage); - storage.commit = (writes, commitContext) => { + storage.commit = (writes, adopt, commitContext) => { commits++; for (const write of writes) written[write.type] += JSON.stringify(write).length; - return commit(writes, commitContext); + return commit(writes, adopt, commitContext); }; const harness = await Harness.open(storage, { models, registry }, context); const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } }); diff --git a/packages/durable/test/scheduler-fanout-bench.ts b/packages/durable/test/scheduler-fanout-bench.ts index 6976cb8d6..ed3bb2eaa 100644 --- a/packages/durable/test/scheduler-fanout-bench.ts +++ b/packages/durable/test/scheduler-fanout-bench.ts @@ -41,9 +41,9 @@ async function run(calls: number): Promise<{ ms: number; commits: number }> { const storage = new MemoryStorage(); let commits = 0; const commit = storage.commit.bind(storage); - storage.commit = (writes, commitContext) => { + storage.commit = (writes, adopt, commitContext) => { commits++; - return commit(writes, commitContext); + return commit(writes, adopt, commitContext); }; const harness = await Harness.open(storage, { models, registry }, context); const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } }); diff --git a/packages/durable/test/session-adoption.test.ts b/packages/durable/test/session-adoption.test.ts new file mode 100644 index 000000000..205c30066 --- /dev/null +++ b/packages/durable/test/session-adoption.test.ts @@ -0,0 +1,106 @@ +import type { Context } from "@earendil-works/chord"; +import { defineDoc, MemoryStorage, type Seq, type StorageWrite } from "@earendil-works/pi-durable"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SessionImpl } from "../src/session/session.ts"; +import { Transaction } from "../src/session/transaction.ts"; +import { context } from "./session-support.ts"; + +const CounterDoc = defineDoc<{ count: number }>({ + checkpointWhen: () => false, + kind: "adoption.counter", + version: 1, + scope: "session", + initial: () => ({ count: 0 }), +}); + +/** Memory storage whose next commit, once durable, misbehaves as `fault` says. */ +class FaultyStorage extends MemoryStorage { + fault: "reject_after_adopt" | "skip_adopt" | undefined; + readonly order: string[] = []; + + override async commit(writes: readonly StorageWrite[], adopt: (seq: Seq) => void, callContext: Context) { + const fault = this.fault; + this.fault = undefined; + if (fault === "skip_adopt") return super.commit(writes, () => {}, callContext); + const seq = await super.commit( + writes, + (committed) => { + this.order.push("adopt"); + adopt(committed); + this.order.push("adopted"); + }, + callContext, + ); + this.order.push("resolve"); + if (fault === "reject_after_adopt") throw new Error("reclaim failed"); + return seq; + } +} + +function open() { + const storage = new FaultyStorage(); + const session = new SessionImpl(storage); + return { storage, session }; +} + +const bump = (session: SessionImpl) => + session.commit(async (tx) => { + (await tx.doc(CounterDoc)).count++; + }, context); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Session adoption inside Storage.commit()", () => { + it("adopts synchronously inside the commit, before Storage resolves", async () => { + const { storage, session } = open(); + const adopt = vi.spyOn(Transaction.prototype, "adopt"); + await bump(session); + expect(storage.order).toEqual(["adopt", "adopted", "resolve"]); + expect(adopt).toHaveBeenCalledTimes(1); + expect(await session.snapshot(CounterDoc, context)).toEqual({ count: 1 }); + await session.close(context); + }); + + it("fails the Session without discarding when Storage rejects after adoption", async () => { + const { storage, session } = open(); + await bump(session); + const discard = vi.spyOn(Transaction.prototype, "discard"); + storage.fault = "reject_after_adopt"; + await expect(bump(session)).rejects.toThrow("reclaim failed"); + expect(discard).not.toHaveBeenCalled(); + expect(await session.closed).toMatchObject({ reason: "failed", error: new Error("reclaim failed") }); + }); + + it("discards the staged changes when Storage rejects before adoption", async () => { + const storage = new MemoryStorage(); + const session = new SessionImpl(storage); + await bump(session); + const discard = vi.spyOn(Transaction.prototype, "discard"); + vi.spyOn(storage, "commit").mockRejectedValueOnce(new Error("disk full")); + await expect(bump(session)).rejects.toThrow("disk full"); + expect(discard).toHaveBeenCalledTimes(1); + expect(await session.closed).toMatchObject({ reason: "failed" }); + }); + + it("fails the Session with its own adoption error, which never reaches Storage", async () => { + const { storage, session } = open(); + await bump(session); + const failure = new Error("adoption failed"); + vi.spyOn(Transaction.prototype, "adopt").mockImplementationOnce(() => { + throw failure; + }); + await expect(bump(session)).rejects.toBe(failure); + // Storage saw a normal adoption and resolved. + expect(storage.order.slice(-3)).toEqual(["adopt", "adopted", "resolve"]); + expect(await session.closed).toEqual({ reason: "failed", error: failure }); + }); + + it("fails the Session when Storage resolves without adopting", async () => { + const { storage, session } = open(); + storage.fault = "skip_adopt"; + await expect(bump(session)).rejects.toThrow("without adopting"); + expect(await session.closed).toMatchObject({ reason: "failed" }); + }); +}); diff --git a/packages/durable/test/session-checkpoints-migrations.test.ts b/packages/durable/test/session-checkpoints-migrations.test.ts index 20e56d721..98bb062ab 100644 --- a/packages/durable/test/session-checkpoints-migrations.test.ts +++ b/packages/durable/test/session-checkpoints-migrations.test.ts @@ -833,7 +833,6 @@ describe("Session tracker cache across definition versions", () => { it("sends observers of an older shape a root replacement after a newer token writes", async () => { const { session } = openTestSession(); await session.commit((tx) => tx.doc(V1Doc), context); - const state = (await session.documentState(V1Doc, context))!; const watch = (await session.watchDoc(V1Doc, context))!; const frames: (readonly unknown[])[] = []; const delivered = new Promise((resolve) => { @@ -846,10 +845,8 @@ describe("Session tracker cache across definition versions", () => { (await tx.doc(V2Doc)).names.push("second"); }, context); await delivered; - expect(state.value).toEqual({ names: ["first", "second"] }); expect(watch.value).toEqual({ names: ["first", "second"] }); expect(frames).toEqual([[["r", { names: ["first", "second"] }]]]); - state.dispose(); await watch.stop(); }); diff --git a/packages/durable/test/session-definitions.test.ts b/packages/durable/test/session-definitions.test.ts index 1146a1067..d4459d105 100644 --- a/packages/durable/test/session-definitions.test.ts +++ b/packages/durable/test/session-definitions.test.ts @@ -2,7 +2,6 @@ import type { Draft } from "@earendil-works/chord"; import { type ConversationId, createSession, - type DocumentState, type DocumentWatch, defineDoc, defineDocFamily, @@ -160,7 +159,6 @@ describe("document definitions", () => { const Note = defineEntry<{ text: string }>("t.note"); const Marker = defineEntry("t.marker"); type Rewound = Promise | undefined>; - type StateOf = Promise | undefined>; type WatchOf = Promise | undefined>; const check = async (session: Session, tx: Tx, conversationId: ConversationId, taskId: TaskId, at: EntryId) => { expectTypeOf(session.snapshotAsOf(RewindableDoc, conversationId, at, context)).toEqualTypeOf(); @@ -174,12 +172,6 @@ describe("document definitions", () => { // @ts-expect-error task documents keep no history void session.snapshotAsOf(TaskDoc, taskId, at, context); - expectTypeOf(session.documentState(SessionDoc, context)).toEqualTypeOf(); - expectTypeOf(session.documentState(LatestDoc, conversationId, context)).toEqualTypeOf(); - expectTypeOf(session.documentState(TaskDoc, taskId, context)).toEqualTypeOf(); - expectTypeOf(session.documentState(SessionFamily, "k", context)).toEqualTypeOf(); - expectTypeOf(session.documentState(ConversationFamily, conversationId, "k", context)).toEqualTypeOf(); - expectTypeOf(session.documentState(TaskFamily, taskId, "k", context)).toEqualTypeOf(); expectTypeOf(session.watchDoc(SessionDoc, context)).toEqualTypeOf(); expectTypeOf(session.watchDoc(LatestDoc, conversationId, context)).toEqualTypeOf(); expectTypeOf(session.watchDoc(TaskDoc, taskId, context)).toEqualTypeOf(); diff --git a/packages/durable/test/session-document-retention.test.ts b/packages/durable/test/session-document-retention.test.ts index 04f78586b..6e2eed770 100644 --- a/packages/durable/test/session-document-retention.test.ts +++ b/packages/durable/test/session-document-retention.test.ts @@ -10,6 +10,7 @@ import { defineTool, type HarnessSettings, MemoryStorage, + type Seq, type StorageWrite, watchEvents, } from "@earendil-works/pi-durable"; @@ -146,12 +147,13 @@ describe("document cache retention", () => { await session.close(context); }); - it("never evicts a document a watch or a document state observes, and evicts it once the last detaches", async () => { + it("never evicts a document a watch observes, and evicts it once the last watch detaches", async () => { const { session, storage, clock, sizes } = open(1_000); const conversationId = await createConversation(session); await bump(session, conversationId); const watch = (await session.watchDoc(CounterDoc, conversationId, context))!; - const state = (await session.documentState(CounterDoc, conversationId, context))!; + const kept = (await session.watchDoc(CounterDoc, conversationId, context))!; + kept.start(async () => {}); const seen: number[] = []; watch.start(async (value) => { seen.push(value?.count ?? -1); @@ -160,8 +162,7 @@ describe("document cache retention", () => { await bump(session, conversationId); await settleLine(session); // Still cached: a read long after its retention passed loads nothing from Storage. - expect(state.value?.count).toBe(2); - await eventually(() => seen.includes(2)); + await eventually(() => seen.includes(2) && kept.value?.count === 2); const reads = storage.documentReadCount; clock.now += 1_000_000; await session.commit(async (tx) => { @@ -174,8 +175,8 @@ describe("document cache retention", () => { clock.now += 1_000_000; await bump(session, conversationId); await settleLine(session); - expect(state.value?.count).toBe(3); - state.dispose(); + await eventually(() => kept.value?.count === 3); + await kept.stop(); clock.now += 1_000_000; await session.commit(async () => {}, context); await session.commit(async (tx) => { @@ -215,9 +216,9 @@ describe("document cache retention", () => { const conversationId = await createConversation(session); const writes: StorageWrite[][] = []; const commit = storage.commit.bind(storage); - storage.commit = (batch: readonly StorageWrite[], commitContext: Context) => { + storage.commit = (batch: readonly StorageWrite[], adopt: (seq: Seq) => void, commitContext: Context) => { writes.push([...batch]); - return commit(batch, commitContext); + return commit(batch, adopt, commitContext); }; for (let n = 0; n < 5; n++) { await bump(session, conversationId); @@ -353,16 +354,18 @@ describe("document cache retention", () => { const { session, sizes } = open(0); const conversationId = await createConversation(session); await bump(session, conversationId); - const state = (await session.documentState(CounterDoc, conversationId, context))!; + const watch = (await session.watchDoc(CounterDoc, conversationId, context))!; + watch.start(async () => {}); await session.commit((tx) => tx.retireDoc(CounterDoc, conversationId), context); - expect(state.value).toBeNull(); + expect(await watch.closed).toEqual({ reason: "retired" }); + expect(watch.value).toBeNull(); await bump(session, conversationId, 5); await eventually(async () => { await settleLine(session); return sizes().cached === 0; }); expect(await session.snapshot(CounterDoc, conversationId, context)).toEqual({ count: 5 }); - state.dispose(); + await watch.stop(); await session.close(context); }); @@ -375,7 +378,7 @@ describe("document cache retention", () => { return session.documentCacheSizes.cached === 0; }); const gate = storage.holdFindDocument(); - const attaching = session.documentState(CounterDoc, conversationId, context); + const attaching = session.watchDoc(CounterDoc, conversationId, context); await gate.entered; const closing = session.close(context); gate.release(); @@ -384,14 +387,14 @@ describe("document cache retention", () => { expect(session.documentCacheSizes).toMatchObject({ cached: 0, idle: 0, attached: 0 }); }); - it("evicts on its timer without a commit, and tolerates a detach called twice", async () => { + it("evicts on its timer without a commit, and tolerates a stop called twice", async () => { const storage = new ControlledStorage(); const session = new SessionImpl(storage, Date.now, () => 20); const conversationId = await createConversation(session); await bump(session, conversationId); - const state = (await session.documentState(CounterDoc, conversationId, context))!; - state.dispose(); - state.dispose(); + const watch = (await session.watchDoc(CounterDoc, conversationId, context))!; + await watch.stop(); + await watch.stop(); const until = Date.now() + 2_000; while (session.documentCacheSizes.cached > 0 && Date.now() < until) await new Promise((resolve) => setTimeout(resolve, 10)); @@ -561,11 +564,11 @@ describe("document cache retention", () => { }); const { session, sizes } = open(0); await session.commit((tx) => tx.doc(V1).then(() => undefined), context); - const state = (await session.documentState(V1, context))!; + const watch = (await session.watchDoc(V1, context))!; expect(await session.snapshot(V2, context)).toEqual({ count: 3, v2: true }); await settleLine(session); expect(sizes().cached).toBe(1); - state.dispose(); + await watch.stop(); await eventually(async () => { await settleLine(session); return sizes().cached === 0; diff --git a/packages/durable/test/session-states.test.ts b/packages/durable/test/session-states.test.ts deleted file mode 100644 index 121912c79..000000000 --- a/packages/durable/test/session-states.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { defineDoc, defineDocFamily } from "@earendil-works/pi-durable"; -import { describe, expect, it } from "vitest"; -import { context, createConversation, documentChanges, flush, openTestSession } from "./session-support.ts"; - -type State = { value: number; retained: { label: string } }; - -const StateDoc = defineDoc({ - checkpointWhen: () => false, - kind: "state.state", - version: 1, - scope: "session", - initial: () => ({ value: 0, retained: { label: "stable" } }), -}); - -const FamilyDoc = defineDocFamily<{ value: number }, string>({ - checkpointWhen: () => false, - kind: "state.family", - version: 1, - family: true, - scope: "session", - initial: (seed) => ({ value: seed.length }), -}); - -async function createState(): Promise> { - const harness = openTestSession(); - await harness.session.commit((tx) => tx.doc(StateDoc).then(() => undefined), context); - await flush(); - return harness; -} - -describe("Session document states", () => { - it("never creates an absent document", async () => { - const { session, storage } = openTestSession(); - const commits = storage.commits.length; - expect(await session.documentState(StateDoc, context)).toBeUndefined(); - expect(await session.documentState(FamilyDoc, "missing", context)).toBeUndefined(); - expect(storage.commits).toHaveLength(commits); - expect(storage.mintCount).toBe(0); - }); - - it("returns an immediately hydrated read-only state with contiguous Chord deliveries", async () => { - const { session } = await createState(); - const baseline = (await session.snapshot(StateDoc, context))!; - const state = (await session.documentState(StateDoc, context))!; - const deliveries: Array<{ value: Readonly | null; sequence: number }> = []; - state.subscribe((value, _context, delivery) => deliveries.push({ value, sequence: delivery.sequence })); - expect(state.value).toBe(baseline); - - await session.commit(async (tx) => { - (await tx.doc(StateDoc)).value = 1; - }, context); - await session.commit(async (tx) => { - (await tx.doc(StateDoc)).value = 2; - }, context); - await flush(); - - expect(deliveries.map(({ sequence }) => sequence)).toEqual([0, 1, 2]); - expect(deliveries.map(({ value }) => value?.value)).toEqual([0, 1, 2]); - expect(state.value).toBe(await session.snapshot(StateDoc, context)); - state.dispose(); - }); - - it("creates independent disposable states for one incarnation", async () => { - const { session } = await createState(); - const first = (await session.documentState(StateDoc, context))!; - const second = (await session.documentState(StateDoc, context))!; - expect(second).not.toBe(first); - await session.commit(async (tx) => { - (await tx.doc(StateDoc)).value = 1; - }, context); - await flush(); - expect(first.value?.value).toBe(1); - expect(second.value?.value).toBe(1); - - first.dispose(); - await session.commit(async (tx) => { - (await tx.doc(StateDoc)).value = 2; - }, context); - await flush(); - expect(first.value?.value).toBe(1); - expect(second.value?.value).toBe(2); - second.dispose(); - }); - - it("shares exact committed value references with Chord", async () => { - const { session, publications } = await createState(); - const state = (await session.documentState(StateDoc, context))!; - await session.commit(async (tx) => { - (await tx.doc(StateDoc)).value = 4; - }, context); - await flush(); - const published = documentChanges(publications.at(-1)!)[0]!; - expect(state.value).toBe(published.value); - expect(state.value?.retained).toBe((await session.snapshot(StateDoc, context))!.retained); - state.dispose(); - }); - - it("captures a late baseline without redelivering an already covered commit", async () => { - const { session } = await createState(); - await session.commit(async (tx) => { - (await tx.doc(StateDoc)).value = 1; - }, context); - const state = (await session.documentState(StateDoc, context))!; - const deliveries: number[] = []; - state.subscribe((_value, _context, delivery) => deliveries.push(delivery.sequence)); - await flush(); - expect(state.value?.value).toBe(1); - expect(deliveries).toEqual([0]); - state.dispose(); - }); - - it("publishes null retirement and never follows a replacement incarnation", async () => { - const { session } = await createState(); - const oldState = (await session.documentState(StateDoc, context))!; - await session.commit(async (tx) => { - await tx.retireDoc(StateDoc); - (await tx.doc(StateDoc)).value = 10; - }, context); - await flush(); - expect(oldState.value).toBeNull(); - - const replacement = (await session.documentState(StateDoc, context))!; - expect(replacement.value?.value).toBe(10); - await session.commit(async (tx) => { - (await tx.doc(StateDoc)).value = 11; - }, context); - await flush(); - expect(oldState.value).toBeNull(); - expect(replacement.value?.value).toBe(11); - oldState.dispose(); - replacement.dispose(); - }); - - it("cold-loads a definition-free fork copy", async () => { - const Copied = defineDoc<{ value: number }>({ - checkpointWhen: () => false, - kind: "state.copied", - version: 1, - scope: "conversation", - history: "latest", - fork: "current", - initial: () => ({ value: 0 }), - }); - const { session, storage } = openTestSession(); - const parentId = await createConversation(session); - const entry = await session.commit(async (tx) => { - const created = await tx.appendEntry(parentId, { kind: "point" }); - (await tx.doc(Copied, parentId)).value = 7; - return created; - }, context); - const childId = await session.commit( - async (tx) => (await tx.forkConversation(parentId, entry.id, { ownership: { kind: "ownerless" } })).id, - context, - ); - const reads = storage.documentReadCount; - const state = (await session.documentState(Copied, childId, context))!; - expect(state.value).toEqual({ value: 7 }); - expect(storage.documentReadCount).toBeGreaterThan(reads); - state.dispose(); - }); - - it("hydrates a migrated tracker without writing and skips an equal version-base update", async () => { - const Old = defineDoc<{ value: number }>({ - checkpointWhen: () => false, - kind: "state.migration", - version: 1, - scope: "session", - initial: () => ({ value: 3 }), - }); - const Current = defineDoc<{ value: number; migrated: boolean }>({ - checkpointWhen: () => false, - kind: "state.migration", - version: 2, - scope: "session", - initial: () => ({ value: 0, migrated: false }), - migrate: (value) => ({ value: value.value as number, migrated: true }), - }); - const { session, storage } = openTestSession(); - await session.commit((tx) => tx.doc(Old).then(() => undefined), context); - await session.unloadDocuments(); - const commits = storage.commits.length; - const state = (await session.documentState(Current, context))!; - expect(state.value).toEqual({ value: 3, migrated: true }); - expect(storage.commits).toHaveLength(commits); - const baseline = state.value; - - await session.commit((tx) => tx.doc(Current).then(() => undefined), context); - await flush(); - expect(storage.commits).toHaveLength(commits + 1); - expect(state.value).toBe(baseline); - await session.commit(async (tx) => { - (await tx.doc(Current)).value = 4; - }, context); - await flush(); - expect(state.value).toEqual({ value: 4, migrated: true }); - state.dispose(); - }); - - it("continues from exact committed values after the tracker cache unloads", async () => { - const { session, storage } = await createState(); - const state = (await session.documentState(StateDoc, context))!; - const baseline = state.value; - const reads = storage.documentReadCount; - await session.unloadDocuments(); - const reloaded = await session.snapshot(StateDoc, context); - expect(reloaded).toEqual(baseline); - expect(reloaded).not.toBe(baseline); - expect(storage.documentReadCount).toBeGreaterThan(reads); - await session.commit(async (tx) => { - (await tx.doc(StateDoc)).value = 6; - }, context); - await flush(); - expect(state.value?.value).toBe(6); - state.dispose(); - }); - - it("exposes trusted shared immutable values without freezing", async () => { - const { session } = await createState(); - const snapshot = (await session.snapshot(StateDoc, context))!; - const state = (await session.documentState(StateDoc, context))!; - expect(state.value).toBe(snapshot); - expect(Object.isFrozen(state.value)).toBe(false); - expect(Object.isFrozen(state.value!.retained)).toBe(false); - state.dispose(); - }); -}); diff --git a/packages/durable/test/session-support.ts b/packages/durable/test/session-support.ts index 7d0fd2528..b5c4df8b6 100644 --- a/packages/durable/test/session-support.ts +++ b/packages/durable/test/session-support.ts @@ -78,7 +78,11 @@ export class ControlledStorage extends MemoryStorage { held.gate.resolve(); } - override async commit(writes: readonly StorageWrite[], commitContext: Context): Promise { + override async commit( + writes: readonly StorageWrite[], + adopt: (seq: Seq) => void, + commitContext: Context, + ): Promise { this.admittedCommits.push(writes); this.commits.push(structuredClone(writes)); const held = this.#commitGate; @@ -91,7 +95,7 @@ export class ControlledStorage extends MemoryStorage { this.#commitFailure = undefined; throw failure; } - return super.commit(writes, commitContext); + return super.commit(writes, adopt, commitContext); } override mintId>(): Promise { diff --git a/packages/durable/test/session-watches.test.ts b/packages/durable/test/session-watches.test.ts index 13e0ae46a..d0dfbf9d9 100644 --- a/packages/durable/test/session-watches.test.ts +++ b/packages/durable/test/session-watches.test.ts @@ -1,4 +1,6 @@ -import type { Context } from "@earendil-works/chord"; +import { setFlagsFromString } from "node:v8"; +import { runInNewContext } from "node:vm"; +import type { Context, JsonValue } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT, createContextKey, @@ -7,8 +9,10 @@ import { withContextValue, } from "@earendil-works/chord/context"; import { applyImmutable } from "@earendil-works/chord/delta"; -import { defineDoc } from "@earendil-works/pi-durable"; +import { type DocumentWatch, defineDoc, MemoryStorage } from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; +import { CommittedWatch } from "../src/session/observation.ts"; +import { SessionImpl } from "../src/session/session.ts"; import { context, documentChanges, flush, openTestSession } from "./session-support.ts"; type State = { value: number; items: string[]; retained: { label: string } }; @@ -308,7 +312,7 @@ describe("Session document watches", () => { const baseline = watch.value; await session.commit((tx) => tx.retireDoc(StateDoc), context); await session.close(context); - expect(await watch.closed).toEqual({ reason: "session_closed" }); + expect(await watch.closed).toEqual({ reason: "ended" }); expect(watch.value).toBe(baseline); expect(() => watch.start(async () => undefined)).toThrow("stopped"); }); @@ -330,7 +334,7 @@ describe("Session document watches", () => { await entered.promise; await session.commit((tx) => tx.retireDoc(StateDoc), context); await session.close(context); - expect(await watch.closed).toEqual({ reason: "session_closed" }); + expect(await watch.closed).toEqual({ reason: "ended" }); release.resolve(); await flush(); expect(values).toEqual([1]); @@ -403,7 +407,7 @@ describe("Session document watches", () => { await entered.promise; await session.close(context); expect(deliveryContext!.abortSignal).toBeUndefined(); - expect(await watch.closed).toEqual({ reason: "session_closed" }); + expect(await watch.closed).toEqual({ reason: "ended" }); release.resolve(); }); @@ -503,4 +507,74 @@ describe("Session document watches", () => { expect(watch.value?.value).toBe(7); await watch.stop(); }); + + it("ends retired only on its source's retirement, never on a null value", async () => { + const watch = new CommittedWatch( + 1, + () => {}, + () => {}, + ); + const frames: Array<{ value: JsonValue; ops: unknown }> = []; + watch.start(async (value, ops) => { + frames.push({ value, ops }); + }); + let end: unknown; + void watch.closed.then((settled) => { + end = settled; + }); + watch.advance(null, [["r", null]], context); + watch.advance(2, [["r", 2]], context); + await flush(); + expect(frames).toEqual([ + { value: null, ops: [["r", null]] }, + { value: 2, ops: [["r", 2]] }, + ]); + expect(end).toBeUndefined(); + expect(await watch.stop()).toEqual({ reason: "stopped" }); + }); + + it("retains neither its Session nor its listener once stopped, while still referenced", async () => { + setFlagsFromString("--expose-gc"); + const gc = runInNewContext("gc") as () => void; + const collect = async (): Promise => { + for (let round = 0; round < 5; round++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + gc(); + } + }; + /** A watch of a Session nothing else references; `stop` says whether to stop it before returning. */ + const acquire = async (stop: boolean) => { + // No eviction timer, so nothing but the watch can keep the Session alive. + const session = new SessionImpl( + new MemoryStorage(), + Date.now, + () => Number.POSITIVE_INFINITY, + () => () => {}, + ); + await session.commit((tx) => tx.doc(StateDoc).then(() => undefined), context); + const watch: DocumentWatch = (await session.watchDoc(StateDoc, context))!; + const listener = async (): Promise => {}; + watch.start(listener); + await session.commit(async (tx) => { + (await tx.doc(StateDoc)).value = 1; + }, context); + await flush(); + if (stop) await watch.stop(); + return { watch, session: new WeakRef(session), listener: new WeakRef(listener) }; + }; + + // Control: a running watch keeps its Session, so the collection below is real. + const running = await acquire(false); + await collect(); + expect(running.session.deref()).toBeDefined(); + await running.watch.stop(); + + const stopped = await acquire(true); + await collect(); + expect(stopped.session.deref()).toBeUndefined(); + expect(stopped.listener.deref()).toBeUndefined(); + // What it delivered last stays readable. + expect(stopped.watch.value?.value).toBe(1); + expect(await stopped.watch.closed).toEqual({ reason: "stopped" }); + }); }); diff --git a/packages/durable/test/sqlite-cloudflare.test.ts b/packages/durable/test/sqlite-cloudflare.test.ts index 0bf9c101d..e9580829f 100644 --- a/packages/durable/test/sqlite-cloudflare.test.ts +++ b/packages/durable/test/sqlite-cloudflare.test.ts @@ -53,6 +53,7 @@ describe("Durable Object SQLite adapter", () => { { type: "conversation", value: { id: ROOT_CONVERSATION_ID } }, { type: "entry", value: { id: entryId, conversationId: ROOT_CONVERSATION_ID, kind: "first" } }, ], + () => {}, BACKGROUND_CONTEXT, ), second.commit( @@ -60,6 +61,7 @@ describe("Durable Object SQLite adapter", () => { { type: "conversation", value: { id: ROOT_CONVERSATION_ID } }, { type: "entry", value: { id: entryId, conversationId: ROOT_CONVERSATION_ID, kind: "second" } }, ], + () => {}, BACKGROUND_CONTEXT, ), ]); diff --git a/packages/durable/test/sqlite-facade.test.ts b/packages/durable/test/sqlite-facade.test.ts index 46662535f..ab8b9da8e 100644 --- a/packages/durable/test/sqlite-facade.test.ts +++ b/packages/durable/test/sqlite-facade.test.ts @@ -13,6 +13,9 @@ import { SqliteStorage } from "../src/storage/sqlite/index.ts"; import { NodeSqliteDatabase, openNodeSqliteDatabase } from "../src/storage/sqlite/node.ts"; import { type DocumentId, type EntryId, ROOT_CONVERSATION_ID } from "../src/types.ts"; +/** Storage-level commits here have no in-memory owner to adopt them. */ +const adopt = (): void => {}; + type SettlementMode = "immediate" | "delay" | "reject"; class ControlledSettlementDatabase implements SqliteDatabase { @@ -94,12 +97,13 @@ describe("portable SQLite facade settlement", () => { it("prepares each storage statement once per connection and reuses it across transactions", async () => { const connection = new PrepareCountingDatabaseSync(":memory:"); const storage = await SqliteStorage.open(new NodeSqliteDatabase(connection)); - await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], BACKGROUND_CONTEXT); + await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], adopt, BACKGROUND_CONTEXT); await storage.commit( Array.from({ length: 100 }, (_, index) => ({ type: "entry" as const, value: { id: idFromNumber(index + 2), conversationId: ROOT_CONVERSATION_ID, kind: "cached" }, })), + adopt, BACKGROUND_CONTEXT, ); await storage.commit( @@ -109,12 +113,13 @@ describe("portable SQLite facade settlement", () => { value: { id: idFromNumber(102), conversationId: ROOT_CONVERSATION_ID, kind: "cached-again" }, }, ], + adopt, BACKGROUND_CONTEXT, ); expect((await storage.entry(idFromNumber(2), BACKGROUND_CONTEXT))?.entry.kind).toBe("cached"); expect((await storage.entry(idFromNumber(102), BACKGROUND_CONTEXT))?.entry.kind).toBe("cached-again"); await expect( - storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], BACKGROUND_CONTEXT), + storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], adopt, BACKGROUND_CONTEXT), ).rejects.toThrow("ID 1 already belongs to conversation"); expect((await storage.entry(idFromNumber(2), BACKGROUND_CONTEXT))?.entry.kind).toBe("cached"); expect(connection.repeatedPrepares()).toEqual([]); @@ -126,6 +131,7 @@ describe("portable SQLite facade settlement", () => { const storage = await SqliteStorage.open(database); const commit = storage.commit( [{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], + adopt, BACKGROUND_CONTEXT, ); const read = storage.conversation(ROOT_CONVERSATION_ID, BACKGROUND_CONTEXT); @@ -142,6 +148,7 @@ describe("portable SQLite facade settlement", () => { { type: "conversation", value: { id: ROOT_CONVERSATION_ID } }, { type: "entry", value: { id: entryId, conversationId: ROOT_CONVERSATION_ID, kind: "probe" } }, ], + adopt, BACKGROUND_CONTEXT, ); const scan = storage.scanEntries({ conversationId: ROOT_CONVERSATION_ID }, 10, undefined, BACKGROUND_CONTEXT); @@ -180,6 +187,7 @@ describe("portable SQLite facade settlement", () => { value: { id: idFromNumber(100), conversationId: ROOT_CONVERSATION_ID, kind: "settled" }, }, ], + adopt, BACKGROUND_CONTEXT, ); let committed = false; @@ -201,6 +209,7 @@ describe("portable SQLite facade settlement", () => { value: { id: idFromNumber(200), conversationId: ROOT_CONVERSATION_ID, kind: "rejected" }, }, ], + adopt, BACKGROUND_CONTEXT, ); expect(await storage.mintId()).toBe(102); @@ -224,12 +233,14 @@ describe("portable SQLite facade settlement", () => { content: { kind: "base", version: 1, value: { value: 1 } }, }, ], + adopt, BACKGROUND_CONTEXT, ); const read = storage.document(id, "current", BACKGROUND_CONTEXT); for (let index = 0; index < yields; index++) await Promise.resolve(); const replace = storage.commit( [{ type: "document.change", id, content: { kind: "base", version: 1, value: { value: 2 } } }], + adopt, BACKGROUND_CONTEXT, ); const [stored] = await Promise.all([read, replace]); diff --git a/packages/durable/test/sqlite-migrations.test.ts b/packages/durable/test/sqlite-migrations.test.ts index 0006e8a25..2c33edc2c 100644 --- a/packages/durable/test/sqlite-migrations.test.ts +++ b/packages/durable/test/sqlite-migrations.test.ts @@ -128,6 +128,7 @@ describe("durable SQLite migrations", () => { }, }, ], + () => {}, BACKGROUND_CONTEXT, ); await storage.close(BACKGROUND_CONTEXT); diff --git a/packages/durable/test/sqlite-storage.test.ts b/packages/durable/test/sqlite-storage.test.ts index c3db82ab2..811862dec 100644 --- a/packages/durable/test/sqlite-storage.test.ts +++ b/packages/durable/test/sqlite-storage.test.ts @@ -24,6 +24,9 @@ import type { } from "../src/types.ts"; import { ROOT_CONVERSATION_ID } from "../src/types.ts"; +/** Storage-level commits here have no in-memory owner to adopt them. */ +const adopt = (): void => {}; + const context = BACKGROUND_CONTEXT; const openStorages = new Set(); const tempDirectories = new Set(); @@ -56,10 +59,14 @@ class ReopeningStorage implements Storage { this.options = options; } - async commit(writes: readonly StorageWrite[], commitContext: Parameters[1]): Promise { - if (this.closed) return this.current.commit(writes, commitContext); + async commit( + writes: readonly StorageWrite[], + adopt: Parameters[1], + commitContext: Parameters[2], + ): Promise { + if (this.closed) return this.current.commit(writes, adopt, commitContext); try { - return await this.current.commit(writes, commitContext); + return await this.current.commit(writes, adopt, commitContext); } finally { await this.current.close(context); this.current = await openNodeSqliteStorage(this.path, this.options); @@ -133,7 +140,7 @@ function entry(id: EntryId, conversationId: ConversationId, data?: JsonValue): E } async function createRoot(storage: Storage): Promise { - return storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context); + return storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], adopt, context); } function pendingTask(id: TaskId): TaskRecord { @@ -171,7 +178,9 @@ describe("Pico SqliteStorage", () => { const { storage, path } = await createSqliteStorage(); expect(await createRoot(storage)).toBe(1); const entryId = await storage.mintId(); - expect(await storage.commit([{ type: "entry", value: entry(entryId, ROOT_CONVERSATION_ID) }], context)).toBe(2); + expect( + await storage.commit([{ type: "entry", value: entry(entryId, ROOT_CONVERSATION_ID) }], adopt, context), + ).toBe(2); await storage.close(context); openStorages.delete(storage); @@ -185,6 +194,7 @@ describe("Pico SqliteStorage", () => { expect( await reopened.commit( [{ type: "task", value: pendingTask(await reopened.mintId>()) }], + adopt, context, ), ).toBe(3); @@ -255,6 +265,7 @@ describe("Pico SqliteStorage", () => { content: { kind: "base", version: 1, value: { retained: true } }, }, ], + adopt, context, ); const database = new DatabaseSync(path); @@ -280,6 +291,7 @@ describe("Pico SqliteStorage", () => { content: { kind: "base", version: 1, value: { nested: { value: 1 }, rows: [] } }, }, ], + adopt, context, ); await storage.commit( @@ -294,6 +306,7 @@ describe("Pico SqliteStorage", () => { }, }, ], + adopt, context, ); await storage.commit( @@ -312,6 +325,7 @@ describe("Pico SqliteStorage", () => { }, }, ], + adopt, context, ); await storage.commit( @@ -322,6 +336,7 @@ describe("Pico SqliteStorage", () => { content: { kind: "delta", version: 1, ops: [["s", ["nested", "value"], 4]] }, }, ], + adopt, context, ); @@ -360,15 +375,16 @@ describe("Pico SqliteStorage", () => { value: { ...pendingTask(transientTaskId), input: circular as unknown as JsonValue }, }, ], + adopt, context, ), ).rejects.toThrow("circular structure"); expect(await storage.entry(transientId, context)).toBeUndefined(); expect(await storage.task(transientTaskId, context)).toBeUndefined(); const committedId = await storage.mintId(); - expect(await storage.commit([{ type: "entry", value: entry(committedId, ROOT_CONVERSATION_ID) }], context)).toBe( - 2, - ); + expect( + await storage.commit([{ type: "entry", value: entry(committedId, ROOT_CONVERSATION_ID) }], adopt, context), + ).toBe(2); const db = new DatabaseSync(path, { readOnly: true }); try { @@ -392,6 +408,7 @@ describe("Pico SqliteStorage", () => { } satisfies DocumentCreate; const createdAt = await storage.commit( [{ type: "document.create", record, content: { kind: "base", version: 1, value: { count: 0 } } }], + adopt, context, ); let ancientAt = createdAt; @@ -408,6 +425,7 @@ describe("Pico SqliteStorage", () => { : { kind: "delta", version: 1, ops: [["s", ["count"], count]] }, }, ], + adopt, context, ); if (count === 5) ancientAt = recentAt; @@ -490,11 +508,13 @@ describe("Pico SqliteStorage", () => { content: { kind: "base", version: 1, value: { count: 0 } }, }, ], + adopt, context, ); for (let count = 1; count <= 10; count++) { await storage.commit( [{ type: "document.change", id, content: { kind: "delta", version: 1, ops: [["s", ["count"], count]] } }], + adopt, context, ); } @@ -512,6 +532,7 @@ describe("Pico SqliteStorage", () => { } await storage.commit( [{ type: "document.change", id, content: { kind: "base", version: 1, value: { count: 11 } } }], + adopt, context, ); expect(revisionCount(path, id)).toBe(1); @@ -523,10 +544,11 @@ describe("Pico SqliteStorage", () => { content: { kind: "delta", version: 1, ops: [["r", { count: 12 }]] }, }, ], + adopt, context, ); expect(revisionCount(path, id)).toBe(2); - await storage.commit([{ type: "document.retire", id }], context); + await storage.commit([{ type: "document.retire", id }], adopt, context); expect(revisionCount(path, id)).toBe(0); }); @@ -544,6 +566,7 @@ describe("Pico SqliteStorage", () => { }), }, ], + adopt, context, ); } @@ -573,10 +596,12 @@ describe("Pico SqliteStorage", () => { content: { kind: "base", version: 1, value: { text: large } }, }, ], + adopt, context, ); await storage.commit( [{ type: "document.change", id, content: { kind: "base", version: 1, value: { text: "small" } } }], + adopt, context, ); const before = new DatabaseSync(path, { readOnly: true }); @@ -591,6 +616,7 @@ describe("Pico SqliteStorage", () => { expect(freeAfterDelete).toBeGreaterThan(0); await storage.commit( [{ type: "document.change", id, content: { kind: "base", version: 1, value: { text: large } } }], + adopt, context, ); const after = new DatabaseSync(path, { readOnly: true }); @@ -618,6 +644,7 @@ describe("Pico SqliteStorage", () => { }), }, ], + adopt, context, ); } @@ -636,6 +663,7 @@ describe("Pico SqliteStorage", () => { content: { kind: "base", version: 1, value: { count: 0 } }, }, ], + adopt, context, ); for (let count = 1; count <= 100; count++) { @@ -647,6 +675,7 @@ describe("Pico SqliteStorage", () => { content: { kind: "delta", version: 1, ops: [["s", ["count"], count]] }, }, ], + adopt, context, ); } From b9fb2b854b7a19b28a68c3421365fee0a5342ce6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 22 Oct 2026 10:56:50 +0000 Subject: [PATCH 16/30] docs(durable): renumber examples --- packages/durable/README.md | 50 +++++++++---------- .../test/examples/03-owned-conversations.ts | 2 +- .../examples/{05-watches.ts => 04-watches.ts} | 2 +- .../examples/{06-harness.ts => 05-harness.ts} | 2 +- ...7-configuration.ts => 06-configuration.ts} | 2 +- ...sations.ts => 07-harness-conversations.ts} | 2 +- .../examples/{09-context.ts => 08-context.ts} | 2 +- ...gistry-reload.ts => 09-registry-reload.ts} | 2 +- ...tension-state.ts => 10-extension-state.ts} | 2 +- .../examples/{12-tasks.ts => 11-tasks.ts} | 4 +- .../{13-recovery.ts => 12-recovery.ts} | 2 +- .../test/examples/{14-chat.ts => 13-chat.ts} | 4 +- ...5-system-prompt.ts => 14-system-prompt.ts} | 2 +- .../{16-real-model.ts => 15-real-model.ts} | 2 +- ...{17-coding-tools.ts => 16-coding-tools.ts} | 2 +- .../examples/{18-print.ts => 17-print.ts} | 2 +- .../test/examples/{19-json.ts => 18-json.ts} | 2 +- .../examples/{20-inbox.ts => 19-inbox.ts} | 2 +- .../{21-late-join.ts => 20-late-join.ts} | 2 +- ...oreground.ts => 21-subagent-foreground.ts} | 2 +- ...ackground.ts => 22-subagent-background.ts} | 2 +- .../{24-child-tasks.ts => 23-child-tasks.ts} | 2 +- .../{25-compaction.ts => 24-compaction.ts} | 2 +- ...{26-coding-agent.ts => 25-coding-agent.ts} | 2 +- .../{27-plan-mode.ts => 26-plan-mode.ts} | 2 +- .../{28-reviewer.ts => 27-reviewer.ts} | 2 +- ...tion.ts => 28-sandbox-per-conversation.ts} | 2 +- ...0-tool-override.ts => 29-tool-override.ts} | 2 +- ...nd-restart.ts => 30-reload-and-restart.ts} | 2 +- 29 files changed, 55 insertions(+), 55 deletions(-) rename packages/durable/test/examples/{05-watches.ts => 04-watches.ts} (98%) rename packages/durable/test/examples/{06-harness.ts => 05-harness.ts} (99%) rename packages/durable/test/examples/{07-configuration.ts => 06-configuration.ts} (99%) rename packages/durable/test/examples/{08-harness-conversations.ts => 07-harness-conversations.ts} (98%) rename packages/durable/test/examples/{09-context.ts => 08-context.ts} (99%) rename packages/durable/test/examples/{10-registry-reload.ts => 09-registry-reload.ts} (98%) rename packages/durable/test/examples/{11-extension-state.ts => 10-extension-state.ts} (99%) rename packages/durable/test/examples/{12-tasks.ts => 11-tasks.ts} (97%) rename packages/durable/test/examples/{13-recovery.ts => 12-recovery.ts} (99%) rename packages/durable/test/examples/{14-chat.ts => 13-chat.ts} (95%) rename packages/durable/test/examples/{15-system-prompt.ts => 14-system-prompt.ts} (99%) rename packages/durable/test/examples/{16-real-model.ts => 15-real-model.ts} (99%) rename packages/durable/test/examples/{17-coding-tools.ts => 16-coding-tools.ts} (99%) rename packages/durable/test/examples/{18-print.ts => 17-print.ts} (98%) rename packages/durable/test/examples/{19-json.ts => 18-json.ts} (98%) rename packages/durable/test/examples/{20-inbox.ts => 19-inbox.ts} (99%) rename packages/durable/test/examples/{21-late-join.ts => 20-late-join.ts} (99%) rename packages/durable/test/examples/{22-subagent-foreground.ts => 21-subagent-foreground.ts} (99%) rename packages/durable/test/examples/{23-subagent-background.ts => 22-subagent-background.ts} (99%) rename packages/durable/test/examples/{24-child-tasks.ts => 23-child-tasks.ts} (99%) rename packages/durable/test/examples/{25-compaction.ts => 24-compaction.ts} (99%) rename packages/durable/test/examples/{26-coding-agent.ts => 25-coding-agent.ts} (99%) rename packages/durable/test/examples/{27-plan-mode.ts => 26-plan-mode.ts} (99%) rename packages/durable/test/examples/{28-reviewer.ts => 27-reviewer.ts} (99%) rename packages/durable/test/examples/{29-sandbox-per-conversation.ts => 28-sandbox-per-conversation.ts} (98%) rename packages/durable/test/examples/{30-tool-override.ts => 29-tool-override.ts} (99%) rename packages/durable/test/examples/{31-reload-and-restart.ts => 30-reload-and-restart.ts} (99%) diff --git a/packages/durable/README.md b/packages/durable/README.md index 6f8898d26..ca9826575 100644 --- a/packages/durable/README.md +++ b/packages/durable/README.md @@ -594,7 +594,7 @@ stream.start(async (events) => { }); ``` -Events are derived from commits, one batch per commit, and apply on top of the snapshot. Message and tool updates carry deltas: text and thinking appends, appended tool-call argument text, and output trims and appends. When a consumer falls more than 100 batches behind, it receives a fresh `snapshot` event instead. See `test/examples/19-json.ts` for the full stream of one run. +Events are derived from commits, one batch per commit, and apply on top of the snapshot. Message and tool updates carry deltas: text and thinking appends, appended tool-call argument text, and output trims and appends. When a consumer falls more than 100 batches behind, it receives a fresh `snapshot` event instead. See `test/examples/18-json.ts` for the full stream of one run. ## Hooks @@ -670,8 +670,8 @@ Owned work belongs to its owner: The examples show both patterns as product code: -- [`22-subagent-foreground.ts`](test/examples/22-subagent-foreground.ts): the tool above, returning the child's answer. The UI finds the child through the call's `details` and prints the child's events indented under the call. -- [`23-subagent-background.ts`](test/examples/23-subagent-background.ts): persistent subagents behind one `subagent` tool that spawns, messages (steer or follow-up), waits for, stops, and lists them. Each child is owned by a background anchor task, so the parent's Esc and idle waits never reach it. Each message is delivered by a background reporter task that posts the answer back to the parent as a follow-up input once it arrives; request IDs keep a restart from sending a message or a report twice. +- [`21-subagent-foreground.ts`](test/examples/21-subagent-foreground.ts): the tool above, returning the child's answer. The UI finds the child through the call's `details` and prints the child's events indented under the call. +- [`22-subagent-background.ts`](test/examples/22-subagent-background.ts): persistent subagents behind one `subagent` tool that spawns, messages (steer or follow-up), waits for, stops, and lists them. Each child is owned by a background anchor task, so the parent's Esc and idle waits never reach it. Each message is delivered by a background reporter task that posts the answer back to the parent as a follow-up input once it arrives; request IDs keep a restart from sending a message or a report twice. ## Child Tasks @@ -698,7 +698,7 @@ decide: async (task, runtime, context) => { - **Finishing:** a task that finishes while work it owns is still running is `completing`: its outcome is decided, but it becomes terminal, and `waitForTask()` returns, only once that work is done. A failed or aborted outcome aborts that work first. - **Aborting:** abort runs bottom-up. Aborting a task aborts the work it owns first, and its own abort handler starts only once that work is done, so each task undoes its own effects. -[`24-child-tasks.ts`](test/examples/24-child-tasks.ts) runs a checkout with four payments: a declined card, a cancelled checkout, and a restart while the payments run. +[`23-child-tasks.ts`](test/examples/23-child-tasks.ts) runs a checkout with four payments: a declined card, a cancelled checkout, and a restart while the payments run. ## Task Graph @@ -714,7 +714,7 @@ graph.start(async (value) => { }); ``` -A task appears with the commit that creates it and leaves with the commit that makes it terminal. Statuses are the committed ones: `pending`, `running`, `waiting` (with `on` and `policy`), and `completing` (with the held outcome's status). After a restart, tasks that were `running` show as `pending` until they run again. Whether a pending task is blocked by a missing definition is not part of the graph; `harness.inspect()` reports that. The graph lists live tasks only: once a subagent's owner task is terminal, a later task in its conversation is a top-level node, and the conversation's `ConversationRecord.owner` (also in its view's `conversation`) links it to its parent. [`24-child-tasks.ts`](test/examples/24-child-tasks.ts) prints the checkout's tree while its payments run. +A task appears with the commit that creates it and leaves with the commit that makes it terminal. Statuses are the committed ones: `pending`, `running`, `waiting` (with `on` and `policy`), and `completing` (with the held outcome's status). After a restart, tasks that were `running` show as `pending` until they run again. Whether a pending task is blocked by a missing definition is not part of the graph; `harness.inspect()` reports that. The graph lists live tasks only: once a subagent's owner task is terminal, a later task in its conversation is a top-level node, and the conversation's `ConversationRecord.owner` (also in its view's `conversation`) links it to its parent. [`23-child-tasks.ts`](test/examples/23-child-tasks.ts) prints the checkout's tree while its payments run. ## Your Own State @@ -739,7 +739,7 @@ await root.commit(async (tx) => { console.log(await harness.snapshot(Todos, root.id, context)); ``` -`harness.watchDoc()` watches one document like the view above. `HarnessOptions.conversationCreated(tx, conversation)` runs in every commit that creates or forks a conversation, including a tool's raw `tx.createConversation()`, so every conversation gets your documents; `init` in `createConversation()`, `fork()`, and `root()` writes per-call data in the same commit. An extension's tools, sections, and hooks read their own documents through `api` or `input.read`, and treat an absent one as its default ([`11-extension-state.ts`](test/examples/11-extension-state.ts)). +`harness.watchDoc()` watches one document like the view above. `HarnessOptions.conversationCreated(tx, conversation)` runs in every commit that creates or forks a conversation, including a tool's raw `tx.createConversation()`, so every conversation gets your documents; `init` in `createConversation()`, `fork()`, and `root()` writes per-call data in the same commit. An extension's tools, sections, and hooks read their own documents through `api` or `input.read`, and treat an absent one as its default ([`10-extension-state.ts`](test/examples/10-extension-state.ts)). ## Usage and Cost @@ -832,29 +832,29 @@ A failure closes the Harness by itself; `closed` settles once that close is done Runnable examples live in [`test/examples`](test/examples). Run one from this package directory with: ```bash -node --conditions=source --experimental-strip-types test/examples/14-chat.ts +node --conditions=source --experimental-strip-types test/examples/13-chat.ts ``` | Example | Shows | |---|---| -| [14-chat](test/examples/14-chat.ts) | One question and answer | -| [16-real-model](test/examples/16-real-model.ts) | Streaming an answer from OpenAI | -| [17-coding-tools](test/examples/17-coding-tools.ts) | A tool-using turn on JSONL storage | -| [18-print](test/examples/18-print.ts) | Print mode: submit a prompt, print the answer | -| [19-json](test/examples/19-json.ts) | JSON mode: agent events or raw view operations, on SQLite, JSONL, or memory | -| [20-inbox](test/examples/20-inbox.ts) | Steers, follow-ups, writes, and withdrawal while busy | -| [21-late-join](test/examples/21-late-join.ts) | Attaching a view and an event stream mid-run | -| [22-subagent-foreground](test/examples/22-subagent-foreground.ts) | A replay-safe subagent tool whose child the call owns, with the child's events under the call | -| [23-subagent-background](test/examples/23-subagent-background.ts) | Persistent subagents: spawn, steer, stop, list, answers reported back, restart-safe | -| [24-child-tasks](test/examples/24-child-tasks.ts) | A checkout that owns and waits for four payments: failFast, abort, restart | -| [25-compaction](test/examples/25-compaction.ts) | A long chat compacted in the background, manually, and after a context overflow | -| [26-coding-agent](test/examples/26-coding-agent.ts) | CodingTools, live settings from a settings object, an environment that follows the conversation's directory | -| [27-plan-mode](test/examples/27-plan-mode.ts) | A read-only plan mode as an extension with its own document, switched with `configure()` | -| [28-reviewer](test/examples/28-reviewer.ts) | A reviewer conversation with its own model, extensions, tools, directory, and review loop | -| [29-sandbox-per-conversation](test/examples/29-sandbox-per-conversation.ts) | An environment per conversation, looked up from an app document | -| [30-tool-override](test/examples/30-tool-override.ts) | A same-name bash for some conversations, and a wrapper that times whichever bash won | -| [31-reload-and-restart](test/examples/31-reload-and-restart.ts) | Reloading an extension mid-call, and stored choices surviving a restart | -| [00](test/examples/00-conversation.ts)–[13](test/examples/13-recovery.ts) | The layers underneath: sessions, documents, forks, watches, the Harness, agent configuration, reload, extension state, tasks, recovery | +| [13-chat](test/examples/13-chat.ts) | One question and answer | +| [15-real-model](test/examples/15-real-model.ts) | Streaming an answer from OpenAI | +| [16-coding-tools](test/examples/16-coding-tools.ts) | A tool-using turn on JSONL storage | +| [17-print](test/examples/17-print.ts) | Print mode: submit a prompt, print the answer | +| [18-json](test/examples/18-json.ts) | JSON mode: agent events or raw view operations, on SQLite, JSONL, or memory | +| [19-inbox](test/examples/19-inbox.ts) | Steers, follow-ups, writes, and withdrawal while busy | +| [20-late-join](test/examples/20-late-join.ts) | Attaching a view and an event stream mid-run | +| [21-subagent-foreground](test/examples/21-subagent-foreground.ts) | A replay-safe subagent tool whose child the call owns, with the child's events under the call | +| [22-subagent-background](test/examples/22-subagent-background.ts) | Persistent subagents: spawn, steer, stop, list, answers reported back, restart-safe | +| [23-child-tasks](test/examples/23-child-tasks.ts) | A checkout that owns and waits for four payments: failFast, abort, restart | +| [24-compaction](test/examples/24-compaction.ts) | A long chat compacted in the background, manually, and after a context overflow | +| [25-coding-agent](test/examples/25-coding-agent.ts) | CodingTools, live settings from a settings object, an environment that follows the conversation's directory | +| [26-plan-mode](test/examples/26-plan-mode.ts) | A read-only plan mode as an extension with its own document, switched with `configure()` | +| [27-reviewer](test/examples/27-reviewer.ts) | A reviewer conversation with its own model, extensions, tools, directory, and review loop | +| [28-sandbox-per-conversation](test/examples/28-sandbox-per-conversation.ts) | An environment per conversation, looked up from an app document | +| [29-tool-override](test/examples/29-tool-override.ts) | A same-name bash for some conversations, and a wrapper that times whichever bash won | +| [30-reload-and-restart](test/examples/30-reload-and-restart.ts) | Reloading an extension mid-call, and stored choices surviving a restart | +| [00](test/examples/00-conversation.ts)–[12](test/examples/12-recovery.ts) | The layers underneath: sessions, documents, forks, watches, the Harness, agent configuration, reload, extension state, tasks, recovery | Examples that call OpenAI need `OPENAI_API_KEY`; most use the faux provider otherwise. diff --git a/packages/durable/test/examples/03-owned-conversations.ts b/packages/durable/test/examples/03-owned-conversations.ts index a6086a4cc..c70095b25 100644 --- a/packages/durable/test/examples/03-owned-conversations.ts +++ b/packages/durable/test/examples/03-owned-conversations.ts @@ -13,7 +13,7 @@ const session = createSession(new MemoryStorage()); // all of them exist or none do. // A task definition needs a name, a version, the task's starting state, a -// handler for every phase, and an abort handler (12-tasks.ts runs a task). +// handler for every phase, and an abort handler (11-tasks.ts runs a task). // This example only creates the task record; a plain Session never runs it. const Supervisor = defineTask({ name: "example.supervisor", diff --git a/packages/durable/test/examples/05-watches.ts b/packages/durable/test/examples/04-watches.ts similarity index 98% rename from packages/durable/test/examples/05-watches.ts rename to packages/durable/test/examples/04-watches.ts index 9dba1c965..4ed33f1af 100644 --- a/packages/durable/test/examples/05-watches.ts +++ b/packages/durable/test/examples/04-watches.ts @@ -1,6 +1,6 @@ // Serialize asynchronous document work with a watch. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/05-watches.ts +// node --conditions=source --experimental-strip-types test/examples/04-watches.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { createSession, defineDoc, MemoryStorage } from "../../src/index.ts"; diff --git a/packages/durable/test/examples/06-harness.ts b/packages/durable/test/examples/05-harness.ts similarity index 99% rename from packages/durable/test/examples/06-harness.ts rename to packages/durable/test/examples/05-harness.ts index e4ebdeb6d..ad33c0d0c 100644 --- a/packages/durable/test/examples/06-harness.ts +++ b/packages/durable/test/examples/05-harness.ts @@ -1,6 +1,6 @@ // Open a Harness with a registry of extensions. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/06-harness.ts +// node --conditions=source --experimental-strip-types test/examples/05-harness.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { Type } from "@earendil-works/pi-ai"; import { createModels } from "@earendil-works/pi-ai/models"; diff --git a/packages/durable/test/examples/07-configuration.ts b/packages/durable/test/examples/06-configuration.ts similarity index 99% rename from packages/durable/test/examples/07-configuration.ts rename to packages/durable/test/examples/06-configuration.ts index 1d6a7c019..03184b6d8 100644 --- a/packages/durable/test/examples/07-configuration.ts +++ b/packages/durable/test/examples/06-configuration.ts @@ -1,6 +1,6 @@ // Per-conversation agent choices and Harness-wide settings. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/07-configuration.ts +// node --conditions=source --experimental-strip-types test/examples/06-configuration.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { Type } from "@earendil-works/pi-ai"; import { createModels } from "@earendil-works/pi-ai/models"; diff --git a/packages/durable/test/examples/08-harness-conversations.ts b/packages/durable/test/examples/07-harness-conversations.ts similarity index 98% rename from packages/durable/test/examples/08-harness-conversations.ts rename to packages/durable/test/examples/07-harness-conversations.ts index da07f3055..4ade44b67 100644 --- a/packages/durable/test/examples/08-harness-conversations.ts +++ b/packages/durable/test/examples/07-harness-conversations.ts @@ -1,6 +1,6 @@ // Conversation handles, typed entries, created conversations, and forks. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/08-harness-conversations.ts +// node --conditions=source --experimental-strip-types test/examples/07-harness-conversations.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { createModels } from "@earendil-works/pi-ai/models"; import { createRegistry, defineEntry, Harness, MemoryStorage } from "../../src/index.ts"; diff --git a/packages/durable/test/examples/09-context.ts b/packages/durable/test/examples/08-context.ts similarity index 99% rename from packages/durable/test/examples/09-context.ts rename to packages/durable/test/examples/08-context.ts index 8082b8c1e..98c1642b7 100644 --- a/packages/durable/test/examples/09-context.ts +++ b/packages/durable/test/examples/08-context.ts @@ -1,6 +1,6 @@ // Transcript history and model context. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/09-context.ts +// node --conditions=source --experimental-strip-types test/examples/08-context.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import type { AssistantMessage, Message, StopReason, ToolResultMessage } from "@earendil-works/pi-ai"; import { createModels } from "@earendil-works/pi-ai/models"; diff --git a/packages/durable/test/examples/10-registry-reload.ts b/packages/durable/test/examples/09-registry-reload.ts similarity index 98% rename from packages/durable/test/examples/10-registry-reload.ts rename to packages/durable/test/examples/09-registry-reload.ts index 97c668be0..4135650ee 100644 --- a/packages/durable/test/examples/10-registry-reload.ts +++ b/packages/durable/test/examples/09-registry-reload.ts @@ -1,6 +1,6 @@ // Reload extension code through the registry. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/10-registry-reload.ts +// node --conditions=source --experimental-strip-types test/examples/09-registry-reload.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { Type } from "@earendil-works/pi-ai"; import { createModels } from "@earendil-works/pi-ai/models"; diff --git a/packages/durable/test/examples/11-extension-state.ts b/packages/durable/test/examples/10-extension-state.ts similarity index 99% rename from packages/durable/test/examples/11-extension-state.ts rename to packages/durable/test/examples/10-extension-state.ts index 8bd2e75da..f2d518214 100644 --- a/packages/durable/test/examples/11-extension-state.ts +++ b/packages/durable/test/examples/10-extension-state.ts @@ -1,6 +1,6 @@ // An extension that keeps its own per-conversation document. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/11-extension-state.ts +// node --conditions=source --experimental-strip-types test/examples/10-extension-state.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { Type } from "@earendil-works/pi-ai"; import { createModels } from "@earendil-works/pi-ai/models"; diff --git a/packages/durable/test/examples/12-tasks.ts b/packages/durable/test/examples/11-tasks.ts similarity index 97% rename from packages/durable/test/examples/12-tasks.ts rename to packages/durable/test/examples/11-tasks.ts index 26865ca97..bb55b554f 100644 --- a/packages/durable/test/examples/12-tasks.ts +++ b/packages/durable/test/examples/11-tasks.ts @@ -1,6 +1,6 @@ // Run a durable task. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/12-tasks.ts +// node --conditions=source --experimental-strip-types test/examples/11-tasks.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { createModels } from "@earendil-works/pi-ai/models"; import { createRegistry, defineExtension, defineTask, Harness, MemoryStorage } from "../../src/index.ts"; @@ -52,7 +52,7 @@ registry.install(defineExtension({ name: "payments", tasks: [Payment] })); const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry }, context); const root = await harness.root(context); // Every task names its owner. This one belongs to the conversation; a task can -// also own child tasks and wait for them (24-child-tasks.ts). +// also own child tasks and wait for them (23-child-tasks.ts). const paymentId = await root.commit( (tx) => tx.createTask(Payment, { amount: 5 }, { ownership: { kind: "conversation" } }), context, diff --git a/packages/durable/test/examples/13-recovery.ts b/packages/durable/test/examples/12-recovery.ts similarity index 99% rename from packages/durable/test/examples/13-recovery.ts rename to packages/durable/test/examples/12-recovery.ts index 2b8c23ca9..42063e173 100644 --- a/packages/durable/test/examples/13-recovery.ts +++ b/packages/durable/test/examples/12-recovery.ts @@ -1,6 +1,6 @@ // Close, reopen, and continue where a task stopped. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/13-recovery.ts +// node --conditions=source --experimental-strip-types test/examples/12-recovery.ts import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/durable/test/examples/14-chat.ts b/packages/durable/test/examples/13-chat.ts similarity index 95% rename from packages/durable/test/examples/14-chat.ts rename to packages/durable/test/examples/13-chat.ts index 5abc706f0..5a6d1a3b3 100644 --- a/packages/durable/test/examples/14-chat.ts +++ b/packages/durable/test/examples/13-chat.ts @@ -1,6 +1,6 @@ // A chat turn. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/14-chat.ts +// node --conditions=source --experimental-strip-types test/examples/13-chat.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { createModels } from "@earendil-works/pi-ai/models"; import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux"; @@ -8,7 +8,7 @@ import { AssistantEntry, createRegistry, defineExtension, Harness, MemoryStorage const context = BACKGROUND_CONTEXT; -// The faux provider stands in for a real model (see 16-real-model.ts). +// The faux provider stands in for a real model (see 15-real-model.ts). const faux = fauxProvider(); const models = createModels(); models.setProvider(faux.provider); diff --git a/packages/durable/test/examples/15-system-prompt.ts b/packages/durable/test/examples/14-system-prompt.ts similarity index 99% rename from packages/durable/test/examples/15-system-prompt.ts rename to packages/durable/test/examples/14-system-prompt.ts index 2202871c9..c83083de3 100644 --- a/packages/durable/test/examples/15-system-prompt.ts +++ b/packages/durable/test/examples/14-system-prompt.ts @@ -1,6 +1,6 @@ // System prompt sections and per-conversation instructions. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/15-system-prompt.ts +// node --conditions=source --experimental-strip-types test/examples/14-system-prompt.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { createModels } from "@earendil-works/pi-ai/models"; import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux"; diff --git a/packages/durable/test/examples/16-real-model.ts b/packages/durable/test/examples/15-real-model.ts similarity index 99% rename from packages/durable/test/examples/16-real-model.ts rename to packages/durable/test/examples/15-real-model.ts index 00ce62856..b5cbe1244 100644 --- a/packages/durable/test/examples/16-real-model.ts +++ b/packages/durable/test/examples/15-real-model.ts @@ -1,6 +1,6 @@ // A real model: stream an answer from OpenAI. // Run from packages/durable (needs OPENAI_API_KEY): -// node --conditions=source --experimental-strip-types test/examples/16-real-model.ts +// node --conditions=source --experimental-strip-types test/examples/15-real-model.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { createModels } from "@earendil-works/pi-ai/models"; import { openaiProvider } from "@earendil-works/pi-ai/providers/openai"; diff --git a/packages/durable/test/examples/17-coding-tools.ts b/packages/durable/test/examples/16-coding-tools.ts similarity index 99% rename from packages/durable/test/examples/17-coding-tools.ts rename to packages/durable/test/examples/16-coding-tools.ts index 033313ff8..b150b584b 100644 --- a/packages/durable/test/examples/17-coding-tools.ts +++ b/packages/durable/test/examples/16-coding-tools.ts @@ -2,7 +2,7 @@ // `cat` of /tmp/1gb.txt (create it first to measure; without it that call ends in an error result). The storage // directory is left behind for inspection. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/17-coding-tools.ts +// node --conditions=source --experimental-strip-types test/examples/16-coding-tools.ts import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/durable/test/examples/18-print.ts b/packages/durable/test/examples/17-print.ts similarity index 98% rename from packages/durable/test/examples/18-print.ts rename to packages/durable/test/examples/17-print.ts index 2438efbb8..5dfd8f46e 100644 --- a/packages/durable/test/examples/18-print.ts +++ b/packages/durable/test/examples/17-print.ts @@ -1,7 +1,7 @@ // Print mode: submit one prompt and print its answer, like `pi -p`. The host awaits its own Submission, not global // idle. Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/18-print.ts "What is in this directory?" +// node --conditions=source --experimental-strip-types test/examples/17-print.ts "What is in this directory?" import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import type { AssistantMessage } from "@earendil-works/pi-ai"; import { createModels } from "@earendil-works/pi-ai/models"; diff --git a/packages/durable/test/examples/19-json.ts b/packages/durable/test/examples/18-json.ts similarity index 98% rename from packages/durable/test/examples/19-json.ts rename to packages/durable/test/examples/18-json.ts index 95a894f1d..f1348cfe1 100644 --- a/packages/durable/test/examples/19-json.ts +++ b/packages/durable/test/examples/18-json.ts @@ -5,7 +5,7 @@ // neither is deleted, and the path is printed to stderr at the end. // Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/19-json.ts --events "What is in this directory?" +// node --conditions=source --experimental-strip-types test/examples/18-json.ts --events "What is in this directory?" import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/durable/test/examples/20-inbox.ts b/packages/durable/test/examples/19-inbox.ts similarity index 99% rename from packages/durable/test/examples/20-inbox.ts rename to packages/durable/test/examples/19-inbox.ts index 90beb1ae3..b90560267 100644 --- a/packages/durable/test/examples/20-inbox.ts +++ b/packages/durable/test/examples/19-inbox.ts @@ -1,6 +1,6 @@ // The inbox: what happens to submissions while a conversation is busy. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/20-inbox.ts +// node --conditions=source --experimental-strip-types test/examples/19-inbox.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import type { FauxResponseStep } from "@earendil-works/pi-ai"; import { createModels } from "@earendil-works/pi-ai/models"; diff --git a/packages/durable/test/examples/21-late-join.ts b/packages/durable/test/examples/20-late-join.ts similarity index 99% rename from packages/durable/test/examples/21-late-join.ts rename to packages/durable/test/examples/20-late-join.ts index 76d99530b..122ad70ee 100644 --- a/packages/durable/test/examples/21-late-join.ts +++ b/packages/durable/test/examples/20-late-join.ts @@ -1,7 +1,7 @@ // Late join: a client attaches while a run is already underway. It gets the current state first, the conversation // view or the snapshot event, and then only what changes after that. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/21-late-join.ts +// node --conditions=source --experimental-strip-types test/examples/20-late-join.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { Type } from "@earendil-works/pi-ai"; import { createModels } from "@earendil-works/pi-ai/models"; diff --git a/packages/durable/test/examples/22-subagent-foreground.ts b/packages/durable/test/examples/21-subagent-foreground.ts similarity index 99% rename from packages/durable/test/examples/22-subagent-foreground.ts rename to packages/durable/test/examples/21-subagent-foreground.ts index 69d7ec5c4..c766803fb 100644 --- a/packages/durable/test/examples/22-subagent-foreground.ts +++ b/packages/durable/test/examples/21-subagent-foreground.ts @@ -3,7 +3,7 @@ // details and shows its events under the call. // Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/22-subagent-foreground.ts +// node --conditions=source --experimental-strip-types test/examples/21-subagent-foreground.ts import type { Context } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { type AssistantMessage, type FauxResponseStep, Type } from "@earendil-works/pi-ai"; diff --git a/packages/durable/test/examples/23-subagent-background.ts b/packages/durable/test/examples/22-subagent-background.ts similarity index 99% rename from packages/durable/test/examples/23-subagent-background.ts rename to packages/durable/test/examples/22-subagent-background.ts index 6b2b4a533..19fba5dc5 100644 --- a/packages/durable/test/examples/23-subagent-background.ts +++ b/packages/durable/test/examples/22-subagent-background.ts @@ -4,7 +4,7 @@ // survives a restart: the example closes the Harness while a subagent works and reopens it. // Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/23-subagent-background.ts +// node --conditions=source --experimental-strip-types test/examples/22-subagent-background.ts import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/durable/test/examples/24-child-tasks.ts b/packages/durable/test/examples/23-child-tasks.ts similarity index 99% rename from packages/durable/test/examples/24-child-tasks.ts rename to packages/durable/test/examples/23-child-tasks.ts index 63d67759f..2fd1154a4 100644 --- a/packages/durable/test/examples/24-child-tasks.ts +++ b/packages/durable/test/examples/23-child-tasks.ts @@ -1,7 +1,7 @@ // A task that owns child tasks: a checkout charges four payments at once and waits for them. // The task graph view prints the checkout's tree while its payments run. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/24-child-tasks.ts +// node --conditions=source --experimental-strip-types test/examples/23-child-tasks.ts import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/durable/test/examples/25-compaction.ts b/packages/durable/test/examples/24-compaction.ts similarity index 99% rename from packages/durable/test/examples/25-compaction.ts rename to packages/durable/test/examples/24-compaction.ts index 5fb353ce1..ac593bf22 100644 --- a/packages/durable/test/examples/25-compaction.ts +++ b/packages/durable/test/examples/24-compaction.ts @@ -1,6 +1,6 @@ // Compaction: a long trip-planning chat whose older messages are summarized so the model context stays small. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/25-compaction.ts +// node --conditions=source --experimental-strip-types test/examples/24-compaction.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import type { AssistantMessage, Message, TranscriptContext } from "@earendil-works/pi-ai"; import { createModels } from "@earendil-works/pi-ai/models"; diff --git a/packages/durable/test/examples/26-coding-agent.ts b/packages/durable/test/examples/25-coding-agent.ts similarity index 99% rename from packages/durable/test/examples/26-coding-agent.ts rename to packages/durable/test/examples/25-coding-agent.ts index d5b0e7c47..a25f5d617 100644 --- a/packages/durable/test/examples/26-coding-agent.ts +++ b/packages/durable/test/examples/25-coding-agent.ts @@ -1,7 +1,7 @@ // A small coding agent: the built-in coding tools, a prompt that knows the working directory, settings backed by the // app's settings file, and an environment that follows the conversation's directory. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/26-coding-agent.ts +// node --conditions=source --experimental-strip-types test/examples/25-coding-agent.ts import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/durable/test/examples/27-plan-mode.ts b/packages/durable/test/examples/26-plan-mode.ts similarity index 99% rename from packages/durable/test/examples/27-plan-mode.ts rename to packages/durable/test/examples/26-plan-mode.ts index 9736140f0..b0bedd056 100644 --- a/packages/durable/test/examples/27-plan-mode.ts +++ b/packages/durable/test/examples/26-plan-mode.ts @@ -1,7 +1,7 @@ // Plan mode: the user switches a conversation into a read-only planning mode, the agent writes a plan into the // extension's own document, and switching back restores the full tool set. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/27-plan-mode.ts +// node --conditions=source --experimental-strip-types test/examples/26-plan-mode.ts import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/durable/test/examples/28-reviewer.ts b/packages/durable/test/examples/27-reviewer.ts similarity index 99% rename from packages/durable/test/examples/28-reviewer.ts rename to packages/durable/test/examples/27-reviewer.ts index f219aa9e0..6dbdf25b3 100644 --- a/packages/durable/test/examples/28-reviewer.ts +++ b/packages/durable/test/examples/27-reviewer.ts @@ -1,7 +1,7 @@ // A reviewer agent next to the main one: a cheaper model, a review role and loop, read-only tools, and its own // checkout of the project. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/28-reviewer.ts +// node --conditions=source --experimental-strip-types test/examples/27-reviewer.ts import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/durable/test/examples/29-sandbox-per-conversation.ts b/packages/durable/test/examples/28-sandbox-per-conversation.ts similarity index 98% rename from packages/durable/test/examples/29-sandbox-per-conversation.ts rename to packages/durable/test/examples/28-sandbox-per-conversation.ts index d570b5721..dbc7879f5 100644 --- a/packages/durable/test/examples/29-sandbox-per-conversation.ts +++ b/packages/durable/test/examples/28-sandbox-per-conversation.ts @@ -2,7 +2,7 @@ // environment function looks it up for every tool call. Here a sandbox is a directory; a hosted product would return // an ExecutionEnv that runs inside the conversation's container. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/29-sandbox-per-conversation.ts +// node --conditions=source --experimental-strip-types test/examples/28-sandbox-per-conversation.ts import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/durable/test/examples/30-tool-override.ts b/packages/durable/test/examples/29-tool-override.ts similarity index 99% rename from packages/durable/test/examples/30-tool-override.ts rename to packages/durable/test/examples/29-tool-override.ts index bafdcaa3a..b33a94c89 100644 --- a/packages/durable/test/examples/30-tool-override.ts +++ b/packages/durable/test/examples/29-tool-override.ts @@ -1,7 +1,7 @@ // Replace a tool for some conversations, and decorate whichever tool wins: a bash that runs inside a Python // virtualenv, and a wrapper that times every bash call. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/30-tool-override.ts +// node --conditions=source --experimental-strip-types test/examples/29-tool-override.ts import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/durable/test/examples/31-reload-and-restart.ts b/packages/durable/test/examples/30-reload-and-restart.ts similarity index 99% rename from packages/durable/test/examples/31-reload-and-restart.ts rename to packages/durable/test/examples/30-reload-and-restart.ts index 3da03b0b2..6813a7659 100644 --- a/packages/durable/test/examples/31-reload-and-restart.ts +++ b/packages/durable/test/examples/30-reload-and-restart.ts @@ -1,7 +1,7 @@ // Reload an extension while a call runs, then restart the process. Running work finishes on the code it started // with; stored choices are names, so they survive a restart and bind to whatever code the new process installs. // Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/31-reload-and-restart.ts +// node --conditions=source --experimental-strip-types test/examples/30-reload-and-restart.ts import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; From ac80dd88f349d7a20964a45bacbbea08b7afdbc8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Oct 2026 11:16:38 +0000 Subject: [PATCH 17/30] chore: approve contributors from issue #11088 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index c758d395b..23ba87df7 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -405,3 +405,5 @@ btkostner pr Jay-Madden issue lalyos pr + +Lincoln504 pr From 476a303c1250ce002215bfd08bdab5f6886f0573 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Thu, 22 Oct 2026 13:50:26 +0200 Subject: [PATCH 18/30] fix(coding-agent): drop min-release-age override from managed npm ci (#11270) Updates of pi.dev installer installs run npm ci against a release lockfile that pins every package, so npm never resolves versions and a configured min-release-age does not apply. The --min-release-age=0 flag had no effect, but security scanners that block it stopped the update. Global npm, pnpm and bun self-updates keep their age-gate overrides. closes #11262 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/src/package-manager-cli.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 564f8b0b9..1870f885f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- `pi update` for installs from the pi.dev installer no longer passes `--min-release-age=0` to `npm ci`. The release lockfile pins every package, so the flag had no effect, and security scanners that block it stopped the update ([#11262](https://github.com/earendil-works/pi/issues/11262)) + ## [1.4.0] - 2026-10-21 ### New Features diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 48d605620..2eda49536 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -88,10 +88,11 @@ async function fetchInstallerArtifact(url: string, label: string): Promise { + // No age-gate override: the release lockfile pins every package, so npm never + // resolves versions and a configured min-release-age does not apply. const args = [ "ci", "--ignore-scripts", - "--min-release-age=0", "--omit=dev", "--include=optional", "--no-fund", From be14d9732f98fcc33929cf103a92b96afaa6523e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 22 Oct 2026 14:24:58 +0200 Subject: [PATCH 19/30] feat(durable): open a Harness read-only HarnessOptions.readOnly opens a viewer of a session another process drives, such as a synced copy on a device that only shows it. Open leaves running tasks as committed, commits with writes reject with ReadOnlySession before they reach Storage, and resume() and every call that would run tasks reject with it. --- packages/durable/CHANGELOG.md | 4 + packages/durable/README.md | 2 +- packages/durable/docs/spec.md | 9 ++ packages/durable/src/errors.ts | 11 ++ packages/durable/src/harness/harness.ts | 13 +- packages/durable/src/harness/scheduler.ts | 22 ++- packages/durable/src/harness/types.ts | 15 +- packages/durable/src/index.ts | 2 +- packages/durable/src/session/session.ts | 12 +- .../durable/test/harness-read-only.test.ts | 144 ++++++++++++++++++ 10 files changed, 218 insertions(+), 16 deletions(-) create mode 100644 packages/durable/test/harness-read-only.test.ts diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 0f8d53b5d..96be36d6c 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -9,6 +9,10 @@ - Watches use Chord's `WatchHandle` and `WatchEnd` (Durable re-exports both). End reasons are `stopped`, `cancelled`, `retired`, `ended` (was `session_closed`), `listener_error`, and `failed` (was `session_failed`). Retirement is an explicit event of the watched document incarnation (last frame `[["r", null]]`, then `retired`), never inferred from a `null` value. An ended watch drops its listener and every reference into its Session, so a stopped watch that is still referenced keeps only its last value. - Removed the Chord state getters `Session.documentState()`, `Conversation.viewState()`, and `Harness.taskGraph()`, and the `DocumentState` type; use `watchDoc()`, `Conversation.watch()`, and `Harness.watchTaskGraph()`. Durable no longer uses Chord's `replicatedState()`. +### Added + +- `HarnessOptions.readOnly` opens a Harness as a viewer of a session another process drives: open leaves running tasks as committed instead of recovering them, every commit with writes rejects with `ReadOnlySession` before it reaches Storage, and `resume()` and every call that would run tasks reject with it. + ## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/durable/README.md b/packages/durable/README.md index ca9826575..8882f9a3a 100644 --- a/packages/durable/README.md +++ b/packages/durable/README.md @@ -124,7 +124,7 @@ const root = await harness.root(context); // the same root as last time harness.resume(); // continue any run the last process left unfinished ``` -Work interrupted by a crash or close stays pending. `resume()` starts the task scheduler; submitting or waiting starts it too. Each conversation has its own persisted UUIDv7 in `pi.provider`, forwarded to pi-ai as `sessionId` for provider prompt-cache and session affinity. It survives reopen, retries, reset, compaction, and model changes; a child or fork receives a fresh identity. A legacy conversation receives and persists one before its first generation or compaction request. +Work interrupted by a crash or close stays pending. `resume()` starts the task scheduler; submitting or waiting starts it too. A process that only shows a session another one drives opens it with `readOnly: true`: open recovers nothing, and every write, `resume()`, and every call that would run tasks rejects with `ReadOnlySession`. Each conversation has its own persisted UUIDv7 in `pi.provider`, forwarded to pi-ai as `sessionId` for provider prompt-cache and session affinity. It survives reopen, retries, reset, compaction, and model changes; a child or fork receives a fresh identity. A legacy conversation receives and persists one before its first generation or compaction request. A retried submission with the same `requestId` returns the existing submission instead of submitting twice: diff --git a/packages/durable/docs/spec.md b/packages/durable/docs/spec.md index 99ebf614e..ae40f01b1 100644 --- a/packages/durable/docs/spec.md +++ b/packages/durable/docs/spec.md @@ -753,6 +753,15 @@ with them. A viewer that only reads never enables scheduling: `inspect()`, `getTask()`, `submission()`, `Submission.status()`, `usage()`, document reads, every conversation read, state, and watch, and the task graph. +`HarnessOptions.readOnly` opens a viewer of a session another process drives, +such as a synced copy on a device that only shows it. Open loads live tasks as +committed, `running` ones included, and reconciles nothing. Every commit with +writes rejects with `ReadOnlySession` before it reaches Storage, so the Session +stays healthy; a commit that writes nothing succeeds. `resume()` throws +`ReadOnlySession`, and every call that enables scheduling rejects with it. +`wake()` is always inactive and `onWake` is never called. Reopening without +`readOnly` recovers as usual: the viewer counted no restart. + `createConversation({ ownership, agent, init })` and `fork(at, { ownership, agent, init })` commit atomically: the conversation, its explicitly selected ownership, the creation hook's documents, the `agent` diff --git a/packages/durable/src/errors.ts b/packages/durable/src/errors.ts index c66a1cee2..e2a36dfc1 100644 --- a/packages/durable/src/errors.ts +++ b/packages/durable/src/errors.ts @@ -45,6 +45,17 @@ export class SessionFailed extends Error { } } +/** + * The Harness was opened read-only (`HarnessOptions.readOnly`): a commit with writes, or a call that would run tasks, was + * refused. Nothing was written, and the Harness stays usable for reads. + */ +export class ReadOnlySession extends Error { + constructor(what: string) { + super(`Read-only Harness: ${what} refused`); + this.name = "ReadOnlySession"; + } +} + /** A submission reached a busy conversation and was not admitted. */ export class ConversationBusy extends Error { readonly conversationId: ConversationId; diff --git a/packages/durable/src/harness/harness.ts b/packages/durable/src/harness/harness.ts index 7b315be5f..d22f368cc 100644 --- a/packages/durable/src/harness/harness.ts +++ b/packages/durable/src/harness/harness.ts @@ -123,7 +123,7 @@ class ConversationImpl implements Conversation { return this.#host.submissions.submit(this.id, submission, context); } - compact(instructions: string | undefined, context: Context): Promise> { + async compact(instructions: string | undefined, context: Context): Promise> { this.#host.tasks.resume(); const input = { reason: "manual", ...(instructions === undefined ? {} : { instructions }) } as const; return this.#host.harness.commitWith((tx) => createCompaction(tx, this.id, input), context); @@ -165,12 +165,12 @@ class ConversationImpl implements Conversation { return this.#host.create({ kind: "fork", parentId: this.id, at, ownership: options.ownership }, options, context); } - abort(context: Context, options?: ConversationAbortOptions): Promise { + async abort(context: Context, options?: ConversationAbortOptions): Promise { this.#host.tasks.resume(); return this.#host.tasks.abortConversation(this.id, options?.background === true, context); } - waitForIdle(context: Context): Promise { + async waitForIdle(context: Context): Promise { this.#host.tasks.resume(); return this.#host.tasks.waitForIdle(this.id, context); } @@ -214,7 +214,7 @@ class HarnessImpl extends SessionImpl implements const now = safeNow(options.now, report); const hostSettings = currentSettings(options.settings); // Read at each use, as all settings are, but only this field: it is read on every commit. - super(rawStorage, now, () => hostSettings()?.documentRetentionMs, options.setTimer); + super(rawStorage, now, () => hostSettings()?.documentRetentionMs, options.setTimer, options.readOnly === true); // Every component reads and writes through the Session's failure guard: `rawStorage` serves only `super()`. const storage = this.storage; this.#options = options; @@ -242,6 +242,7 @@ class HarnessImpl extends SessionImpl implements return record === undefined ? undefined : boundConversation(id, binding, this.#submissions, this.#tasks); }, context: withoutAbortSignal(context), + readOnly: options.readOnly === true, }); this.#submissions = new Submissions(this, storage, now, settings, () => this.#tasks.resume(), options.telemetry); this.#taskGraph = new TaskGraphView(this, storage, { @@ -337,12 +338,12 @@ class HarnessImpl extends SessionImpl implements return this.#tasks.retry(id, context); } - waitForTask(id: TaskId, context: Context): Promise> { + async waitForTask(id: TaskId, context: Context): Promise> { this.#tasks.resume(); return this.#tasks.waitForTask(id, context) as Promise>; } - waitForIdle(context: Context): Promise { + async waitForIdle(context: Context): Promise { this.#tasks.resume(); return this.#tasks.waitForIdle(undefined, context); } diff --git a/packages/durable/src/harness/scheduler.ts b/packages/durable/src/harness/scheduler.ts index 23951e899..0ce1b3de3 100644 --- a/packages/durable/src/harness/scheduler.ts +++ b/packages/durable/src/harness/scheduler.ts @@ -9,7 +9,7 @@ import { type TelemetryCarrier, } from "@earendil-works/pi-telemetry"; import type { ExecutionEnv } from "../env/index.ts"; -import { errorMessage } from "../errors.ts"; +import { errorMessage, ReadOnlySession } from "../errors.ts"; import type { SessionImpl } from "../session/session.ts"; import type { Transaction } from "../session/transaction.ts"; import type { Timers } from "../timers.ts"; @@ -226,6 +226,11 @@ export type TaskSchedulerOptions = { ) => Promise; /** Context for scheduler commits and invocations; carries no caller cancellation. */ readonly context: Context; + /** + * `HarnessOptions.readOnly`: open loads live tasks as committed, `running` ones included, and the scheduler never + * commits, runs anything, or asks for a wakeup. + */ + readonly readOnly?: boolean; }; /** @@ -340,6 +345,7 @@ export class TaskScheduler { #cascadePending = false; #unsubscribeRegistry: () => void = () => {}; #enabled = false; + readonly #readOnly: boolean; #closing = false; #dirty = false; #draining = false; @@ -365,11 +371,13 @@ export class TaskScheduler { this.#withdrawInputs = options.withdrawInputs; this.#conversation = options.conversation; this.#context = options.context; + this.#readOnly = options.readOnly === true; } /** * Load live tasks and change surviving `running` tasks back to `pending`, counting the restart. Dispatches nothing. A - * task that reaches the restart limit this way is parked and reported. + * task that reaches the restart limit this way is parked and reported. Read-only, it only loads them: a `running` + * task stays as committed, running in another process. */ async open(context: Context): Promise { this.#session.observeCommits((publication) => this.#observe(publication)); @@ -390,6 +398,7 @@ export class TaskScheduler { for (const record of records) { this.#track(undefined, record); this.#resumed.add(record.id); + if (this.#readOnly) continue; if (record.abandonOnRestart === true) this.#abandoned.add(record.id); if (record.state.status === "running") { const restarts = (record.restarts ?? 0) + 1; @@ -410,6 +419,7 @@ export class TaskScheduler { ); this.#report(error, { type: "task_parked", ...this.#aboutTask(recordTask(record)) }); } + if (this.#readOnly) return; // Derive abort marks a crash left unapplied below cancelled owners, and finalize held outcomes. this.#cascadePending = true; this.#scheduleReconcile(); @@ -421,6 +431,7 @@ export class TaskScheduler { * Idempotent; the kick does nothing once closing. */ resume(): void { + if (this.#readOnly) throw new ReadOnlySession("running tasks"); this.#enabled = true; this.#wakeDue(); this.#kick(); @@ -475,6 +486,8 @@ export class TaskScheduler { * sleep not yet due. Synchronous, from memory; a task whose chain is not loaded yet counts as it would alone. */ wake(): Wake { + // A read-only Harness runs nothing, so it never needs a process. + if (this.#readOnly) return { active: false }; const now = this.#now(); // A reconcile pass with work to do is work too: it finalizes held outcomes and marks cascades, at open as // recovery. A process that dies before it lands must be woken again. A pass with nothing to do is not. @@ -762,7 +775,8 @@ export class TaskScheduler { // ─── Ownership ─────────────────────────────────────────────────────────── #scheduleReconcile(): void { - if (this.#reconcileScheduled || this.#closing) return; + // A read-only reconcile could only fail: what it writes is refused, and its own commits fail the Session. + if (this.#reconcileScheduled || this.#closing || this.#readOnly) return; this.#reconcileScheduled = true; queueMicrotask(() => void this.#reconcile()); } @@ -1314,7 +1328,7 @@ export class TaskScheduler { /** Recompute the wake in a microtask, off the Session line: arm the built-in timer, or tell `onWake` of a change. */ #scheduleWake(): void { - if (this.#wakeScheduled || this.#closing) return; + if (this.#wakeScheduled || this.#closing || this.#readOnly) return; this.#wakeScheduled = true; queueMicrotask(() => { this.#wakeScheduled = false; diff --git a/packages/durable/src/harness/types.ts b/packages/durable/src/harness/types.ts index 6935fb797..545f12e6b 100644 --- a/packages/durable/src/harness/types.ts +++ b/packages/durable/src/harness/types.ts @@ -660,6 +660,14 @@ export type HarnessOptions = { * Its own throw or rejection is dropped. */ readonly onReport?: (error: unknown, info: ReportInfo) => void; + /** + * Open for reading only, as a viewer of a session that another process drives, for example a synced copy on a device + * that only shows it. Open recovers nothing: a `running` task stays as committed. Every commit with writes rejects + * with `ReadOnlySession` before it reaches Storage, and so does `resume()` and every call that enables scheduling; + * the Harness stays usable for reads and watches. `wake()` is always inactive, and `onWake` is never called. Reopen + * without it to write. + */ + readonly readOnly?: boolean; }; /** @@ -779,9 +787,10 @@ export type ConversationWatch = WatchHandle; /** Durable agent harness over one Session. */ export interface Harness extends Session { /** - * Enable task scheduling. Idempotent; throws after close. Calls that ask for progress enable it too: - * `Conversation.submit()`, `Conversation.compact()`, `Conversation.abort()`, `Submission.wait()`, `waitForTask()`, - * `Harness.waitForIdle()`, and `Conversation.waitForIdle()`. Read-only viewers never do. + * Enable task scheduling. Idempotent; throws after close, and `ReadOnlySession` on a read-only Harness. Calls that + * ask for progress enable it too: `Conversation.submit()`, `Conversation.compact()`, `Conversation.abort()`, + * `Submission.wait()`, `waitForTask()`, `Harness.waitForIdle()`, and `Conversation.waitForIdle()`. Read-only viewers + * never do. */ resume(): void; /** diff --git a/packages/durable/src/index.ts b/packages/durable/src/index.ts index 5012b44d1..60dad5337 100644 --- a/packages/durable/src/index.ts +++ b/packages/durable/src/index.ts @@ -9,7 +9,7 @@ export { ToolResultEntry, UserEntry, } from "./entries.ts"; -export { ConversationBusy, ReadAfterWrite, SessionFailed, StorageRequestError } from "./errors.ts"; +export { ConversationBusy, ReadAfterWrite, ReadOnlySession, SessionFailed, StorageRequestError } from "./errors.ts"; export { AgentDoc, configure, diff --git a/packages/durable/src/session/session.ts b/packages/durable/src/session/session.ts index d87507e54..99513ce91 100644 --- a/packages/durable/src/session/session.ts +++ b/packages/durable/src/session/session.ts @@ -9,7 +9,7 @@ import { materializeDocument, resolveAddress, } from "../documents.ts"; -import { SessionFailed, StorageRequestError } from "../errors.ts"; +import { ReadOnlySession, SessionFailed, StorageRequestError } from "../errors.ts"; import { idFromNumber } from "../ids.ts"; import { hostTimers, type Timers } from "../timers.ts"; import type { @@ -121,6 +121,8 @@ export class SessionImpl implements Session { readonly #attachedDocuments = new Map(); readonly #clock: () => number; readonly #documentRetentionMs: () => number; + /** Refuse every commit with writes (`HarnessOptions.readOnly`). */ + readonly #readOnly: boolean; readonly #timers: Timers; /** The pending eviction: a timer for the oldest idle document, or a line job when the retention is 0. */ #documentSweep: { readonly at: number; disarm: (() => void) | undefined } | undefined; @@ -158,9 +160,11 @@ export class SessionImpl implements Session { now: () => number = Date.now, documentRetentionMs: () => number | undefined = () => undefined, setTimer?: SetTimer, + readOnly = false, ) { // Waits race it; none has to. this.#failed.promise.catch(() => {}); + this.#readOnly = readOnly; this.#storage = new GuardedStorage(storage, this); this.#timers = hostTimers(setTimer, (error) => this.report(error, { type: "host_error", callback: "setTimer" })); // Cache bookkeeping must never change the outcome of the operation it follows: a clock or a retention setting @@ -675,6 +679,12 @@ export class SessionImpl implements Session { this.#sweepDocuments(); return result; } + if (this.#readOnly) { + // Refused before Storage: the Session stays healthy, and the caller sees why. + tx.discard(); + this.#sweepDocuments(); + throw new ReadOnlySession("a commit with writes"); + } // Storage adopts the batch synchronously once it is durable, before its next commit; a throw here would reach // Storage, so the callback keeps its own error. let adoption: { readonly documents: DocumentCommitChange[] } | { readonly error: unknown } | undefined; diff --git a/packages/durable/test/harness-read-only.test.ts b/packages/durable/test/harness-read-only.test.ts new file mode 100644 index 000000000..da6539789 --- /dev/null +++ b/packages/durable/test/harness-read-only.test.ts @@ -0,0 +1,144 @@ +// A read-only Harness: a viewer of a session another process drives, such as a synced copy on another device. +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { Harness, LiveDoc, ReadOnlySession, UserEntry, type Wake } from "@earendil-works/pi-durable"; +import { afterEach, describe, expect, it } from "vitest"; +import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; +import { allEntries, type ChatSetup, chatSetup, openChat, unanswered } from "./chat-support.ts"; +import { context } from "./session-support.ts"; + +const directories = new Set(); + +afterEach(async () => { + for (const directory of directories) await rm(directory, { recursive: true, force: true }); + directories.clear(); +}); + +async function sqlitePath(): Promise { + const directory = await mkdtemp(join(tmpdir(), "pi-durable-read-only-")); + directories.add(directory); + return join(directory, "session.sqlite"); +} + +async function openReadOnly(path: string, setup: ChatSetup, onWake?: (wake: Wake) => void): Promise { + return Harness.open( + await openNodeSqliteStorage(path), + { + models: setup.models, + registry: setup.registry, + readOnly: true, + onReport: (error) => setup.reports.push(error), + ...(onWake === undefined ? {} : { onWake }), + }, + context, + ); +} + +/** The next sequence the file would commit, read beside every open handle; `undefined` before the schema exists. */ +function head(path: string): number | undefined { + const db = new DatabaseSync(path); + try { + const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'durable_metadata'").get(); + if (exists === undefined) return undefined; + return Number(db.prepare("SELECT next_seq FROM durable_metadata WHERE singleton = 1").get()?.next_seq); + } finally { + db.close(); + } +} + +/** A session whose root generation is running in another Harness, still open, waiting for the model. */ +async function runningSession(path: string, setup: ChatSetup) { + const busy = unanswered(); + setup.faux.setResponses([busy.step]); + const driver = await openChat(await openNodeSqliteStorage(path), setup); + await driver.root.submit({ type: "input", content: "slow" }, context); + await busy.reached; + return driver; +} + +describe("read-only Harness", () => { + it("opens a session with work running elsewhere without writing", async () => { + const path = await sqlitePath(); + const setup = chatSetup(); + const driver = await runningSession(path, setup); + const before = head(path); + const wakes: Wake[] = []; + + const viewer = await openReadOnly(path, chatSetup(), (wake) => wakes.push(wake)); + const root = await viewer.root(context); + expect((await allEntries(root)).map((entry) => entry.kind)).toEqual([UserEntry.kind]); + const run = (await viewer.snapshot(LiveDoc, root.id, context))?.run; + expect(run).toBeDefined(); + expect((await viewer.getTask(run!.taskId, context))?.state.status).toBe("running"); + const inspection = await viewer.inspect(context); + expect(inspection.scheduling).toBe("paused"); + expect(viewer.wake()).toEqual({ active: false }); + await viewer.close(context); + + expect(head(path)).toBe(before); + expect(wakes).toEqual([]); + await driver.harness.close(context); + }); + + it("refuses writes and scheduling, and stays usable", async () => { + const path = await sqlitePath(); + const setup = chatSetup(); + setup.faux.setResponses([fauxAssistantMessage("one")]); + const driver = await openChat(await openNodeSqliteStorage(path), setup); + await (await driver.root.submit({ type: "input", content: "hi" }, context)).wait(context); + await driver.harness.close(context); + const before = head(path); + + const viewerSetup = chatSetup(); + const viewer = await openReadOnly(path, viewerSetup); + const root = await viewer.root(context); + await expect(root.commit((tx) => tx.appendEntry(root.id, { kind: "app.note" }), context)).rejects.toBeInstanceOf( + ReadOnlySession, + ); + await expect(root.submit({ type: "input", content: "again" }, context)).rejects.toBeInstanceOf(ReadOnlySession); + expect(() => viewer.resume()).toThrow(ReadOnlySession); + await expect(viewer.waitForIdle(context)).rejects.toBeInstanceOf(ReadOnlySession); + await expect(root.abort(context)).rejects.toBeInstanceOf(ReadOnlySession); + await expect(viewer.createConversation({ ownership: { kind: "ownerless" } }, context)).rejects.toBeInstanceOf( + ReadOnlySession, + ); + + // Still healthy: reads work, and a commit that writes nothing succeeds. + expect(await root.commit(() => "nothing", context)).toBe("nothing"); + expect((await allEntries(root)).length).toBe(2); + await viewer.close(context); + expect(head(path)).toBe(before); + expect(viewerSetup.reports).toEqual([]); + }); + + it("refuses to create the root of an empty storage", async () => { + const path = await sqlitePath(); + const viewer = await openReadOnly(path, chatSetup()); + await expect( + viewer.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } }), + ).rejects.toBeInstanceOf(ReadOnlySession); + await viewer.close(context); + expect(head(path)).toBe(1); + }); + + it("leaves recovery to the next writable open", async () => { + const path = await sqlitePath(); + const setup = chatSetup(); + const driver = await runningSession(path, setup); + const taskId = (await driver.harness.snapshot(LiveDoc, driver.root.id, context))!.run!.taskId; + + const viewer = await openReadOnly(path, chatSetup()); + await viewer.close(context); + + // The writable open recovers the task once, as if the viewer had never opened it. + const writer = await openChat(await openNodeSqliteStorage(path), chatSetup()); + const record = await writer.harness.getTask(taskId, context); + expect(record?.state.status).toBe("pending"); + expect(record?.restarts).toBe(1); + await writer.harness.close(context); + await driver.harness.close(context); + }); +}); From 74f46864025c68504cd10c89eeb626b7e3d64cea Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Thu, 22 Oct 2026 16:42:51 +0200 Subject: [PATCH 20/30] fix: trust robopipi[bot] in PR gate --- .github/workflows/pr-gate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index bc6f7eef1..b50f5a109 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -18,7 +18,7 @@ jobs: script: | const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; const VALID_CAPABILITIES = new Set(['issue', 'pr']); - const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']); + const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]', 'robopipi[bot]']); const prAuthor = context.payload.pull_request.user.login; const defaultBranch = context.payload.repository.default_branch; const isBotAuthor = prAuthor.endsWith('[bot]'); From 27cc6bfc0c2ca0bfdd983d3db4c992a2bfae9cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Thu, 22 Oct 2026 19:51:11 +0200 Subject: [PATCH 21/30] fix(coding-agent): stop macOS shell groups before killing them Reduce the race where killing a foreground child wakes its shell before the shell is signalled. Keep Linux and Windows termination unchanged and preserve kill fallbacks when stopping fails. closes #11252 --- packages/coding-agent/src/utils/shell.ts | 13 ++++ .../test/shell-kill-process-tree.test.ts | 75 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 packages/coding-agent/test/shell-kill-process-tree.test.ts diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index fbe81f965..9289d27e4 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -236,6 +236,19 @@ export function killProcessTree(pid: number): void { // Ignore errors if taskkill fails. } } else { + if (process.platform === "darwin") { + // macOS signals group members individually after releasing the group lock. A killed + // child can wake its waiting shell before that shell is signalled, letting `a; b` + // start b during cancellation (#11252). Stop the group first to reduce this race; + // this is best-effort, not an atomic freeze. SIGKILL also kills stopped processes. + // Linux holds tasklist_lock across group signalling; child exit notification needs + // its write lock, so the shell is signalled before observing the killed child's exit. + try { + process.kill(-pid, "SIGSTOP"); + } catch { + // A failed stop must not prevent the kill attempt or its single-process fallback. + } + } // Use SIGKILL on Unix/Linux/Mac try { process.kill(-pid, "SIGKILL"); diff --git a/packages/coding-agent/test/shell-kill-process-tree.test.ts b/packages/coding-agent/test/shell-kill-process-tree.test.ts new file mode 100644 index 000000000..8cc300053 --- /dev/null +++ b/packages/coding-agent/test/shell-kill-process-tree.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { killProcessTree } from "../src/utils/shell.ts"; + +const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + +// Regression coverage for #11252: stop-before-kill is macOS-only and best-effort. +describe("killProcessTree", () => { + beforeEach(() => { + Object.defineProperty(process, "platform", { configurable: true, value: "darwin" }); + vi.spyOn(process, "kill").mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor); + }); + + it("stops the macOS process group before killing it without resuming it", () => { + killProcessTree(1234); + + expect(vi.mocked(process.kill).mock.calls).toEqual([ + [-1234, "SIGSTOP"], + [-1234, "SIGKILL"], + ]); + }); + + it("still kills the group when stopping fails", () => { + vi.mocked(process.kill).mockImplementationOnce(() => { + throw new Error("stop failed"); + }); + + killProcessTree(1234); + + expect(vi.mocked(process.kill).mock.calls).toEqual([ + [-1234, "SIGSTOP"], + [-1234, "SIGKILL"], + ]); + }); + + it("falls back to killing the shell when the group kill fails", () => { + vi.mocked(process.kill).mockImplementation((pid, signal) => { + if (pid < 0 && signal === "SIGKILL") throw new Error("group kill failed"); + return true; + }); + + killProcessTree(1234); + + expect(vi.mocked(process.kill).mock.calls).toEqual([ + [-1234, "SIGSTOP"], + [-1234, "SIGKILL"], + [1234, "SIGKILL"], + ]); + }); + + it("tolerates failure of both group signals and the fallback", () => { + vi.mocked(process.kill).mockImplementation(() => { + throw new Error("process no longer exists"); + }); + + expect(() => killProcessTree(1234)).not.toThrow(); + expect(vi.mocked(process.kill).mock.calls).toEqual([ + [-1234, "SIGSTOP"], + [-1234, "SIGKILL"], + [1234, "SIGKILL"], + ]); + }); + + it("keeps Linux group termination unchanged", () => { + Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); + + killProcessTree(1234); + + expect(vi.mocked(process.kill).mock.calls).toEqual([[-1234, "SIGKILL"]]); + }); +}); From 9359adf0c6df974b2059b597e632a14cf1942c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Thu, 22 Oct 2026 20:16:38 +0200 Subject: [PATCH 22/30] fix(coding-agent): render system messages in HTML export Render the leading system message as the system prompt and tool list, and later system messages as named section and tool updates. The header prompt/tools from agent state remain as a fallback for sessions without system entries. CLI --export now shows the prompt and tools too (#3464). closes #11261 --- .../src/core/export-html/template.css | 9 + .../src/core/export-html/template.js | 158 +++++++++++++++--- .../test/export-html-system-messages.test.ts | 83 +++++++++ 3 files changed, 225 insertions(+), 25 deletions(-) create mode 100644 packages/coding-agent/test/export-html-system-messages.test.ts diff --git a/packages/coding-agent/src/core/export-html/template.css b/packages/coding-agent/src/core/export-html/template.css index 1de5bf46b..2e2489994 100644 --- a/packages/coding-agent/src/core/export-html/template.css +++ b/packages/coding-agent/src/core/export-html/template.css @@ -687,6 +687,15 @@ display: block; } + .system-entry > .message-timestamp { + padding-left: var(--line-height); + margin-bottom: 4px; + } + + .system-entry > :last-child { + margin-bottom: 0; + } + .system-prompt.provider-prompt { border-left: 3px solid var(--warning); } diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index 737adbdb5..d6541ca77 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -313,6 +313,76 @@ return ''; } + // ============================================================ + // SYSTEM MESSAGES (pure data) + // ============================================================ + + function systemContentText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.filter(c => c.type === 'text' && c.text).map(c => c.text).join('\n'); + } + return ''; + } + + /** Full prompt text of a system message, mirroring getSystemMessageText() in pi-ai. */ + function getSystemMessageText(msg) { + const parts = [systemContentText(msg.content)]; + for (const value of Object.values(msg.sections || {})) { + if (value !== null) parts.push(value); + } + return parts.filter(part => part.length > 0).join('\n\n'); + } + + /** Text of a later system message, mirroring renderSystemMessageUpdate() in pi-ai. */ + function renderSystemMessageUpdate(msg) { + const parts = []; + const text = systemContentText(msg.content); + if (text.length > 0) parts.push(text); + for (const [name, value] of Object.entries(msg.sections || {})) { + parts.push(value === null + ? `Removed system prompt section "${name}".` + : `Updated system prompt section "${name}":\n\n${value}`); + } + return parts.join('\n\n'); + } + + function carriesSystemState(entry) { + return (entry.type === 'message' && entry.message.role === 'system') || + (entry.type === 'compaction' && entry.systemMessage); + } + + /** + * Whether a system entry starts the prompt on its branch. Later system entries + * patch the prompt, so they render as updates instead of a full prompt. + */ + function isLeadingSystemEntry(entry) { + let current = entry.parentId && entry.parentId !== entry.id ? byId.get(entry.parentId) : undefined; + while (current) { + if (carriesSystemState(current)) return false; + if (!current.parentId || current.parentId === current.id) break; + current = byId.get(current.parentId); + } + return true; + } + + /** Short description of what a later system message changes, e.g. "project_context, -rules, tools +bash". */ + function describeSystemUpdate(msg) { + const parts = []; + if (systemContentText(msg.content).length > 0) parts.push('instructions'); + for (const [name, value] of Object.entries(msg.sections || {})) { + parts.push(value === null ? `-${name}` : name); + } + const toolChanges = [ + ...(msg.toolsAdded || []).map(t => `+${t.name}`), + ...(msg.toolsRemoved || []).map(t => `-${t.name}`), + ]; + if (toolChanges.length > 0) parts.push(`tools ${toolChanges.join(' ')}`); + return parts.join(', '); + } + + const hasSystemEntries = entries.some(entry => entry.type === 'message' && entry.message.role === 'system'); + /** * Parse a skill block from message text. * Returns null if the text doesn't contain a skill block. @@ -337,6 +407,13 @@ case 'message': { const msg = entry.message; parts.push(msg.role); + if (msg.role === 'system') { + parts.push(isLeadingSystemEntry(entry) ? 'system prompt' : 'system update'); + parts.push(getSystemMessageText(msg)); + for (const name of Object.keys(msg.sections || {})) parts.push(name); + for (const tool of [...(msg.toolsAdded || []), ...(msg.toolsRemoved || [])]) parts.push(tool.name); + break; + } if (msg.content) parts.push(extractContent(msg.content)); if (msg.role === 'bashExecution' && msg.command) parts.push(msg.command); break; @@ -675,6 +752,13 @@ const cmd = truncate(normalize(msg.command || '')); return labelHtml + `[bash]: ${escapeHtml(cmd)}`; } + if (msg.role === 'system') { + if (isLeadingSystemEntry(entry)) { + return labelHtml + `[system prompt]`; + } + const changes = describeSystemUpdate(msg); + return labelHtml + `[system update${changes ? ': ' + escapeHtml(truncate(changes)) : ''}]`; + } return labelHtml + `[${escapeHtml(msg.role)}]`; } case 'compaction': @@ -1311,6 +1395,24 @@ } if (msg.role === 'toolResult') return ''; + + if (msg.role === 'system') { + let html = ''; + if (isLeadingSystemEntry(entry)) { + const text = getSystemMessageText(msg); + if (text) html += renderSystemPromptBlock('System Prompt', text); + html += renderToolsList('Available Tools', msg.toolsAdded); + } else { + const text = renderSystemMessageUpdate(msg); + const changes = describeSystemUpdate(msg); + if (text) html += renderSystemPromptBlock(`System Prompt Update${changes ? ': ' + changes : ''}`, text); + html += renderToolsList('Tools Added', msg.toolsAdded); + if (msg.toolsRemoved && msg.toolsRemoved.length > 0) { + html += `
Tools Removed
${msg.toolsRemoved.map(t => `
${escapeHtml(t.name)}
`).join('')}
`; + } + } + return html ? `
${tsHtml}${html}
` : ''; + } } if (entry.type === 'model_change') { @@ -1437,30 +1539,39 @@ `; - // Render system prompt (user's base prompt, applies to all providers) - if (systemPrompt) { - const lines = systemPrompt.split('\n'); - const previewLines = 10; - if (lines.length > previewLines) { - const preview = lines.slice(0, previewLines).join('\n'); - const remaining = lines.length - previewLines; - html += ``; - } else { - html += `
-
System Prompt
-
${escapeHtml(systemPrompt)}
-
`; - } + // Sessions with system entries render the prompt and tools in the transcript, where they + // occur. Older sessions have none, so fall back to the exporting agent's current state. + if (!hasSystemEntries) { + if (systemPrompt) html += renderSystemPromptBlock('System Prompt', systemPrompt); + html += renderToolsList('Available Tools', tools); } - if (tools && tools.length > 0) { - html += `
-
Available Tools
+ return html; + } + + function renderSystemPromptBlock(title, text) { + const lines = text.split('\n'); + const previewLines = 10; + if (lines.length > previewLines) { + const preview = lines.slice(0, previewLines).join('\n'); + const remaining = lines.length - previewLines; + return ``; + } + return `
+
${escapeHtml(title)}
+
${escapeHtml(text)}
+
`; + } + + function renderToolsList(title, tools) { + if (!tools || tools.length === 0) return ''; + return `
+
${escapeHtml(title)}
${tools.map(t => { const hasParams = t.parameters && typeof t.parameters === 'object' && t.parameters.properties && Object.keys(t.parameters.properties).length > 0; @@ -1485,9 +1596,6 @@ }).join('')}
`; - } - - return html; } // ============================================================ diff --git a/packages/coding-agent/test/export-html-system-messages.test.ts b/packages/coding-agent/test/export-html-system-messages.test.ts new file mode 100644 index 000000000..ee931a750 --- /dev/null +++ b/packages/coding-agent/test/export-html-system-messages.test.ts @@ -0,0 +1,83 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { getSystemMessageText, renderSystemMessageUpdate, type SystemMessage } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; + +interface SystemHelpers { + getSystemMessageText(message: SystemMessage): string; + renderSystemMessageUpdate(message: SystemMessage): string; + isLeadingSystemEntry(entry: { id: string; parentId: string | null }): boolean; + describeSystemUpdate(message: SystemMessage): string; +} + +const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8"); + +function loadSystemHelpers(entries: unknown[]): SystemHelpers { + const start = templateJs.indexOf("function systemContentText(content)"); + const end = templateJs.indexOf("const hasSystemEntries"); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + const context: { entries: unknown[]; helpers?: SystemHelpers } = { entries }; + runInNewContext( + `const byId = new Map(entries.map(entry => [entry.id, entry])); + ${templateJs.slice(start, end)} + helpers = { getSystemMessageText, renderSystemMessageUpdate, isLeadingSystemEntry, describeSystemUpdate };`, + context, + ); + return context.helpers!; +} + +const initial: SystemMessage = { + role: "system", + content: "You are a helpful assistant.", + sections: { rules: "\nbe concise\n", project_context: "\nv1\n" }, + toolsAdded: [{ name: "read", description: "Read a file", parameters: { type: "object", properties: {} } as never }], + timestamp: 1, +}; + +const update: SystemMessage = { + role: "system", + content: [{ type: "text", text: "Extra instructions" }], + sections: { project_context: "\nv2\n", rules: null }, + toolsAdded: [ + { name: "bash", description: "Run a command", parameters: { type: "object", properties: {} } as never }, + ], + toolsRemoved: [{ name: "read" }], + timestamp: 2, +}; + +const entries = [ + { type: "message", id: "s1", parentId: null, message: initial }, + { type: "message", id: "u1", parentId: "s1", message: { role: "user", content: "hi" } }, + { type: "message", id: "s2", parentId: "u1", message: update }, +]; + +// Regression tests for #11261: system messages were stored in the export but never rendered. +describe("export HTML system messages", () => { + it("renders system message text the same way the model receives it", () => { + const helpers = loadSystemHelpers(entries); + expect(helpers.getSystemMessageText(initial)).toBe(getSystemMessageText(initial)); + expect(helpers.renderSystemMessageUpdate(update)).toBe(renderSystemMessageUpdate(update)); + }); + + it("renders the first system entry on a branch as the prompt and later ones as updates", () => { + const helpers = loadSystemHelpers(entries); + expect(helpers.isLeadingSystemEntry(entries[0])).toBe(true); + expect(helpers.isLeadingSystemEntry(entries[2])).toBe(false); + expect(helpers.describeSystemUpdate(update)).toBe("instructions, project_context, -rules, tools +bash -read"); + }); + + it("treats a compaction snapshot as earlier system state", () => { + const helpers = loadSystemHelpers([ + { type: "compaction", id: "c1", parentId: null, systemMessage: initial }, + { type: "message", id: "s2", parentId: "c1", message: update }, + ]); + expect(helpers.isLeadingSystemEntry({ id: "s2", parentId: "c1" })).toBe(false); + }); + + it("renders system entries in the transcript", () => { + expect(templateJs).toMatch(/if \(msg\.role === 'system'\) \{\s*let html = '';/); + expect(templateJs).toContain("renderSystemPromptBlock('System Prompt', text)"); + expect(templateJs).toContain("renderToolsList('Available Tools', msg.toolsAdded)"); + }); +}); From 64627838b276da852f312800ff423d83bba0b139 Mon Sep 17 00:00:00 2001 From: Ville Penttinen Date: Thu, 22 Oct 2026 21:49:38 +0300 Subject: [PATCH 23/30] feat(coding-agent): add messageId and timestamp to markdown transformer context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(coding-agent): add messageId and timestamp to markdown transformer context * fix(coding-agent): update compaction render test for messageMeta argument --------- Co-authored-by: Petr Houška --- .../coding-agent/src/core/extensions/types.ts | 4 +++ .../components/assistant-message.ts | 16 +++++++++- .../components/markdown-transform.ts | 8 ++++- .../interactive/components/user-message.ts | 14 ++++++++- .../src/modes/interactive/interactive-mode.ts | 31 ++++++++++++++++--- .../test/interactive-mode-compaction.test.ts | 7 +++++ .../coding-agent/test/user-message.test.ts | 17 ++++++++++ 7 files changed, 90 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 60388c0eb..b29e2d4b0 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1542,6 +1542,10 @@ export interface MarkdownTransformContext { messageType: "user" | "assistant" | "assistant-thinking"; isStreaming: boolean; availableWidth: number; + /** Persisted session-entry id. Undefined for live messages or live rendering; available when rendering from persisted session entries. */ + messageId?: string; + /** Persisted session-entry ISO timestamp. Undefined for live messages or live rendering; available when rendering from persisted session entries. */ + timestamp?: string; } export type MarkdownTransformer = (markdown: string, context: MarkdownTransformContext) => string; diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index c02e37165..c710c7df7 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -21,6 +21,8 @@ export class AssistantMessageComponent extends Container { private lastMessage?: AssistantMessage; private hasToolCalls = false; private isStreaming = false; + private messageId?: string; + private timestamp?: string; private thinkingVisibilityOverrides = new Map(); constructor( @@ -30,6 +32,8 @@ export class AssistantMessageComponent extends Container { hiddenThinkingLabel = "Thinking...", outputPad = 1, markdownTransformers: readonly MarkdownTransformer[] = [], + messageId?: string, + timestamp?: string, ) { super(); @@ -38,6 +42,8 @@ export class AssistantMessageComponent extends Container { this.hiddenThinkingLabel = hiddenThinkingLabel; this.outputPad = outputPad; this.markdownTransformers = markdownTransformers; + this.messageId = messageId; + this.timestamp = timestamp; // Container for text/thinking content this.contentContainer = new Container(); @@ -112,7 +118,13 @@ export class AssistantMessageComponent extends Container { // Set paddingY=0 to avoid extra spacing before tool executions this.contentContainer.addChild( new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme, undefined, { - transform: createMarkdownTransform("assistant", this.isStreaming, this.markdownTransformers), + transform: createMarkdownTransform( + "assistant", + this.isStreaming, + this.markdownTransformers, + this.messageId, + this.timestamp, + ), }), ); } else if (content.type === "thinking") { @@ -157,6 +169,8 @@ export class AssistantMessageComponent extends Container { "assistant-thinking", this.isStreaming, this.markdownTransformers, + this.messageId, + this.timestamp, ), }, ); diff --git a/packages/coding-agent/src/modes/interactive/components/markdown-transform.ts b/packages/coding-agent/src/modes/interactive/components/markdown-transform.ts index 417bac5e9..4ff7685c2 100644 --- a/packages/coding-agent/src/modes/interactive/components/markdown-transform.ts +++ b/packages/coding-agent/src/modes/interactive/components/markdown-transform.ts @@ -4,9 +4,15 @@ export function createMarkdownTransform( messageType: MarkdownTransformContext["messageType"], isStreaming: boolean, transformers: readonly MarkdownTransformer[], + messageId?: string, + timestamp?: string, ): (markdown: string, availableWidth: number) => string { return (markdown, availableWidth) => - applyMarkdownTransformers(markdown, { messageType, isStreaming, availableWidth }, transformers); + applyMarkdownTransformers( + markdown, + { messageType, isStreaming, availableWidth, messageId, timestamp }, + transformers, + ); } function applyMarkdownTransformers( diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index c187eff43..d8ca373cc 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -15,18 +15,24 @@ export class UserMessageComponent extends Container { private markdownTheme: MarkdownTheme; private outputPad: number; private markdownTransformers: readonly MarkdownTransformer[]; + private messageId?: string; + private timestamp?: string; constructor( text: string, markdownTheme: MarkdownTheme = getMarkdownTheme(), outputPad = 1, markdownTransformers: readonly MarkdownTransformer[] = [], + messageId?: string, + timestamp?: string, ) { super(); this.text = text; this.markdownTheme = markdownTheme; this.outputPad = outputPad; this.markdownTransformers = markdownTransformers; + this.messageId = messageId; + this.timestamp = timestamp; this.rebuild(); } @@ -52,7 +58,13 @@ export class UserMessageComponent extends Container { { preserveOrderedListMarkers: true, preserveBackslashEscapes: true, - transform: createMarkdownTransform("user", false, this.markdownTransformers), + transform: createMarkdownTransform( + "user", + false, + this.markdownTransformers, + this.messageId, + this.timestamp, + ), }, ), ); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index e24d150be..a596eea88 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3834,7 +3834,10 @@ export class InteractiveMode { this.chatContainer.addChild(component); } - private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { + private addMessageToChat( + message: AgentMessage, + options?: { populateHistory?: boolean; messageId?: string; timestamp?: string }, + ): void { switch (message.role) { case "bashExecution": { const component = new BashExecutionComponent( @@ -3917,6 +3920,8 @@ export class InteractiveMode { this.getMarkdownThemeWithSettings(), this.outputPad, this.getMarkdownTransformers(), + options?.messageId, + options?.timestamp, ); this.chatContainer.addChild(userComponent); } @@ -3926,6 +3931,8 @@ export class InteractiveMode { this.getMarkdownThemeWithSettings(), this.outputPad, this.getMarkdownTransformers(), + options?.messageId, + options?.timestamp, ); this.chatContainer.addChild(userComponent); } @@ -3943,6 +3950,8 @@ export class InteractiveMode { this.hiddenThinkingLabel, this.outputPad, this.getMarkdownTransformers(), + options?.messageId, + options?.timestamp, ); this.chatContainer.addChild(assistantComponent); break; @@ -3959,6 +3968,7 @@ export class InteractiveMode { private renderSessionItems( items: readonly RenderSessionItem[], + messageMeta?: Map, options: { updateFooter?: boolean; populateHistory?: boolean } = {}, ): void { this.pendingTools.clear(); @@ -3991,7 +4001,11 @@ export class InteractiveMode { const message = item; // Assistant messages need special handling for tool calls if (message.role === "assistant") { - this.addMessageToChat(message); + const meta = messageMeta?.get(message); + this.addMessageToChat(message, { + messageId: meta?.id, + timestamp: meta?.timestamp, + }); // Render tool call components for (const content of message.content) { if (content.type === "toolCall") { @@ -4041,7 +4055,12 @@ export class InteractiveMode { } } else { // All other messages use standard rendering - this.addMessageToChat(message, options); + const meta = messageMeta?.get(message); + this.addMessageToChat(message, { + ...options, + messageId: meta?.id, + timestamp: meta?.timestamp, + }); } } @@ -4063,17 +4082,21 @@ export class InteractiveMode { ): void { // Selection coordinates point into the transcript being replaced (#9311). if (this.renderer instanceof TuiAltScreen) this.renderer.resetTextSelection(); + const messageMeta = new Map(); const items = entries.flatMap((entry): RenderSessionItem[] => { if (entry.type === "custom" || (entry.type === "usage" && entry.kind === "cache_warm")) { return [entry]; } const messages = sessionEntryToContextMessages(entry); + for (const msg of messages) { + messageMeta.set(msg, { id: entry.id, timestamp: entry.timestamp }); + } if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage && messages.length > 0) { return [...messages, { type: "compaction_cost", kind: entry.type, usage: entry.usage }]; } return messages; }); - this.renderSessionItems(items, options); + this.renderSessionItems(items, messageMeta, options); } private addCacheWarmingUsage(entry: UsageEntry): void { diff --git a/packages/coding-agent/test/interactive-mode-compaction.test.ts b/packages/coding-agent/test/interactive-mode-compaction.test.ts index 0f35b02b4..89b329fa1 100644 --- a/packages/coding-agent/test/interactive-mode-compaction.test.ts +++ b/packages/coding-agent/test/interactive-mode-compaction.test.ts @@ -102,8 +102,15 @@ describe("InteractiveMode compaction events", () => { expect.objectContaining({ role: "compactionSummary", summary: "previous summary" }), { type: "compaction_cost", kind: "compaction", usage: previousUsage }, ], + expect.any(Map), {}, ); + const [items, messageMeta] = fakeThis.renderSessionItems.mock.calls[0] as [ + unknown[], + Map, + ]; + expect(messageMeta.get(items[0])).toEqual({ id: "current", timestamp: "2025-01-02T00:00:00Z" }); + expect(messageMeta.get(items[2])).toEqual({ id: "previous", timestamp: "2025-01-01T00:00:00Z" }); }); test("renders retained entries and appends the latest summary cost at the bottom", async () => { diff --git a/packages/coding-agent/test/user-message.test.ts b/packages/coding-agent/test/user-message.test.ts index e1f6ad23c..0fd1766f0 100644 --- a/packages/coding-agent/test/user-message.test.ts +++ b/packages/coding-agent/test/user-message.test.ts @@ -55,4 +55,21 @@ describe("UserMessageComponent", () => { expect(stripAnsi(component.render(80).join("\n"))).toContain("Message after"); }); + + test("passes persisted entry identity in transformer context; live leaves it undefined", () => { + initTheme("dark"); + const captured: Array<{ messageId?: string; timestamp?: string }> = []; + const capture = (_markdown: string, context: { messageId?: string; timestamp?: string }) => { + captured.push({ messageId: context.messageId, timestamp: context.timestamp }); + return _markdown; + }; + + new UserMessageComponent("restored", undefined, 1, [capture], "entry-abc", "2025-01-15T10:30:00.000Z").render(80); + new UserMessageComponent("live", undefined, 1, [capture]).render(80); + + expect(captured).toEqual([ + { messageId: "entry-abc", timestamp: "2025-01-15T10:30:00.000Z" }, + { messageId: undefined, timestamp: undefined }, + ]); + }); }); From 1c720d28504189273f1d2a165fbb5df6d84f0983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Thu, 22 Oct 2026 20:40:35 +0200 Subject: [PATCH 24/30] fix(coding-agent): render HTML export for sessions deeper than the call stack Replace recursive tree walks in the export template (sortChildren, mapNodes, markActive) with iterative traversals, and build getPath with push + reverse. closes #11225 --- .../src/core/export-html/template.js | 40 ++++---- .../test/export-html-deep-tree.test.ts | 93 +++++++++++++++++++ 2 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 packages/coding-agent/test/export-html-deep-tree.test.ts diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index d6541ca77..37022818d 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -98,14 +98,12 @@ } } - // Sort children by timestamp - function sortChildren(node) { + // Sort children by timestamp. Iterative: long sessions form chains deeper than the JS call stack. + for (const node of nodeMap.values()) { node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime() ); - node.children.forEach(sortChildren); } - roots.forEach(sortChildren); return roots; } @@ -134,14 +132,14 @@ const path = []; let current = byId.get(targetId); while (current) { - path.unshift(current); + path.push(current); // Stop if no parent or self-referencing (root) if (!current.parentId || current.parentId === current.id) { break; } current = byId.get(current.parentId); } - return path; + return path.reverse(); } // Tree node lookup for finding leaves @@ -156,12 +154,12 @@ // Build tree node map lazily if (!treeNodeMap) { treeNodeMap = new Map(); - const tree = buildTree(); - function mapNodes(node) { + const stack = [...buildTree()]; + while (stack.length > 0) { + const node = stack.pop(); treeNodeMap.set(node.entry.id, node); - node.children.forEach(mapNodes); + for (const child of node.children) stack.push(child); } - tree.forEach(mapNodes); } const node = treeNodeMap.get(nodeId); @@ -185,16 +183,22 @@ const multipleRoots = roots.length > 1; // Mark which subtrees contain the active leaf + // Iterative post-order: long sessions form chains deeper than the JS call stack. const containsActive = new Map(); - function markActive(node) { - let has = activePathIds.has(node.entry.id); - for (const child of node.children) { - if (markActive(child)) has = true; - } - containsActive.set(node, has); - return has; + const postOrder = []; + const pending = [...roots]; + while (pending.length > 0) { + const node = pending.pop(); + postOrder.push(node); + for (const child of node.children) pending.push(child); + } + for (let i = postOrder.length - 1; i >= 0; i--) { + const node = postOrder[i]; + containsActive.set( + node, + activePathIds.has(node.entry.id) || node.children.some(child => containsActive.get(child)) + ); } - roots.forEach(markActive); // Stack: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] const stack = []; diff --git a/packages/coding-agent/test/export-html-deep-tree.test.ts b/packages/coding-agent/test/export-html-deep-tree.test.ts new file mode 100644 index 000000000..59a8a9851 --- /dev/null +++ b/packages/coding-agent/test/export-html-deep-tree.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createContext, runInContext } from "node:vm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { exportFromFile } from "../src/core/export-html/index.ts"; +import { CURRENT_SESSION_VERSION, type SessionHeader, type SessionMessageEntry } from "../src/core/session-manager.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +interface TreeNode { + entry: { id: string }; + children: TreeNode[]; +} + +interface TreeApi { + buildTree: () => TreeNode[]; + buildActivePathIds: (targetId: string) => Set; + flattenTree: (roots: TreeNode[], activePathIds: Set) => Array<{ node: TreeNode }>; + findNewestLeaf: (nodeId: string) => string; + getPath: (targetId: string) => Array<{ id: string }>; +} + +function loadTreeApi(html: string): TreeApi { + const sessionData = html.match(/