mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge remote-tracking branch 'origin/main' into anthropic-message-threads
# Conflicts: # packages/ai/src/providers/compat-schema.ts
This commit is contained in:
commit
a445b89112
1300 changed files with 126235 additions and 22917 deletions
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"revision": "sha256-7fe1b41feaf43a7aad4bc832e6b306c7ced56e6d41bbc724b74e91eeacff6edf"
|
||||
"revision": "sha256-f4dbabe79cda4b38aee4a761b23fd5eaf526ac2e28fed3c9ee86e37ca0e5d71e"
|
||||
}
|
||||
|
|
|
|||
918
package-lock.json
generated
918
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,17 +8,16 @@
|
|||
"packages/coding-agent/examples/extensions/with-deps",
|
||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
||||
"packages/coding-agent/examples/extensions/sandbox",
|
||||
"packages/coding-agent/examples/extensions/gondolin"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "npm run clean --workspaces",
|
||||
"build": "cd packages/telemetry && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
|
||||
"build:offline": "cd packages/telemetry && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build:offline && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
|
||||
"build": "cd packages/telemetry && npm run build && cd ../schema && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
|
||||
"build:offline": "cd packages/telemetry && npm run build && cd ../schema && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build:offline && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
|
||||
"build:native:darwin": "npm --prefix packages/tui run build:native:darwin",
|
||||
"build:native:linux": "npm --prefix packages/tui run build:native:linux",
|
||||
"build:native:win32": "npm --prefix packages/tui run build:native:win32",
|
||||
"check": "node scripts/biome-check.mjs --write && npm run check:pinned-deps && npm run check:runtime-deps && npm run check:ts-imports && npm run check:entry-graphs && npm run check:install-lock:coding-agent && tsc --noEmit && npm run check:browser-smoke",
|
||||
"check": "node scripts/biome-check.mjs --write && npm run check:pinned-deps && npm run check:runtime-deps && npm run check:ts-imports && npm run check:entry-graphs && npm run check:install-lock:coding-agent && tsc --noEmit && tsc --noEmit -p packages/coding-agent/examples/tsconfig.json && npm run check:browser-smoke",
|
||||
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
|
||||
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
|
||||
"check:runtime-deps": "node scripts/check-runtime-deps.mjs",
|
||||
|
|
@ -57,14 +56,12 @@
|
|||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "0.0.26",
|
||||
"@biomejs/biome": "2.3.5",
|
||||
"@biomejs/wasm-nodejs": "2.3.5",
|
||||
"@types/node": "22.19.19",
|
||||
"esbuild": "0.28.2",
|
||||
"husky": "9.1.7",
|
||||
"pnpm": "11.15.1",
|
||||
"shx": "0.3.4",
|
||||
"typescript": "7.0.2",
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.5.0] - 2026-11-02
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `AgentTool` takes any JSON Schema object as `parameters` (`AgentTool<TParameters extends object>`) and types `params` and `prepareArguments` through `@earendil-works/pi-schema` (`AgentToolParams<TParameters>`): pi-schema's builders give their static type, TypeBox schemas give TypeBox's static type for the common kinds (String, Number, Integer, Boolean, Null, Literal, Array, Object with optional and readonly properties, Union, Record, Unsafe, Unknown, Any; other kinds such as Enum, Tuple and Intersect give `unknown`), other schemas give `unknown`, `AgentTool<any>` stays `any`. `outputSchema` is any JSON Schema object. `typebox` is no longer a dependency. Migration: build parameters with pi-schema (`obj({ path: str() })` instead of `Type.Object({ path: Type.String() })`), or keep TypeBox schemas (narrow `params` yourself only where they use other kinds).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `streamProxy()` re-parsing the accumulated tool-call arguments on every delta and keeping their raw text on the partial content block ([#9265](https://github.com/earendil-works/pi/issues/9265))
|
||||
|
|
|
|||
|
|
@ -455,18 +455,20 @@ const agent = new Agent({
|
|||
|
||||
## Tools
|
||||
|
||||
Define tools using `AgentTool`:
|
||||
Define tools using `AgentTool`. `parameters` is any JSON Schema object; built with `@earendil-works/pi-schema`'s builders, it also types `params` (other schemas give `unknown`, `AgentTool<any>` gives `any`):
|
||||
|
||||
```typescript
|
||||
import { Type } from "typebox";
|
||||
import { obj, str } from "@earendil-works/pi-schema";
|
||||
|
||||
const readFileTool: AgentTool = {
|
||||
const readFileParameters = obj({
|
||||
path: str({ description: "File path" }),
|
||||
});
|
||||
|
||||
const readFileTool: AgentTool<typeof readFileParameters> = {
|
||||
name: "read_file",
|
||||
label: "Read File", // For UI display
|
||||
description: "Read a file's contents",
|
||||
parameters: Type.Object({
|
||||
path: Type.String({ description: "File path" }),
|
||||
}),
|
||||
parameters: readFileParameters,
|
||||
// Override execution mode for this tool (optional).
|
||||
// "sequential" forces the entire batch to run one at a time.
|
||||
// "parallel" allows concurrent execution with other tool calls.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
renderDeclarations,
|
||||
} from "@earendil-works/pi-codemode";
|
||||
import { type McpClient, toLlmContent } from "@earendil-works/pi-mcp";
|
||||
import { Type } from "typebox";
|
||||
import { obj, str } from "@earendil-works/pi-schema";
|
||||
|
||||
/** Provider tool names are limited to 64 characters of `[A-Za-z0-9_-]`. */
|
||||
function toToolName(prefix: string, name: string): string {
|
||||
|
|
@ -45,12 +45,12 @@ export async function createMcpTools(client: McpClient, prefix: string): Promise
|
|||
label: tool.title ?? tool.name,
|
||||
description: tool.description ?? tool.title ?? `MCP tool ${tool.name}`,
|
||||
// Providers require an object schema, and some reject one without `properties`.
|
||||
parameters: Type.Unsafe({
|
||||
parameters: {
|
||||
...tool.inputSchema,
|
||||
type: "object",
|
||||
properties: tool.inputSchema.properties ?? {},
|
||||
}),
|
||||
outputSchema: tool.outputSchema ? Type.Unsafe<Record<string, unknown>>(tool.outputSchema) : undefined,
|
||||
},
|
||||
outputSchema: tool.outputSchema,
|
||||
execute: async (_toolCallId, params, signal, onUpdate) => {
|
||||
const result = await client.callTool(tool.name, params as Record<string, unknown>, {
|
||||
signal,
|
||||
|
|
@ -104,9 +104,11 @@ export function createNestedToolRunner(
|
|||
|
||||
export const CODEMODE_TOOL_NAME = "codemode";
|
||||
|
||||
const codemodeParameters = Type.Object({
|
||||
code: Type.String({ description: "JavaScript source. Top-level await and return work." }),
|
||||
});
|
||||
const codemodeParameters = obj(
|
||||
{ code: str({ description: "JavaScript source. Top-level await and return work." }) },
|
||||
// Open, as the tool accepted extra arguments before it moved from TypeBox.
|
||||
{ open: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* A tool that runs model-written JavaScript in the codemode sandbox, where `tools.<name>(args)`
|
||||
|
|
@ -121,7 +123,6 @@ export function createCodemodeTool(
|
|||
const sandboxTools: CodemodeTool[] = callable.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
// TypeBox schemas are JSON Schema.
|
||||
inputSchema: tool.parameters as CodemodeJsonSchema,
|
||||
// What the script receives: the structured result if the tool declares one, else its text.
|
||||
outputSchema: (tool.outputSchema as CodemodeJsonSchema | undefined) ?? { type: "string" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-agent-core",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -17,14 +17,14 @@
|
|||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"clean": "node ../../scripts/package-files.mjs clean dist",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"test": "vitest --run",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "1.4.0",
|
||||
"typebox": "1.3.27"
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
"@earendil-works/pi-schema": "1.5.0"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type {
|
|||
TranscriptContext,
|
||||
Usage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { Static, TSchema } from "typebox";
|
||||
import type { Static } from "@earendil-works/pi-schema";
|
||||
|
||||
/**
|
||||
* Stream function used by the agent loop. `Models.streamSimple` satisfies
|
||||
|
|
@ -462,27 +462,39 @@ export interface AgentToolCallOutcome {
|
|||
*/
|
||||
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
|
||||
|
||||
/** Tool definition used by the agent runtime. */
|
||||
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
|
||||
/**
|
||||
* The arguments type of a tool with parameter schema `TParameters`: pi-schema's `Static<TParameters>` (the builders'
|
||||
* type, TypeBox's for the common TypeBox kinds, else `unknown`), and `any` for `AgentTool<any>`.
|
||||
*/
|
||||
export type AgentToolParams<TParameters> = IsAny<TParameters> extends true ? any : Static<TParameters>;
|
||||
|
||||
// `0 extends 1 & T` is resolved too early for a `T` constrained to `object`; this form waits for the argument.
|
||||
type IsAny<T> = boolean extends (T extends never ? true : false) ? true : false;
|
||||
|
||||
/**
|
||||
* Tool definition used by the agent runtime. `parameters` is any JSON Schema object; see {@link AgentToolParams} for
|
||||
* the type `execute` receives.
|
||||
*/
|
||||
export interface AgentTool<TParameters extends object = object, TDetails = any> extends Tool<TParameters> {
|
||||
/** Human-readable label for UI display. */
|
||||
label: string;
|
||||
/**
|
||||
* Optional compatibility shim for raw tool-call arguments before schema validation.
|
||||
* Must return an object that matches `TParameters`.
|
||||
*/
|
||||
prepareArguments?: (args: unknown) => Static<TParameters>;
|
||||
prepareArguments?: (args: unknown) => AgentToolParams<TParameters>;
|
||||
/**
|
||||
* JSON Schema of `structuredContent` in successful results. Tools that declare it should always
|
||||
* set `structuredContent`.
|
||||
*/
|
||||
outputSchema?: TSchema;
|
||||
outputSchema?: object;
|
||||
/**
|
||||
* Execute the tool call. Throw on failure, or return a result with `isError: true`; do not only
|
||||
* describe the failure in `content`.
|
||||
*/
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: Static<TParameters>,
|
||||
params: AgentToolParams<TParameters>,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback<TDetails>,
|
||||
) => Promise<AgentToolResult<TDetails>>;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
type ToolResultMessage,
|
||||
type UserMessage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import { arr, obj, str } from "@earendil-works/pi-schema";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { agentLoop, agentLoopContinue, runAgentLoop, runToolCall } from "../src/agent-loop.ts";
|
||||
import { setDefaultStreamFn } from "../src/index.ts";
|
||||
|
|
@ -303,7 +303,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should handle tool calls and results", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const executed: string[] = [];
|
||||
const toolUsage = {
|
||||
input: 1,
|
||||
|
|
@ -400,7 +400,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
|
||||
// #10549
|
||||
it("records how long execute() took on the tool result, excluding hooks", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
|
|
@ -454,7 +454,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should not execute tool calls from a length-truncated assistant message", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const executed: string[] = [];
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
|
|
@ -526,7 +526,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should execute mutated beforeToolCall args without revalidation", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const executed: Array<string | number> = [];
|
||||
const tool: AgentTool<typeof toolSchema, { value: string | number }> = {
|
||||
name: "echo",
|
||||
|
|
@ -587,8 +587,8 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should prepare tool arguments for validation", async () => {
|
||||
const replaceSchema = Type.Object({ oldText: Type.String(), newText: Type.String() });
|
||||
const toolSchema = Type.Object({ edits: Type.Array(replaceSchema) });
|
||||
const replaceSchema = obj({ oldText: str(), newText: str() });
|
||||
const toolSchema = obj({ edits: arr(replaceSchema) });
|
||||
const executed: Array<Array<{ oldText: string; newText: string }>> = [];
|
||||
const tool: AgentTool<typeof toolSchema, { count: number }> = {
|
||||
name: "edit",
|
||||
|
|
@ -666,7 +666,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should emit tool_execution_end in completion order but persist tool results in source order", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
let firstResolved = false;
|
||||
let parallelObserved = false;
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
|
@ -760,7 +760,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should inject queued messages after all tool calls complete", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const executed: string[] = [];
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
|
|
@ -865,7 +865,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should force sequential execution when a tool has executionMode=sequential even with default parallel config", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
let firstResolved = false;
|
||||
let parallelObserved = false;
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
|
@ -947,7 +947,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should force sequential execution when one of multiple tools has executionMode=sequential", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const executionOrder: string[] = [];
|
||||
let releaseSlow: (() => void) | undefined;
|
||||
const slowDone = new Promise<void>((resolve) => {
|
||||
|
|
@ -1033,7 +1033,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should allow parallel execution when all tools have executionMode=parallel", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
let firstResolved = false;
|
||||
let parallelObserved = false;
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
|
@ -1106,7 +1106,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("runs finishTurn after tool-result messages and before turn_end", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
|
|
@ -1215,7 +1215,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
);
|
||||
|
||||
it("action:end skips queue polling and next-turn preparation", async () => {
|
||||
const toolSchema = Type.Object({});
|
||||
const toolSchema = obj({});
|
||||
const tool: AgentTool<typeof toolSchema, undefined> = {
|
||||
name: "noop",
|
||||
label: "Noop",
|
||||
|
|
@ -1305,7 +1305,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("lets a natural tool-result request satisfy continuation", async () => {
|
||||
const toolSchema = Type.Object({});
|
||||
const toolSchema = obj({});
|
||||
const tool: AgentTool<typeof toolSchema, undefined> = {
|
||||
name: "noop",
|
||||
label: "Noop",
|
||||
|
|
@ -1501,7 +1501,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should use prepareNextTurn snapshot before continuing", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
|
|
@ -1578,7 +1578,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("picks up steering queued during prepareNextTurn before the next request", async () => {
|
||||
const toolSchema = Type.Object({});
|
||||
const toolSchema = obj({});
|
||||
const tool: AgentTool<typeof toolSchema, undefined> = {
|
||||
name: "noop",
|
||||
label: "Noop",
|
||||
|
|
@ -1631,7 +1631,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("action:end receives finalized turn context and stops before queue polling", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const executed: string[] = [];
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
|
|
@ -1730,7 +1730,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should stop after a tool batch when every tool result sets terminate=true", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
|
|
@ -1781,7 +1781,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should stop after a blocked tool call when beforeToolCall sets terminate=true", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
let executed = false;
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
|
|
@ -1839,7 +1839,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should continue after a mixed batch with one terminating blocked call", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const executed: string[] = [];
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
|
|
@ -1897,7 +1897,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should continue after parallel tool calls when not all tool results terminate", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
|
|
@ -1962,7 +1962,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
});
|
||||
|
||||
it("should allow afterToolCall to mark a tool batch as terminating", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const toolSchema = obj({ value: str() });
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
|
|
@ -2131,13 +2131,13 @@ describe("agentLoopContinue with AgentMessage", () => {
|
|||
});
|
||||
|
||||
describe("runToolCall", () => {
|
||||
const echoSchema = Type.Object({ value: Type.String() });
|
||||
const echoSchema = obj({ value: str() });
|
||||
const echo: AgentTool<typeof echoSchema> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo tool",
|
||||
parameters: echoSchema,
|
||||
outputSchema: Type.Object({ value: Type.String() }),
|
||||
outputSchema: obj({ value: str() }),
|
||||
async execute(_toolCallId, params, _signal, onUpdate) {
|
||||
onUpdate?.({ content: [{ type: "text", text: "partial" }], details: {} });
|
||||
return {
|
||||
|
|
@ -2151,7 +2151,7 @@ describe("runToolCall", () => {
|
|||
name: "failing",
|
||||
label: "Failing",
|
||||
description: "Returns an error result",
|
||||
parameters: Type.Object({}),
|
||||
parameters: obj({}),
|
||||
async execute() {
|
||||
return { content: [{ type: "text", text: "bad" }], details: { partial: true }, isError: true };
|
||||
},
|
||||
|
|
|
|||
65
packages/agent/test/agent-tool-typebox.test.ts
Normal file
65
packages/agent/test/agent-tool-typebox.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// AgentTool params typed from a TypeBox-built parameter schema. pi-agent-core does not depend on TypeBox: the schema
|
||||
// below has TypeBox 1.x's type shapes as its d.mts declare them; pi-schema's own tests check the shapes against TypeBox.
|
||||
|
||||
import { describe, expectTypeOf, it } from "vitest";
|
||||
import type { AgentTool } from "../src/types.ts";
|
||||
|
||||
interface TString {
|
||||
"~kind": "String";
|
||||
type: "string";
|
||||
}
|
||||
|
||||
interface TLiteral<Value> {
|
||||
"~kind": "Literal";
|
||||
const: Value;
|
||||
}
|
||||
|
||||
interface TUnion<Branches> {
|
||||
"~kind": "Union";
|
||||
anyOf: Branches;
|
||||
}
|
||||
|
||||
interface TObject<Properties> {
|
||||
"~kind": "Object";
|
||||
type: "object";
|
||||
properties: Properties;
|
||||
required: string[];
|
||||
}
|
||||
|
||||
type TOptional<Schema> = Schema & { "~optional": true };
|
||||
|
||||
const parameters: TObject<{ command: TString; mode: TOptional<TUnion<[TLiteral<"fast">, TLiteral<"deep">]>> }> = {
|
||||
"~kind": "Object",
|
||||
type: "object",
|
||||
properties: {
|
||||
command: { "~kind": "String", type: "string" },
|
||||
mode: {
|
||||
"~kind": "Union",
|
||||
anyOf: [
|
||||
{ "~kind": "Literal", const: "fast" },
|
||||
{ "~kind": "Literal", const: "deep" },
|
||||
],
|
||||
"~optional": true,
|
||||
},
|
||||
},
|
||||
required: ["command"],
|
||||
};
|
||||
|
||||
type Params = { command: string; mode?: "fast" | "deep" };
|
||||
|
||||
describe("AgentTool params of a TypeBox schema", () => {
|
||||
it("are typed by the schema in execute() and prepareArguments()", () => {
|
||||
const tool: AgentTool<typeof parameters> = {
|
||||
name: "bash",
|
||||
label: "Bash",
|
||||
description: "Runs a command",
|
||||
parameters,
|
||||
async execute(_toolCallId, params) {
|
||||
expectTypeOf(params).toEqualTypeOf<Params>();
|
||||
return { content: [], details: undefined };
|
||||
},
|
||||
};
|
||||
expectTypeOf<Parameters<typeof tool.execute>[1]>().toEqualTypeOf<Params>();
|
||||
expectTypeOf<ReturnType<NonNullable<typeof tool.prepareArguments>>>().toEqualTypeOf<Params>();
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
toToolDeclaration,
|
||||
type UserMessage,
|
||||
} from "@earendil-works/pi-ai/compat";
|
||||
import { Type } from "typebox";
|
||||
import { obj } from "@earendil-works/pi-schema";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Agent,
|
||||
|
|
@ -49,7 +49,7 @@ function createTool(name: string): AgentTool {
|
|||
name,
|
||||
label: name,
|
||||
description: `${name} tool`,
|
||||
parameters: Type.Object({}),
|
||||
parameters: obj({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: name }], details: {} }),
|
||||
};
|
||||
}
|
||||
|
|
@ -146,7 +146,7 @@ describe("Agent", () => {
|
|||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo input",
|
||||
parameters: Type.Object({}),
|
||||
parameters: obj({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
|
||||
};
|
||||
const agent = new Agent({
|
||||
|
|
@ -200,7 +200,7 @@ describe("Agent", () => {
|
|||
expect(update).toEqual({
|
||||
role: "system",
|
||||
content: "",
|
||||
toolsAdded: [{ name: "second", description: "second tool", parameters: Type.Object({}) }],
|
||||
toolsAdded: [{ name: "second", description: "second tool", parameters: obj({}) }],
|
||||
toolsRemoved: [{ name: "first" }],
|
||||
timestamp: expect.any(Number),
|
||||
});
|
||||
|
|
@ -214,7 +214,7 @@ describe("Agent", () => {
|
|||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo input",
|
||||
parameters: Type.Object({}),
|
||||
parameters: obj({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
|
||||
};
|
||||
const agent = new Agent({
|
||||
|
|
@ -239,7 +239,7 @@ describe("Agent", () => {
|
|||
role: "system",
|
||||
content: "",
|
||||
sections: { skills: "<skills>x</skills>" },
|
||||
toolsAdded: [{ name: "echo", description: "Echo input", parameters: Type.Object({}) }],
|
||||
toolsAdded: [{ name: "echo", description: "Echo input", parameters: obj({}) }],
|
||||
timestamp: 1,
|
||||
});
|
||||
});
|
||||
|
|
@ -284,7 +284,7 @@ describe("Agent", () => {
|
|||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo input",
|
||||
parameters: Type.Object({}),
|
||||
parameters: obj({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
|
||||
};
|
||||
const agent = new Agent({
|
||||
|
|
@ -471,7 +471,7 @@ describe("Agent", () => {
|
|||
});
|
||||
|
||||
it("should ignore tool updates after the tool execution settles", async () => {
|
||||
const toolSchema = Type.Object({});
|
||||
const toolSchema = obj({});
|
||||
let delayedUpdate: AgentToolUpdateCallback<{ status: string }> | undefined;
|
||||
const events: AgentEvent[] = [];
|
||||
const unhandledRejections: unknown[] = [];
|
||||
|
|
@ -536,7 +536,7 @@ describe("Agent", () => {
|
|||
});
|
||||
|
||||
it("should ignore a settled parallel tool update while another tool is still running", async () => {
|
||||
const toolSchema = Type.Object({});
|
||||
const toolSchema = obj({});
|
||||
const slowStarted = createDeferred();
|
||||
const settledToolEnded = createDeferred();
|
||||
const releaseSlow = createDeferred();
|
||||
|
|
@ -858,7 +858,7 @@ describe("Agent", () => {
|
|||
});
|
||||
|
||||
it("keeps legacy prepareNextTurn signal callback behavior", async () => {
|
||||
const schema = Type.Object({});
|
||||
const schema = obj({});
|
||||
const tool: AgentTool<typeof schema> = {
|
||||
name: "noop",
|
||||
label: "Noop",
|
||||
|
|
@ -899,7 +899,7 @@ describe("Agent", () => {
|
|||
});
|
||||
|
||||
it("forwards finishTurn through AgentOptions with the active abort signal", async () => {
|
||||
const schema = Type.Object({});
|
||||
const schema = obj({});
|
||||
const tool: AgentTool<typeof schema> = {
|
||||
name: "noop",
|
||||
label: "Noop",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { type Static, Type } from "typebox";
|
||||
import { obj, type Static, str } from "@earendil-works/pi-schema";
|
||||
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
|
||||
|
||||
export interface CalculateResult extends AgentToolResult<undefined> {
|
||||
|
|
@ -16,9 +16,11 @@ export function calculate(expression: string): CalculateResult {
|
|||
}
|
||||
}
|
||||
|
||||
const calculateSchema = Type.Object({
|
||||
expression: Type.String({ description: "The mathematical expression to evaluate" }),
|
||||
});
|
||||
const calculateSchema = obj(
|
||||
{ expression: str({ description: "The mathematical expression to evaluate" }) },
|
||||
// Open, as the tool accepted extra arguments before it moved from TypeBox.
|
||||
{ open: true },
|
||||
);
|
||||
|
||||
type CalculateParams = Static<typeof calculateSchema>;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type Static, Type } from "typebox";
|
||||
import { obj, opt, type Static, str } from "@earendil-works/pi-schema";
|
||||
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
|
||||
|
||||
export interface GetCurrentTimeResult extends AgentToolResult<{ utcTimestamp: number }> {}
|
||||
|
|
@ -27,11 +27,11 @@ export async function getCurrentTime(timezone?: string): Promise<GetCurrentTimeR
|
|||
};
|
||||
}
|
||||
|
||||
const getCurrentTimeSchema = Type.Object({
|
||||
timezone: Type.Optional(
|
||||
Type.String({ description: "Optional timezone (e.g., 'America/New_York', 'Europe/London')" }),
|
||||
),
|
||||
});
|
||||
const getCurrentTimeSchema = obj(
|
||||
{ timezone: opt(str({ description: "Optional timezone (e.g., 'America/New_York', 'Europe/London')" })) },
|
||||
// Open, as the tool accepted extra arguments before it moved from TypeBox.
|
||||
{ open: true },
|
||||
);
|
||||
|
||||
type GetCurrentTimeParams = Static<typeof getCurrentTimeSchema>;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,41 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode Go Claude Haiku 5.5 failing with a 400 (`Invalid signature in thinking block ... bound to a different conversation`) after the tool list, system prompt, or earlier history changed, which left the session failing on every retry. OpenCode Go and Zen Claude 5.5 models, Opus 5, and Fable 5.1 now use per-message effort and send `thinking.block_binding: drop_block`, like the Anthropic provider ([#10954](https://github.com/earendil-works/pi/issues/10954))
|
||||
- Fixed Anthropic non-strict tool schemas dropping root `$defs` and `definitions`, which left property `$ref`s pointing at nothing. Claude then produced wrong arguments for such tools (common in MCP servers), for example a JSON string instead of an object, and some Anthropic-compatible gateways rejected the request outright ([#9557](https://github.com/earendil-works/pi/issues/9557))
|
||||
- Fixed OpenRouter rejecting requests with a 400 (`This endpoint's maximum context length is ...`) for models whose output limit is close to the context window, such as DeepSeek flash. OpenRouter counts the input with its own tokenizer, which can be several percent higher than the provider's count, and pi left only 4,096 tokens spare. pi then treated the 400 as a context overflow and compacted the session, even at around 20% of the window. The default output limit now leaves 15% of the estimated input unused, and near a full window at most 32,768 output tokens are requested ([#10497](https://github.com/earendil-works/pi/issues/10497))
|
||||
|
||||
## [1.5.0] - 2026-11-02
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- The main entry no longer re-exports TypeBox (`Type`, `Static`, `TSchema`), and `typebox` is no longer a dependency. `Tool.parameters` is any JSON Schema object (`Tool<TParameters extends object>`): `@earendil-works/pi-schema`'s builders, which also type the arguments through pi-schema's `Static`, TypeBox schemas, or plain objects. `validateToolCall()`/`validateToolArguments()` validate with pi-schema (`compile(schema, { coerce: true }).parse()`): every schema gets the same coercion, the superset of TypeBox's `Value.Convert` rules (which only applied to TypeBox-built schemas) and pi-ai's own, so plain JSON Schema tools (MCP tools) now also get, for example, `null` to `"null"` for a required string, `"3.5"` to 3 for an integer and a single value wrapped into an array. The error text lists pi-schema's messages (`- edits.0.oldText: missing`, `- limit: expected number, got string`) instead of TypeBox's. `StringEnum()` returns a pi-schema `Schema<T>` with the same JSON (`{ type: "string", enum }`). The model and compat schemas (`providers/model-schema`, `providers/compat-schema`) are built with pi-schema, with the same JSON Schema, and their types come from pi-schema's `Static`; `ProviderCompat` is exported. Migration:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
import { Type, type Static, StringEnum } from "@earendil-works/pi-ai";
|
||||
const params = Type.Object({ path: Type.String(), mode: StringEnum(["a", "b"]) });
|
||||
type Params = Static<typeof params>;
|
||||
|
||||
// After: pi-schema builders (closed objects; pass { open: true } to allow other properties)
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import { obj, type Static, str } from "@earendil-works/pi-schema";
|
||||
const params = obj({ path: str(), mode: StringEnum(["a", "b"]) });
|
||||
type Params = Static<typeof params>;
|
||||
|
||||
// Or keep TypeBox, imported from typebox itself (its schemas are JSON Schema)
|
||||
import { Type, type Static } from "typebox";
|
||||
```
|
||||
- Tool arguments are validated as JSON Schema only; nothing runs through TypeBox's own validator any more, so TypeBox features outside JSON Schema no longer apply to tool arguments:
|
||||
- `Type.Refine()` refinements are not run: arguments they would reject are accepted.
|
||||
- `Type.BigInt()`, `Type.Undefined()` and `Type.Void()` reject every JSON argument (TypeBox converted to them).
|
||||
- Codecs (`Type.Codec()`) do not decode: the tool receives the encoded JSON value.
|
||||
- Formats registered with TypeBox's `Format.Set()` are not checked; the standard formats plus `url` and `json-pointer-uri-fragment` are.
|
||||
- Property names are names, not regular expressions: `"a.b"` no longer also converts the argument `axb`.
|
||||
- `additionalProperties` converts only undeclared properties; TypeBox also converted declared ones with it when an object had more than one property.
|
||||
- A `$ref` that names nothing (a missing definition, an external document) rejects only arguments at its place (`- t: unresolved reference #/definitions/Missing`) instead of failing every call of the tool, as TypeBox's rejection did before.
|
||||
- `appendGrammarToolInputJsonDelta()` (`api/constrained-sampling`) takes the input delta instead of the whole input so far and no longer closes the input; close it with the new `closeGrammarToolInputJson()` ([#9265](https://github.com/earendil-works/pi/issues/9265))
|
||||
|
||||
### Added
|
||||
|
|
@ -11,6 +44,8 @@
|
|||
- Added optional `openBrowserOnConfirm` and `copyCodeOnConfirm` to `device_code` auth events and `OAuthDeviceCodeInfo`. Both default to `true`; set them to `false` to stop clients from offering to open the verification page or copy the code ([#9282](https://github.com/earendil-works/pi/issues/9282), [#9301](https://github.com/earendil-works/pi/pull/9301))
|
||||
- Added `StreamingJsonParser`, an incremental, tolerant parser for JSON text that arrives in pieces (each piece is read once; snapshots of the partial value never change once returned), and `ToolCallArgumentsStream`, which every provider now uses for streamed tool-call arguments; its `append()` also returns the changes each piece made (`JsonChange`: a value set at a path, or text appended to a string), for writers that keep their own copy ([#9265](https://github.com/earendil-works/pi/issues/9265))
|
||||
- Added GPT-6.1 Terra to the OpenAI, Azure OpenAI Responses, and OpenAI Codex providers with official pricing ($1 input, $0.05 cached input, $1.25 cache writes, $5 output, long-context tier above 272k), tool search, and mid-conversation effort changes. Unlike GPT-6.1 Sol it supports `off` (reasoning effort `none`); Codex serves it with a 500K context window
|
||||
- Added `Usage.cost.totalFromProvider` to distinguish provider-reported billed totals from catalog estimates. Missing or `false` means an estimate; combined usage is marked provider-reported only when every part is ([#11178](https://github.com/earendil-works/pi/pull/11178))
|
||||
- Added `responseModel` to pi-messages terminal events, preserving the concrete model reported by providers such as Radius when a virtual model is requested ([#11265](https://github.com/earendil-works/pi/pull/11265))
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
@ -32,6 +67,12 @@
|
|||
- Fixed GPT-5.4 and GPT-5.5 failing with `Missing namespace for function_call` after switching from another model that called tools. On the OpenAI and OpenCode providers they now declare their tools in the top-level tool list instead of loading them through `additional_tools` or tool search ([#11415](https://github.com/earendil-works/pi/issues/11415))
|
||||
- Fixed Bedrock requests failing when `strict: "prefer"` tools exceed Claude's strict tool limits, such as more than 16 optional parameters across all strict tools; requests now retry once with preferred tools non-strict while keeping `strict: "require"` tools strict, as the Anthropic provider already does ([#11416](https://github.com/earendil-works/pi/issues/11416))
|
||||
- Fixed OpenRouter's `~anthropic/claude-*-latest` aliases losing Claude's earlier thinking after each tool call. They used the Chat Completions API, where pi dropped the thinking signature, and OpenRouter then ignored the unsigned thinking. They now use the Anthropic Messages API with adaptive thinking, like the versioned Claude models ([#9534](https://github.com/earendil-works/pi/issues/9534))
|
||||
- Fixed OpenRouter cost totals using catalog estimates instead of reported billing for Chat Completions and Anthropic Messages. BYOK totals include both OpenRouter charges and reported upstream costs; component costs remain catalog estimates ([#9980](https://github.com/earendil-works/pi/issues/9980))
|
||||
- Fixed OpenCode Zen and OpenCode Go requests using shared endpoints instead of their protocol-specific OpenAI, Anthropic, Google, and System One endpoints ([#11434](https://github.com/earendil-works/pi/pull/11434) by [@vimtor](https://github.com/vimtor))
|
||||
- Fixed Zhipu/Z.ai usage-limit errors (code `1308`) being treated as transient HTTP 429 errors, causing automatic retries that cannot succeed until the usage cap resets ([#11537](https://github.com/earendil-works/pi/issues/11537))
|
||||
- Fixed LiteLLM's `ContextWindowExceededError` not being recognized as context overflow ([#11492](https://github.com/earendil-works/pi/issues/11492))
|
||||
- Fixed Gemini tool schemas sending unsupported `const` keywords. They are now converted to single-value `enum` schemas without rewriting literal data in defaults, examples, or enum values, or losing properties named `__proto__` ([#11308](https://github.com/earendil-works/pi/issues/11308))
|
||||
- Fixed the faux provider counting simulated cache-write tokens as both input and cache writes
|
||||
|
||||
## [1.4.0] - 2026-10-21
|
||||
|
||||
|
|
|
|||
|
|
@ -103,15 +103,16 @@ Unified LLM API with provider collections, automatic auth resolution, token and
|
|||
npm install @earendil-works/pi-ai
|
||||
```
|
||||
|
||||
TypeBox exports are re-exported from `@earendil-works/pi-ai`: `Type`, `Static`, and `TSchema`.
|
||||
Tool parameters are JSON Schema. The examples build them with `@earendil-works/pi-schema`, which also types the arguments (`npm install @earendil-works/pi-schema`); TypeBox schemas and plain JSON Schema objects work as well.
|
||||
|
||||
## Quick Start
|
||||
|
||||
You build a `Models` collection of providers and stream through it. The quickest start registers every built-in provider; apps that care about bundle size register individual providers instead (see [Provider Factories](#provider-factories) and [Bundling and Tree Shaking](#bundling-and-tree-shaking)).
|
||||
|
||||
```typescript
|
||||
import { Type, type Context, type Tool } from '@earendil-works/pi-ai';
|
||||
import { type Context, type Tool } from '@earendil-works/pi-ai';
|
||||
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
|
||||
import { obj, opt, str } from '@earendil-works/pi-schema';
|
||||
|
||||
// A Models collection with every built-in provider registered
|
||||
const models = builtinModels();
|
||||
|
|
@ -119,12 +120,12 @@ const models = builtinModels();
|
|||
// Sync lookup against the collection
|
||||
const model = models.getModel('openai', 'gpt-4o-mini')!;
|
||||
|
||||
// Define tools with TypeBox schemas for type safety and validation
|
||||
// Define tools with JSON Schema; pi-schema's builders also type the arguments
|
||||
const tools: Tool[] = [{
|
||||
name: 'get_time',
|
||||
description: 'Get the current time',
|
||||
parameters: Type.Object({
|
||||
timezone: Type.Optional(Type.String({ description: 'Optional timezone (e.g., America/New_York)' }))
|
||||
parameters: obj({
|
||||
timezone: opt(str({ description: 'Optional timezone (e.g., America/New_York)' }))
|
||||
})
|
||||
}];
|
||||
|
||||
|
|
@ -538,34 +539,35 @@ Amazon Bedrock resolves ambient AWS credentials (`AWS_PROFILE`, access key pairs
|
|||
|
||||
## Tools
|
||||
|
||||
Tools enable LLMs to interact with external systems. This library uses TypeBox schemas for type-safe tool definitions with automatic validation using TypeBox's built-in validator and value conversion utilities. TypeBox schemas can be serialized and deserialized as plain JSON, making them ideal for distributed systems.
|
||||
Tools enable LLMs to interact with external systems. A tool's `parameters` is a JSON Schema object: built with `@earendil-works/pi-schema`'s builders (which also type the arguments through its `Static`), with TypeBox, or written by hand. `validateToolCall()` and `validateToolArguments()` validate with pi-schema, coercing loosely typed arguments first (numeric strings, `"true"`, a single value for an array, `null` for an omitted optional property). Schemas are plain JSON, so they serialize with the rest of a context.
|
||||
|
||||
### Defining Tools
|
||||
|
||||
```typescript
|
||||
import { Type, type Tool, StringEnum } from '@earendil-works/pi-ai';
|
||||
import { type Tool, StringEnum } from '@earendil-works/pi-ai';
|
||||
import { arr, obj, str } from '@earendil-works/pi-schema';
|
||||
|
||||
// Define tool parameters with TypeBox
|
||||
// Define tool parameters with pi-schema's builders
|
||||
const weatherTool: Tool = {
|
||||
name: 'get_weather',
|
||||
description: 'Get current weather for a location',
|
||||
parameters: Type.Object({
|
||||
location: Type.String({ description: 'City name or coordinates' }),
|
||||
parameters: obj({
|
||||
location: str({ description: 'City name or coordinates' }),
|
||||
units: StringEnum(['celsius', 'fahrenheit'], { default: 'celsius' })
|
||||
})
|
||||
};
|
||||
|
||||
// Note: For Google API compatibility, use StringEnum helper instead of Type.Enum
|
||||
// Type.Enum generates anyOf/const patterns that Google doesn't support
|
||||
// Note: StringEnum gives { type: "string", enum: [...] }, which every provider accepts,
|
||||
// including Google's, which rejects anyOf/const patterns
|
||||
|
||||
const bookMeetingTool: Tool = {
|
||||
name: 'book_meeting',
|
||||
description: 'Schedule a meeting',
|
||||
parameters: Type.Object({
|
||||
title: Type.String({ minLength: 1 }),
|
||||
startTime: Type.String({ format: 'date-time' }),
|
||||
endTime: Type.String({ format: 'date-time' }),
|
||||
attendees: Type.Array(Type.String({ format: 'email' }), { minItems: 1 })
|
||||
parameters: obj({
|
||||
title: str({ minLength: 1 }),
|
||||
startTime: str({ format: 'date-time' }),
|
||||
endTime: str({ format: 'date-time' }),
|
||||
attendees: arr(str({ format: 'email' }), { minItems: 1 })
|
||||
})
|
||||
};
|
||||
```
|
||||
|
|
@ -578,10 +580,7 @@ Tools can opt in to provider-side constrained sampling. For JSON-schema tools, `
|
|||
const strictTool: Tool = {
|
||||
name: 'edit_file',
|
||||
description: 'Edit a file',
|
||||
parameters: Type.Object({
|
||||
path: Type.String(),
|
||||
content: Type.String()
|
||||
}, { additionalProperties: false }),
|
||||
parameters: obj({ path: str(), content: str() }), // obj() is closed: additionalProperties: false
|
||||
constrainedSampling: { type: 'json_schema', strict: 'prefer' }
|
||||
};
|
||||
```
|
||||
|
|
@ -592,9 +591,7 @@ Strict JSON-schema constrained sampling is supported for OpenAI, Anthropic, supp
|
|||
const patchTool: Tool = {
|
||||
name: 'apply_patch',
|
||||
description: 'Apply a patch',
|
||||
parameters: Type.Object({
|
||||
input: Type.String()
|
||||
}, { additionalProperties: false }),
|
||||
parameters: obj({ input: str() }),
|
||||
constrainedSampling: {
|
||||
type: 'grammar',
|
||||
variants: {
|
||||
|
|
@ -698,7 +695,7 @@ for await (const event of s) {
|
|||
|
||||
### Validating Tool Arguments
|
||||
|
||||
When implementing your own tool execution loop, use `validateToolCall` to validate arguments before passing them to your tools:
|
||||
When implementing your own tool execution loop, use `validateToolCall` to validate arguments before passing them to your tools. It coerces loosely typed arguments with pi-schema (`compile(schema, { coerce: true }).parse()`), returns the coerced arguments, and throws an error listing pi-schema's errors (`path: message`) followed by the arguments it received:
|
||||
|
||||
```typescript
|
||||
import { validateToolCall, type Tool } from '@earendil-works/pi-ai';
|
||||
|
|
@ -894,7 +891,7 @@ console.log(model.output); // ['image'] or ['image', 'text']
|
|||
|
||||
## Classification
|
||||
|
||||
Classifier models consume structured JSON state, and optionally images, and answer one or more typed questions. They do not use chat or image-generation APIs. TypeSafe's Jev model, Cloudflare's Clef models, and OpenAI's GPT-6 Luna are available from these built-in providers:
|
||||
Classifier models consume structured JSON state, and optionally images, and answer one or more typed questions. They do not use chat or image-generation APIs. TypeSafe's Jev model, Cloudflare's Clef models, OpenAI's GPT-6 Luna, and Microsoft-Decision-1 are available from these built-in providers:
|
||||
|
||||
| Provider | Model IDs | Auth |
|
||||
| --- | --- | --- |
|
||||
|
|
@ -904,6 +901,7 @@ Classifier models consume structured JSON state, and optionally images, and answ
|
|||
| `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` |
|
||||
| `github-copilot` | `microsoft-decision-1` | `COPILOT_GITHUB_TOKEN` or GitHub Copilot OAuth |
|
||||
|
||||
```typescript
|
||||
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
|
||||
|
|
@ -1728,8 +1726,8 @@ models.setProvider(openaiProvider());
|
|||
|
||||
Rules:
|
||||
|
||||
- `@earendil-works/pi-ai/models` exports the model runtime (`createModels`, `createProvider`, model helpers, and their types) without TypeBox, built-in catalogs, or SDK implementations. Other types can still use `import type` from the root.
|
||||
- `@earendil-works/pi-ai` is the core entrypoint and does not import built-in catalogs, real provider factories, or SDK implementations, but it eagerly imports TypeBox and schema validation. Unbundled Node scripts do not tree-shake its unused exports; prefer `./models`, `./providers/faux`, and specific `./utils/*` subpaths when those are all you need.
|
||||
- `@earendil-works/pi-ai/models` exports the model runtime (`createModels`, `createProvider`, model helpers, and their types) without schema validation, built-in catalogs, or SDK implementations. Other types can still use `import type` from the root.
|
||||
- `@earendil-works/pi-ai` is the core entrypoint and does not import built-in catalogs, real provider factories, or SDK implementations, but it eagerly imports schema validation (pi-schema). Unbundled Node scripts do not tree-shake its unused exports; prefer `./models`, `./providers/faux`, and specific `./utils/*` subpaths when those are all you need.
|
||||
- `@earendil-works/pi-ai/providers/<provider>` imports that provider's catalog and lazy API wrapper only.
|
||||
- `@earendil-works/pi-ai/providers/all` imports every built-in provider factory and all catalogs. Use it only when you want the full built-in set.
|
||||
- With code splitting, provider SDKs stay in lazy chunks and load on first request.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Api, Model, ThinkingLevelMap } from "../../src/types.ts";
|
||||
import type { Api, ClassifierModel, Model, ThinkingLevelMap } from "../../src/types.ts";
|
||||
import { addMissing, type ChatModel, defineProvider, mergeCompat, mergeThinking, modelsDevModels } from "../define.ts";
|
||||
import { claude55ThinkingLevels, supportsClaudeMidConvoSystemMessages } from "../families/anthropic.ts";
|
||||
import {
|
||||
|
|
@ -67,6 +67,22 @@ const GPT_6_LUNA: Model<"openai-responses"> = {
|
|||
headers: { ...STATIC_HEADERS },
|
||||
};
|
||||
|
||||
// The authenticated Copilot catalog lists this decision model on /v1/systemone (checked 2026-11-02) without
|
||||
// a context window or price, and GitHub's Copilot pricing page does not list it. These are Azure's values,
|
||||
// which OpenRouter and Vercel AI Gateway also list.
|
||||
const MICROSOFT_DECISION_1: ClassifierModel<"typesafe-system-one"> = {
|
||||
type: "classifier",
|
||||
id: "microsoft-decision-1",
|
||||
name: "Microsoft-Decision-1",
|
||||
api: "typesafe-system-one",
|
||||
provider: "github-copilot",
|
||||
baseUrl: BASE_URL,
|
||||
input: ["text"],
|
||||
cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32768,
|
||||
headers: { ...STATIC_HEADERS },
|
||||
};
|
||||
|
||||
export default defineProvider({
|
||||
id: "github-copilot",
|
||||
models: (ctx) =>
|
||||
|
|
@ -94,6 +110,7 @@ export default defineProvider({
|
|||
}),
|
||||
[GPT_6_LUNA],
|
||||
).map((model) => (EXTENDED_CONTEXT_MODEL_IDS.has(model.id) ? { ...model, contextWindow: 1000000 } : model)),
|
||||
classifiers: () => [MICROSOFT_DECISION_1],
|
||||
compat: { "openai-completions": { supportsStrictMode: true } },
|
||||
finalize: (model) => {
|
||||
if (model.id.startsWith("gpt-5")) mergeThinking(model, { minimal: "low" });
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ import {
|
|||
modelsDevModels,
|
||||
type ProviderContext,
|
||||
} from "../define.ts";
|
||||
import { supportsClaudeMidConvoSystemMessages } from "../families/anthropic.ts";
|
||||
import {
|
||||
enableClaudeMidConvoEffort,
|
||||
supportsClaudeMidConvoEffort,
|
||||
supportsClaudeMidConvoSystemMessages,
|
||||
} from "../families/anthropic.ts";
|
||||
import { googleThinkingLevels } from "../families/google.ts";
|
||||
import { isKimiK3FamilyId } from "../families/kimi.ts";
|
||||
import { enableOpenAiGrammarTools, enableOpenAiMidConvoSystemMessages } from "../families/openai.ts";
|
||||
|
|
@ -162,6 +166,9 @@ export function finalizeOpencodeModel(model: ChatModel): void {
|
|||
if (model.provider === "opencode" && supportsClaudeMidConvoSystemMessages(model.id)) {
|
||||
mergeCompat(messagesModel, { supportsMidConvoSystemMessages: true });
|
||||
}
|
||||
// Zen and Go forward per-message effort and `thinking.block_binding`. Without
|
||||
// `drop_block`, Go rejects replayed thinking after the system prompt or tools change.
|
||||
if (supportsClaudeMidConvoEffort(model.id)) enableClaudeMidConvoEffort(messagesModel);
|
||||
// OpenCode Qwen 3.8 Flash emits and accepts thinking blocks with empty signatures.
|
||||
if (model.id === "qwen3.8-flash") mergeCompat(messagesModel, { allowEmptySignature: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-ai",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -56,27 +56,27 @@
|
|||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"clean": "node ../../scripts/package-files.mjs clean dist",
|
||||
"generate-models": "node catalog/generate.ts --strict",
|
||||
"hydrate-model-data": "node catalog/generate.ts --strict --data-only",
|
||||
"generate-model-catalog": "node catalog/generate.ts --strict --json-only --json-output ../../.artifacts/model-catalog",
|
||||
"check:model-data": "node scripts/check-model-data.ts",
|
||||
"build": "npm run generate-models && npm run build:offline",
|
||||
"build:offline": "npm run check:model-data && tsc -p tsconfig.build.json && shx rm -rf dist/providers/data && shx cp -r src/providers/data dist/providers/data",
|
||||
"build:offline": "npm run check:model-data && tsc -p tsconfig.build.json && node ../../scripts/package-files.mjs clean dist/providers/data && node ../../scripts/package-files.mjs copy src/providers/data dist/providers",
|
||||
"test": "vitest --run",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.129.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
|
||||
"@earendil-works/pi-telemetry": "1.4.0",
|
||||
"@earendil-works/pi-schema": "1.5.0",
|
||||
"@earendil-works/pi-telemetry": "1.5.0",
|
||||
"@google/genai": "2.21.0",
|
||||
"@smithy/node-http-handler": "4.12.1",
|
||||
"http-proxy-agent": "9.1.0",
|
||||
"https-proxy-agent": "9.1.0",
|
||||
"openai": "7.19.0",
|
||||
"partial-json": "0.1.7",
|
||||
"typebox": "1.3.27"
|
||||
"partial-json": "0.1.7"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
|
|
@ -101,6 +101,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "22.19.19",
|
||||
"canvas": "3.2.3",
|
||||
"typebox": "1.3.27",
|
||||
"vitest": "4.1.12"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1896,11 +1896,20 @@ function convertTools(
|
|||
? undefined
|
||||
: resolveJsonSchemaStrictSampling(tool, supportsStrictTools, isAnthropicStrictUnsupportedKeyword);
|
||||
const parameters = getJsonSchemaToolParameters(tool, strict);
|
||||
const schema = parameters as { properties?: unknown; required?: string[] };
|
||||
const schema = parameters as {
|
||||
properties?: unknown;
|
||||
required?: string[];
|
||||
$defs?: unknown;
|
||||
definitions?: unknown;
|
||||
};
|
||||
// Keep shared definitions so property $refs still resolve. Other root keywords stay out:
|
||||
// Anthropic rejects root anyOf/oneOf/allOf with a 400.
|
||||
const legacyInputSchema = {
|
||||
type: "object" as const,
|
||||
properties: schema.properties ?? {},
|
||||
required: schema.required ?? [],
|
||||
...(schema.$defs === undefined ? {} : { $defs: schema.$defs }),
|
||||
...(schema.definitions === undefined ? {} : { definitions: schema.definitions }),
|
||||
};
|
||||
const inputSchema =
|
||||
strict === true
|
||||
|
|
|
|||
|
|
@ -13,12 +13,25 @@ import type {
|
|||
import { estimateContextTokens } from "../utils/estimate.ts";
|
||||
|
||||
const CONTEXT_SAFETY_TOKENS = 4096;
|
||||
// OpenRouter counts the input with its own tokenizer and rejects input + max_tokens above the
|
||||
// window before routing. It counted prose up to 12% above the provider's count (#10497).
|
||||
const CONTEXT_SAFETY_RATIO = 0.15;
|
||||
// The 15% margin is kept only while it leaves at least this much output. Otherwise (a nearly full
|
||||
// large window, or any small window such as a 32k local model) only the fixed margin is kept, and
|
||||
// output is capped at this size so an OpenRouter overcount still fits for a while before compaction.
|
||||
const MIN_OUTPUT_WITH_RATIO_MARGIN = 32768;
|
||||
const MIN_MAX_TOKENS = 1;
|
||||
|
||||
/** Fit maxTokens into the context window, leaving room for the input being counted higher than estimated. */
|
||||
export function clampMaxTokensToContext(model: Model<Api>, context: TranscriptContext, maxTokens: number): number {
|
||||
if (model.contextWindow <= 0) return Math.max(MIN_MAX_TOKENS, maxTokens);
|
||||
const available = model.contextWindow - estimateContextTokens(context).tokens - CONTEXT_SAFETY_TOKENS;
|
||||
return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available));
|
||||
const input = estimateContextTokens(context).tokens;
|
||||
const margin = Math.max(CONTEXT_SAFETY_TOKENS, Math.ceil(input * CONTEXT_SAFETY_RATIO));
|
||||
let room = model.contextWindow - input - margin;
|
||||
if (room < MIN_OUTPUT_WITH_RATIO_MARGIN) {
|
||||
room = Math.min(MIN_OUTPUT_WITH_RATIO_MARGIN, model.contextWindow - input - CONTEXT_SAFETY_TOKENS);
|
||||
}
|
||||
return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, room));
|
||||
}
|
||||
|
||||
export function resolveSamplingParams(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
export type { Static, TSchema } from "typebox";
|
||||
export { Type } from "typebox";
|
||||
|
||||
// Core only, side-effect free: no generated catalogs, no provider factories,
|
||||
// no api-registry, no OAuth implementations, no compat. Provider factories
|
||||
// live under "@earendil-works/pi-ai/providers/*", API implementations under
|
||||
|
|
@ -42,9 +39,9 @@ export * from "./utils/json-parse.ts";
|
|||
export * from "./utils/overflow.ts";
|
||||
export * from "./utils/retry.ts";
|
||||
export * from "./utils/streaming-json.ts";
|
||||
export * from "./utils/string-enum.ts";
|
||||
export { contentText, getSystemMessageText, renderSystemMessageUpdate } from "./utils/text.ts";
|
||||
export * from "./utils/transcript.ts";
|
||||
export * from "./utils/typebox-helpers.ts";
|
||||
export { uuidv7 } from "./utils/uuid.ts";
|
||||
export * from "./utils/validation.ts";
|
||||
export { PI_AI_VERSION } from "./utils/version.ts";
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,28 @@
|
|||
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
|
||||
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
||||
import { openAIResponsesApi } from "../api/openai-responses.lazy.ts";
|
||||
import { typesafeSystemOneApi } from "../api/typesafe-system-one.lazy.ts";
|
||||
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
|
||||
import { loadGitHubCopilotOAuth } from "../auth/oauth/load.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { GITHUB_COPILOT_MODELS } from "./github-copilot.models.ts";
|
||||
import type { ProviderClassifier } from "../types.ts";
|
||||
import { GITHUB_COPILOT_CLASSIFIER_MODELS, GITHUB_COPILOT_MODELS } from "./github-copilot.models.ts";
|
||||
|
||||
export function githubCopilotProvider(): Provider<"anthropic-messages" | "openai-completions" | "openai-responses"> {
|
||||
return createProvider({
|
||||
type GitHubCopilotApi = "anthropic-messages" | "openai-completions" | "openai-responses";
|
||||
|
||||
/**
|
||||
* Copilot serves System One at `/v1/systemone`, while its chat APIs have no version prefix.
|
||||
* The login replaces the catalog base URL with the account's endpoint, so the prefix is added per request.
|
||||
*/
|
||||
function copilotSystemOne(classifier: ProviderClassifier): ProviderClassifier {
|
||||
return {
|
||||
classify: (model, context, options) =>
|
||||
classifier.classify({ ...model, baseUrl: `${model.baseUrl.replace(/\/+$/u, "")}/v1` }, context, options),
|
||||
};
|
||||
}
|
||||
|
||||
export function githubCopilotProvider(): Provider<GitHubCopilotApi> {
|
||||
return createProvider<GitHubCopilotApi>({
|
||||
id: "github-copilot",
|
||||
name: "GitHub Copilot",
|
||||
baseUrl: "https://api.individual.githubcopilot.com",
|
||||
|
|
@ -15,7 +30,7 @@ export function githubCopilotProvider(): Provider<"anthropic-messages" | "openai
|
|||
apiKey: envApiKeyAuth("GitHub Copilot token", ["COPILOT_GITHUB_TOKEN"]),
|
||||
oauth: lazyOAuth({ name: "GitHub Copilot", isSubscription: true, load: loadGitHubCopilotOAuth }),
|
||||
},
|
||||
models: Object.values(GITHUB_COPILOT_MODELS),
|
||||
models: [...Object.values(GITHUB_COPILOT_MODELS), ...Object.values(GITHUB_COPILOT_CLASSIFIER_MODELS)],
|
||||
filterModels: (models, credential) => {
|
||||
if (credential?.type !== "oauth") return models;
|
||||
const availableModelIds = credential.availableModelIds;
|
||||
|
|
@ -30,5 +45,6 @@ export function githubCopilotProvider(): Provider<"anthropic-messages" | "openai
|
|||
"openai-completions": openAICompletionsApi(),
|
||||
"openai-responses": openAIResponsesApi(),
|
||||
},
|
||||
classifiers: { "typesafe-system-one": copilotSystemOne(typesafeSystemOneApi()) },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
import { type Static, type TSchemaOptions, Type } from "typebox";
|
||||
import {
|
||||
type Annotations,
|
||||
arr,
|
||||
int,
|
||||
nul,
|
||||
num,
|
||||
oneOf,
|
||||
opt,
|
||||
type Static,
|
||||
type StaticObject,
|
||||
str,
|
||||
union,
|
||||
} from "@earendil-works/pi-schema";
|
||||
import { openObject as object } from "./schema-builders.ts";
|
||||
|
||||
export const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
||||
export const MODEL_THINKING_LEVELS = ["off", ...THINKING_LEVELS] as const;
|
||||
|
|
@ -6,19 +19,31 @@ export const MODEL_INPUT_MODALITIES = ["text", "image"] as const;
|
|||
export const CACHE_RETENTIONS = ["none", "short", "long"] as const;
|
||||
export const MODEL_PROMPT_CACHE_RETENTIONS = ["short", "long"] as const;
|
||||
|
||||
export const ThinkingLevelSchema = Type.Enum(THINKING_LEVELS);
|
||||
export const ModelThinkingLevelSchema = Type.Enum(MODEL_THINKING_LEVELS);
|
||||
export const ModelInputModalitySchema = Type.Enum(MODEL_INPUT_MODALITIES);
|
||||
export const CacheRetentionSchema = Type.Enum(CACHE_RETENTIONS);
|
||||
export const ThinkingLevelSchema = oneOf(THINKING_LEVELS);
|
||||
export const ModelThinkingLevelSchema = oneOf(MODEL_THINKING_LEVELS);
|
||||
export const ModelInputModalitySchema = oneOf(MODEL_INPUT_MODALITIES);
|
||||
export const CacheRetentionSchema = oneOf(CACHE_RETENTIONS);
|
||||
|
||||
const ThinkingLevelMapValueSchema = Type.Union([Type.String(), Type.Null()]);
|
||||
export const ThinkingLevelMapSchema = Type.Partial(Type.Record(ModelThinkingLevelSchema, ThinkingLevelMapValueSchema), {
|
||||
description:
|
||||
"Provider value sent for each thinking level. null marks a level unsupported. Levels the model does not map use the provider's default value through high; xhigh and max are unsupported.",
|
||||
});
|
||||
const ThinkingLevelMapValueSchema = union([str(), nul()]);
|
||||
export const ThinkingLevelMapSchema = object(
|
||||
{
|
||||
off: opt(ThinkingLevelMapValueSchema),
|
||||
minimal: opt(ThinkingLevelMapValueSchema),
|
||||
low: opt(ThinkingLevelMapValueSchema),
|
||||
medium: opt(ThinkingLevelMapValueSchema),
|
||||
high: opt(ThinkingLevelMapValueSchema),
|
||||
xhigh: opt(ThinkingLevelMapValueSchema),
|
||||
max: opt(ThinkingLevelMapValueSchema),
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Provider value sent for each thinking level. null marks a level unsupported. Levels the model does not map use the provider's default value through high; xhigh and max are unsupported.",
|
||||
},
|
||||
);
|
||||
|
||||
export const ModelPromptCacheSchema = Type.Partial(
|
||||
Type.Record(Type.Enum(MODEL_PROMPT_CACHE_RETENTIONS), Type.Number({ exclusiveMinimum: 0 })),
|
||||
const PromptCacheLifetimeSchema = num({ exclusiveMinimum: 0 });
|
||||
export const ModelPromptCacheSchema = object(
|
||||
{ short: opt(PromptCacheLifetimeSchema), long: opt(PromptCacheLifetimeSchema) },
|
||||
{
|
||||
description:
|
||||
"Best-effort prompt cache lifetime in seconds for the short and long retention tiers. Pi warms only tiers with a known lifetime.",
|
||||
|
|
@ -26,63 +51,54 @@ export const ModelPromptCacheSchema = Type.Partial(
|
|||
);
|
||||
|
||||
const ModelCostRatesProperties = {
|
||||
input: Type.Number({ description: "Input cost in USD per million tokens." }),
|
||||
output: Type.Number({ description: "Output cost in USD per million tokens." }),
|
||||
cacheRead: Type.Number({ description: "Cache-read cost in USD per million tokens." }),
|
||||
cacheWrite: Type.Number({ description: "Cache-write cost in USD per million tokens." }),
|
||||
input: num({ description: "Input cost in USD per million tokens." }),
|
||||
output: num({ description: "Output cost in USD per million tokens." }),
|
||||
cacheRead: num({ description: "Cache-read cost in USD per million tokens." }),
|
||||
cacheWrite: num({ description: "Cache-write cost in USD per million tokens." }),
|
||||
};
|
||||
|
||||
export const ModelCostRatesSchema = Type.Object(ModelCostRatesProperties);
|
||||
export const ModelCostTierSchema = Type.Object({
|
||||
inputTokensAbove: Type.Number({
|
||||
export const ModelCostRatesSchema = object(ModelCostRatesProperties);
|
||||
export const ModelCostTierSchema = object({
|
||||
inputTokensAbove: num({
|
||||
description: "Use this tier when total request input exceeds this token count.",
|
||||
}),
|
||||
...ModelCostRatesProperties,
|
||||
});
|
||||
export const ModelCostSchema = Type.Object({
|
||||
export const ModelCostSchema = object({
|
||||
...ModelCostRatesProperties,
|
||||
tiers: Type.Optional(
|
||||
Type.Array(ModelCostTierSchema, {
|
||||
tiers: opt(
|
||||
arr(ModelCostTierSchema, {
|
||||
description: "Request-wide pricing tiers. The highest matching input threshold applies to the full request.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
function modelImageResizeOptions(options?: TSchemaOptions) {
|
||||
return Type.Object(
|
||||
{
|
||||
maxWidth: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
maxHeight: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
maxBytes: Type.Optional(
|
||||
Type.Integer({ minimum: 1, description: "Maximum base64-encoded payload size in bytes." }),
|
||||
),
|
||||
jpegQuality: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
||||
},
|
||||
options,
|
||||
);
|
||||
const ModelImageResizeProperties = {
|
||||
maxWidth: opt(int({ minimum: 1 })),
|
||||
maxHeight: opt(int({ minimum: 1 })),
|
||||
maxBytes: opt(int({ minimum: 1, description: "Maximum base64-encoded payload size in bytes." })),
|
||||
jpegQuality: opt(int({ minimum: 1, maximum: 100 })),
|
||||
};
|
||||
|
||||
function modelImageResizeOptions(options?: Annotations<StaticObject<typeof ModelImageResizeProperties>>) {
|
||||
return object(ModelImageResizeProperties, options);
|
||||
}
|
||||
|
||||
export const ModelImageResizeOptionsSchema = modelImageResizeOptions();
|
||||
|
||||
export const ModelImageInputLimitsSchema = Type.Object({
|
||||
resize: Type.Optional(
|
||||
export const ModelImageInputLimitsSchema = object({
|
||||
resize: opt(
|
||||
modelImageResizeOptions({
|
||||
description: "Cache-safe resize profile applied before a new image enters conversation history.",
|
||||
}),
|
||||
),
|
||||
maxPerMessage: Type.Optional(
|
||||
Type.Integer({ minimum: 1, description: "Maximum images accepted in one provider message." }),
|
||||
),
|
||||
maxPerRequest: Type.Optional(
|
||||
Type.Integer({ minimum: 1, description: "Maximum images accepted across one provider request." }),
|
||||
),
|
||||
maxPerMessage: opt(int({ minimum: 1, description: "Maximum images accepted in one provider message." })),
|
||||
maxPerRequest: opt(int({ minimum: 1, description: "Maximum images accepted across one provider request." })),
|
||||
});
|
||||
|
||||
export const ModelInputLimitsSchema = Type.Object({
|
||||
maxRequestBytes: Type.Optional(
|
||||
Type.Integer({ minimum: 1, description: "Maximum serialized provider request size in bytes." }),
|
||||
),
|
||||
images: Type.Optional(ModelImageInputLimitsSchema),
|
||||
export const ModelInputLimitsSchema = object({
|
||||
maxRequestBytes: opt(int({ minimum: 1, description: "Maximum serialized provider request size in bytes." })),
|
||||
images: opt(ModelImageInputLimitsSchema),
|
||||
});
|
||||
|
||||
export type ThinkingLevel = Static<typeof ThinkingLevelSchema>;
|
||||
|
|
|
|||
34
packages/ai/src/providers/schema-builders.ts
Normal file
34
packages/ai/src/providers/schema-builders.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Builders for pi-ai's own configuration schemas (model metadata, compat settings): pi-schema's builders, shaped like the
|
||||
// TypeBox schemas they replace, so models.json and the catalog validate exactly as before.
|
||||
|
||||
import {
|
||||
type Annotations,
|
||||
obj,
|
||||
type Properties,
|
||||
type Schema,
|
||||
type Static,
|
||||
type StaticObject,
|
||||
} from "@earendil-works/pi-schema";
|
||||
|
||||
/** An object that allows other properties; `additionalProperties: true` says so explicitly. */
|
||||
export function openObject<const P extends Properties>(
|
||||
properties: P,
|
||||
options: Annotations<StaticObject<P>> & { readonly additionalProperties?: true } = {},
|
||||
): Schema<StaticObject<P>> {
|
||||
const { additionalProperties, ...annotations } = options;
|
||||
const schema = obj(properties, { ...annotations, open: true });
|
||||
return additionalProperties ? { ...schema, additionalProperties } : schema;
|
||||
}
|
||||
|
||||
/** One string: `{ type: "string", const: value }`. */
|
||||
export function stringLiteral<const V extends string>(value: V, options: Annotations<V> = {}): Schema<V> {
|
||||
return { type: "string", const: value, ...options };
|
||||
}
|
||||
|
||||
/** An object with any string keys and values of `value`: `{ type: "object", patternProperties: { "^.*$": value } }`. */
|
||||
export function stringRecord<S extends Schema>(
|
||||
value: S,
|
||||
options: Annotations<Record<string, Static<S>>> = {},
|
||||
): Schema<Record<string, Static<S>>> {
|
||||
return { type: "object", patternProperties: { "^.*$": value }, ...options };
|
||||
}
|
||||
|
|
@ -749,8 +749,6 @@ export interface ClassifierResult {
|
|||
timestamp: number; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
import type { TSchema } from "typebox";
|
||||
|
||||
/** OpenAI grammar variants for constrained sampling. */
|
||||
export type GrammarFormat = "openai_lark" | "openai_regex";
|
||||
|
||||
|
|
@ -773,7 +771,11 @@ export type ConstrainedSamplingConfig =
|
|||
variants: GrammarVariants;
|
||||
};
|
||||
|
||||
export interface Tool<TParameters extends TSchema = TSchema> {
|
||||
/**
|
||||
* A tool the model may call. `parameters` is the JSON Schema of its arguments: pi-schema's builders (which also type
|
||||
* the arguments, see `Static` in `@earendil-works/pi-schema`), TypeBox schemas, or any JSON Schema object.
|
||||
*/
|
||||
export interface Tool<TParameters extends object = object> {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: TParameters;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([
|
|||
// Sign in with ChatGPT on a ChatGPT Business plan: a usage limit set by the
|
||||
// workspace admin. Only the admin can raise it.
|
||||
"subscription_sharing_workplace_individual_usage_limit_exceeded",
|
||||
|
||||
// Zhipu/Z.ai (open.bigmodel.cn) error code 1308: the plan's usage limit is
|
||||
// reached, sent as HTTP 429. It resets after hours, not seconds (#11537).
|
||||
'"code"\\s*:\\s*"?1308(?![0-9])',
|
||||
]);
|
||||
|
||||
const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
import { type TUnsafe, Type } from "typebox";
|
||||
import type { Schema } from "@earendil-works/pi-schema";
|
||||
|
||||
/**
|
||||
* Creates a string enum schema compatible with Google's API and other providers
|
||||
* that don't support anyOf/const patterns.
|
||||
* that don't support anyOf/const patterns: `{ type: "string", enum: [...] }`.
|
||||
*
|
||||
* @example
|
||||
* const OperationSchema = StringEnum(["add", "subtract", "multiply", "divide"], {
|
||||
* description: "The operation to perform"
|
||||
* });
|
||||
*
|
||||
* type Operation = Static<typeof OperationSchema>; // "add" | "subtract" | "multiply" | "divide"
|
||||
* type Operation = Static<typeof OperationSchema>; // "add" | "subtract" | "multiply" | "divide" (Static from @earendil-works/pi-schema)
|
||||
*/
|
||||
export function StringEnum<T extends readonly string[]>(
|
||||
values: T,
|
||||
options?: { description?: string; default?: T[number] },
|
||||
): TUnsafe<T[number]> {
|
||||
return Type.Unsafe<T[number]>({
|
||||
): Schema<T[number]> {
|
||||
return {
|
||||
type: "string",
|
||||
enum: values as any,
|
||||
enum: values,
|
||||
...(options?.description && { description: options.description }),
|
||||
...(options?.default && { default: options.default }),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -133,7 +133,7 @@ export function toToolDeclaration(tool: Tool): Tool {
|
|||
* Whether two tools declare the same interface to the model.
|
||||
*
|
||||
* Both sides go through {@link toToolDeclaration} first: its JSON round-trip drops the
|
||||
* typebox symbol keys and `undefined` fields that a structural comparison would see, and
|
||||
* schema metadata keys (TypeBox's non-enumerable kind marks) and `undefined` fields that a structural comparison would see, and
|
||||
* builds both objects with the same key order, so comparing the serialized declarations
|
||||
* is exact. This avoids a deep-equal dependency in a browser-safe package.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,299 +1,34 @@
|
|||
import { Compile } from "typebox/compile";
|
||||
import type { TLocalizedValidationError } from "typebox/error";
|
||||
import { Value } from "typebox/value";
|
||||
import { type CoercingValidator, compile } from "@earendil-works/pi-schema";
|
||||
import { formats } from "@earendil-works/pi-schema/formats";
|
||||
import type { Tool, ToolCall } from "../types.ts";
|
||||
|
||||
const validatorCache = new WeakMap<object, ReturnType<typeof Compile>>();
|
||||
const TYPEBOX_KIND = Symbol.for("TypeBox.Kind");
|
||||
// One compiled validator per schema object: tool schemas are long-lived, so the WeakMap drops entries with their tools.
|
||||
const validatorCache = new WeakMap<object, CoercingValidator>();
|
||||
|
||||
interface JsonSchemaObject {
|
||||
type?: string | string[];
|
||||
properties?: Record<string, JsonSchemaObject>;
|
||||
required?: string[];
|
||||
items?: JsonSchemaObject | JsonSchemaObject[];
|
||||
additionalProperties?: boolean | JsonSchemaObject;
|
||||
allOf?: JsonSchemaObject[];
|
||||
anyOf?: JsonSchemaObject[];
|
||||
oneOf?: JsonSchemaObject[];
|
||||
}
|
||||
|
||||
function getSchemaTypes(schema: JsonSchemaObject): string[] {
|
||||
if (typeof schema.type === "string") {
|
||||
return [schema.type];
|
||||
}
|
||||
if (Array.isArray(schema.type)) {
|
||||
return schema.type.filter((type): type is string => typeof type === "string");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function matchesJsonType(value: unknown, type: string): boolean {
|
||||
switch (type) {
|
||||
case "number":
|
||||
return typeof value === "number";
|
||||
case "integer":
|
||||
return typeof value === "number" && Number.isInteger(value);
|
||||
case "boolean":
|
||||
return typeof value === "boolean";
|
||||
case "string":
|
||||
return typeof value === "string";
|
||||
case "null":
|
||||
return value === null;
|
||||
case "array":
|
||||
return Array.isArray(value);
|
||||
case "object":
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSubSchemaValidator(schema: JsonSchemaObject): ReturnType<typeof Compile> | undefined {
|
||||
try {
|
||||
return getValidator(schema as Tool["parameters"]);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function coercePrimitiveByType(value: unknown, type: string): unknown {
|
||||
switch (type) {
|
||||
case "number": {
|
||||
if (value === null) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "integer": {
|
||||
if (value === null) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isInteger(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "boolean": {
|
||||
if (value === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (value === "true") {
|
||||
return true;
|
||||
}
|
||||
if (value === "false") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (value === 1) {
|
||||
return true;
|
||||
}
|
||||
if (value === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "string": {
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "null": {
|
||||
if (value === "" || value === 0 || value === false) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function applySchemaObjectCoercion(value: Record<string, unknown>, schema: JsonSchemaObject): void {
|
||||
const properties = schema.properties;
|
||||
const definedKeys = new Set<string>(properties ? Object.keys(properties) : []);
|
||||
|
||||
if (properties) {
|
||||
for (const [key, propertySchema] of Object.entries(properties)) {
|
||||
if (!(key in value)) {
|
||||
continue;
|
||||
}
|
||||
value[key] = coerceWithJsonSchema(value[key], propertySchema);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
for (const [key, propertyValue] of Object.entries(value)) {
|
||||
if (definedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
value[key] = coerceWithJsonSchema(propertyValue, schema.additionalProperties);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applySchemaArrayCoercion(value: unknown[], schema: JsonSchemaObject): void {
|
||||
if (Array.isArray(schema.items)) {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const itemSchema = schema.items[index];
|
||||
if (!itemSchema) {
|
||||
continue;
|
||||
}
|
||||
value[index] = coerceWithJsonSchema(value[index], itemSchema);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.items && typeof schema.items === "object") {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
value[index] = coerceWithJsonSchema(value[index], schema.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function coerceWithUnionSchema(value: unknown, schemas: JsonSchemaObject[]): unknown {
|
||||
for (const schema of schemas) {
|
||||
const validator = getSubSchemaValidator(schema);
|
||||
if (validator?.Check(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const schema of schemas) {
|
||||
const candidate = structuredClone(value);
|
||||
const coerced = coerceWithJsonSchema(candidate, schema);
|
||||
const validator = getSubSchemaValidator(schema);
|
||||
if (validator?.Check(coerced)) {
|
||||
return coerced;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown {
|
||||
let nextValue = value;
|
||||
|
||||
if (Array.isArray(schema.allOf)) {
|
||||
for (const nested of schema.allOf) {
|
||||
nextValue = coerceWithJsonSchema(nextValue, nested);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
nextValue = coerceWithUnionSchema(nextValue, schema.anyOf);
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.oneOf)) {
|
||||
nextValue = coerceWithUnionSchema(nextValue, schema.oneOf);
|
||||
}
|
||||
|
||||
const schemaTypes = getSchemaTypes(schema);
|
||||
const matchesUnionMember =
|
||||
schemaTypes.length > 1 && schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType));
|
||||
if (schemaTypes.length > 0 && !matchesUnionMember) {
|
||||
for (const schemaType of schemaTypes) {
|
||||
const candidate = coercePrimitiveByType(nextValue, schemaType);
|
||||
if (candidate !== nextValue) {
|
||||
nextValue = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
schemaTypes.includes("object") &&
|
||||
typeof nextValue === "object" &&
|
||||
nextValue !== null &&
|
||||
!Array.isArray(nextValue)
|
||||
) {
|
||||
applySchemaObjectCoercion(nextValue as Record<string, unknown>, schema);
|
||||
}
|
||||
|
||||
if (schemaTypes.includes("array") && Array.isArray(nextValue)) {
|
||||
applySchemaArrayCoercion(nextValue, schema);
|
||||
}
|
||||
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
function normalizeOptionalNulls(value: unknown, schema: JsonSchemaObject): void {
|
||||
if (Array.isArray(value)) {
|
||||
if (Array.isArray(schema.items)) {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const itemSchema = schema.items[index];
|
||||
if (itemSchema) normalizeOptionalNulls(value[index], itemSchema);
|
||||
}
|
||||
} else if (schema.items) {
|
||||
for (const item of value) normalizeOptionalNulls(item, schema.items);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null || !schema.properties) return;
|
||||
|
||||
const object = value as Record<string, unknown>;
|
||||
const required = new Set(schema.required ?? []);
|
||||
for (const [key, propertySchema] of Object.entries(schema.properties)) {
|
||||
if (!(key in object)) continue;
|
||||
if (
|
||||
object[key] === null &&
|
||||
!required.has(key) &&
|
||||
typeof (propertySchema as { $ref?: unknown }).$ref !== "string" &&
|
||||
getSubSchemaValidator(propertySchema)?.Check(null) === false
|
||||
) {
|
||||
delete object[key];
|
||||
} else {
|
||||
normalizeOptionalNulls(object[key], propertySchema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getValidator(schema: Tool["parameters"]): ReturnType<typeof Compile> {
|
||||
const key = schema as object;
|
||||
const cached = validatorCache.get(key);
|
||||
function getValidator(schema: Tool["parameters"]): CoercingValidator {
|
||||
const cached = validatorCache.get(schema);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const validator = Compile(schema);
|
||||
validatorCache.set(key, validator);
|
||||
// A $ref that names nothing (a dangling definition in an MCP server's schema) rejects only values at its place,
|
||||
// instead of making every call of the tool fail.
|
||||
const validator = compile(schema, { formats, coerce: true, unresolvedRefs: "reject" });
|
||||
validatorCache.set(schema, validator);
|
||||
return validator;
|
||||
}
|
||||
|
||||
function formatValidationPath(error: TLocalizedValidationError): string {
|
||||
if (error.keyword === "required") {
|
||||
const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties;
|
||||
const requiredProperty = requiredProperties?.[0];
|
||||
if (requiredProperty) {
|
||||
const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
|
||||
return basePath ? `${basePath}.${requiredProperty}` : requiredProperty;
|
||||
}
|
||||
}
|
||||
const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
|
||||
return path || "root";
|
||||
/** A pi-schema error path (a JSON Pointer) as the dotted path the model sees: `/edits/0/oldText` -> `edits.0.oldText`. */
|
||||
function formatValidationPath(pointer: string): string {
|
||||
if (pointer === "") return "root";
|
||||
return pointer
|
||||
.slice(1)
|
||||
.split("/")
|
||||
.map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"))
|
||||
.join(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a tool by name and validates the tool call arguments against its TypeBox schema
|
||||
* Finds a tool by name and validates the tool call arguments against its JSON Schema
|
||||
* @param tools Array of tool definitions
|
||||
* @param toolCall The tool call from the LLM
|
||||
* @returns The validated arguments
|
||||
|
|
@ -308,8 +43,10 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): any {
|
|||
}
|
||||
|
||||
/**
|
||||
* Validates tool call arguments against the tool's TypeBox schema
|
||||
* @param tool The tool definition with TypeBox schema
|
||||
* Validates tool call arguments against the tool's JSON Schema (any JSON Schema: pi-schema's builders, TypeBox, plain
|
||||
* objects). The arguments are coerced first (pi-schema's `parse()`): models often send `"42"` for a number, a single
|
||||
* value for an array, or `null` for an omitted optional property.
|
||||
* @param tool The tool definition with its JSON Schema
|
||||
* @param toolCall The tool call from the LLM
|
||||
* @returns The validated (and potentially coerced) arguments
|
||||
* @throws Error with formatted message if validation fails
|
||||
|
|
@ -318,34 +55,13 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
|
|||
if (toolCall.argumentsError) {
|
||||
throw new Error(`Invalid arguments for tool "${toolCall.name}": ${toolCall.argumentsError}`);
|
||||
}
|
||||
const args = structuredClone(toolCall.arguments);
|
||||
normalizeOptionalNulls(args, tool.parameters as JsonSchemaObject);
|
||||
Value.Convert(tool.parameters, args);
|
||||
|
||||
const validator = getValidator(tool.parameters);
|
||||
if (!Object.getOwnPropertySymbols(tool.parameters).includes(TYPEBOX_KIND)) {
|
||||
const coerced = coerceWithJsonSchema(args, tool.parameters as JsonSchemaObject);
|
||||
if (coerced !== args) {
|
||||
if (typeof args === "object" && args !== null && typeof coerced === "object" && coerced !== null) {
|
||||
for (const key of Object.keys(args)) {
|
||||
delete args[key];
|
||||
}
|
||||
Object.assign(args, coerced);
|
||||
} else {
|
||||
return validator.Check(coerced) ? coerced : args;
|
||||
}
|
||||
}
|
||||
// A copy, so tools that modify their arguments never modify the tool call in the transcript.
|
||||
const result = getValidator(tool.parameters).parse(structuredClone(toolCall.arguments));
|
||||
if (result.ok) {
|
||||
return result.value;
|
||||
}
|
||||
|
||||
if (validator.Check(args)) {
|
||||
return args;
|
||||
}
|
||||
|
||||
const errors =
|
||||
validator
|
||||
.Errors(args)
|
||||
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
|
||||
.join("\n") || "Unknown validation error";
|
||||
const errors = result.errors.map((error) => ` - ${formatValidationPath(error.path)}: ${error.message}`).join("\n");
|
||||
|
||||
const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.4.0";
|
||||
export const PI_AI_VERSION = "1.5.0";
|
||||
|
|
|
|||
|
|
@ -98,6 +98,31 @@ describe("Anthropic strict tool schemas", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// https://github.com/earendil-works/pi/issues/9557
|
||||
it("keeps root definitions in non-strict schemas so property refs resolve", async () => {
|
||||
const settings = { type: "object", properties: { verbose: { type: "boolean" } } };
|
||||
const cases = [
|
||||
{ ref: "#/$defs/Settings", definitions: { $defs: { Settings: settings } } },
|
||||
{ ref: "#/definitions/Settings", definitions: { definitions: { Settings: settings } } },
|
||||
];
|
||||
for (const { ref, definitions } of cases) {
|
||||
const parameters = {
|
||||
type: "object",
|
||||
properties: { settings: { $ref: ref } },
|
||||
required: ["settings"],
|
||||
...definitions,
|
||||
} as unknown as Tool["parameters"];
|
||||
const tool = await captureFirstTool(createTool(parameters));
|
||||
expect(tool.strict).toBeUndefined();
|
||||
expect(tool.input_schema).toEqual({
|
||||
type: "object",
|
||||
properties: { settings: { $ref: ref } },
|
||||
required: ["settings"],
|
||||
...definitions,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// https://github.com/earendil-works/pi/issues/9953
|
||||
it("sends prefer tools non-strict when they use keywords Anthropic strict mode rejects", async () => {
|
||||
const unsupportedParameters: Tool["parameters"][] = [
|
||||
|
|
|
|||
71
packages/ai/test/clamp-max-tokens.test.ts
Normal file
71
packages/ai/test/clamp-max-tokens.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { clampMaxTokensToContext } from "../src/api/simple-options.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const WINDOW = 1_048_576;
|
||||
|
||||
function modelWith(maxTokens: number, contextWindow = WINDOW): Model<"openai-completions"> {
|
||||
const { compat: _compat, ...base } = getModel("openai", "gpt-4o-mini")!;
|
||||
return { ...base, api: "openai-completions", contextWindow, maxTokens };
|
||||
}
|
||||
|
||||
// The last assistant usage anchors the estimate, so the estimated input is exactly `inputTokens`.
|
||||
function contextWithInput(inputTokens: number) {
|
||||
const assistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
usage: {
|
||||
input: inputTokens,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: inputTokens,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
};
|
||||
return normalizeContext({ messages: [{ role: "user", content: "hi", timestamp: 1 }, assistant] });
|
||||
}
|
||||
|
||||
describe("clampMaxTokensToContext", () => {
|
||||
it("leaves 15% of the input unused so an OpenRouter overcount still fits (#10497)", () => {
|
||||
// The failing request from #10497: OpenRouter counted 229,747 input tokens where pi estimated 220,815.
|
||||
const maxTokens = clampMaxTokensToContext(modelWith(943_718), contextWithInput(220_815), 943_718);
|
||||
expect(maxTokens).toBe(WINDOW - 220_815 - 33_123);
|
||||
expect(229_747 + maxTokens).toBeLessThanOrEqual(WINDOW);
|
||||
});
|
||||
|
||||
it("leaves at least 4096 tokens unused for small inputs", () => {
|
||||
expect(clampMaxTokensToContext(modelWith(WINDOW), contextWithInput(10_000), WINDOW)).toBe(WINDOW - 10_000 - 4096);
|
||||
});
|
||||
|
||||
it("keeps the model output limit when it already fits", () => {
|
||||
expect(clampMaxTokensToContext(modelWith(64_000), contextWithInput(100_000), 64_000)).toBe(64_000);
|
||||
});
|
||||
|
||||
it("sends up to 32768 tokens when the proportional margin leaves no room", () => {
|
||||
expect(clampMaxTokensToContext(modelWith(943_718), contextWithInput(912_000), 943_718)).toBe(32_768);
|
||||
});
|
||||
|
||||
it("uses the real room minus 4096 when less than 32768 tokens fit", () => {
|
||||
expect(clampMaxTokensToContext(modelWith(943_718), contextWithInput(1_030_000), 943_718)).toBe(
|
||||
WINDOW - 1_030_000 - 4096,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps only the fixed 4096 margin for small windows", () => {
|
||||
expect(clampMaxTokensToContext(modelWith(32_768, 32_768), contextWithInput(20_000), 32_768)).toBe(
|
||||
32_768 - 20_000 - 4096,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends 1 token when the input already exceeds the window", () => {
|
||||
expect(clampMaxTokensToContext(modelWith(943_718), contextWithInput(1_050_000), 943_718)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -245,6 +245,62 @@ describe("Models with classifier models", () => {
|
|||
expect((await withOAuth.getAvailable("openai")).some((model) => model.id === "gpt-6-luna")).toBe(true);
|
||||
});
|
||||
|
||||
it("routes GitHub Copilot Microsoft-Decision-1 to the account's /v1/systemone endpoint", async () => {
|
||||
const credentials = new InMemoryCredentialStore();
|
||||
await credentials.modify("github-copilot", async () => ({
|
||||
type: "oauth",
|
||||
access: "tid=test;exp=9999999999;proxy-ep=proxy.enterprise.githubcopilot.com;",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
// The login records only picker models; classifiers stay listed.
|
||||
availableModelIds: ["gpt-6-luna"],
|
||||
}));
|
||||
const models = builtinModels({ credentials });
|
||||
const decision = models.getModelOfType("classifier", "github-copilot", "microsoft-decision-1");
|
||||
if (!decision) throw new Error("missing GitHub Copilot decision model");
|
||||
expect(decision).toMatchObject({
|
||||
api: "typesafe-system-one",
|
||||
baseUrl: "https://api.individual.githubcopilot.com",
|
||||
input: ["text"],
|
||||
contextWindow: 32768,
|
||||
});
|
||||
expect(models.getModel("github-copilot", "microsoft-decision-1")).toBeUndefined();
|
||||
expect(await models.getAvailableOfType("classifier", "github-copilot")).toEqual([decision]);
|
||||
|
||||
const requests: Array<{ url: string; headers: Headers }> = [];
|
||||
const result = await models.classify(decision, context, {
|
||||
fetch: async (input, init) => {
|
||||
requests.push({ url: String(input), headers: new Headers(init?.headers) });
|
||||
return Response.json({ answers: { approved: { type: "noul", noul: 0.8 } } });
|
||||
},
|
||||
});
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual(["https://api.enterprise.githubcopilot.com/v1/systemone"]);
|
||||
expect(requests[0].headers.get("authorization")).toBe(
|
||||
"Bearer tid=test;exp=9999999999;proxy-ep=proxy.enterprise.githubcopilot.com;",
|
||||
);
|
||||
// Copilot rejects requests without an editor version.
|
||||
expect(requests[0].headers.get("editor-version")).toBeTruthy();
|
||||
expect(requests[0].headers.get("copilot-integration-id")).toBe("vscode-chat");
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(result.answers.approved).toEqual({ type: "bool", probability: 0.8 });
|
||||
});
|
||||
|
||||
it("adds the System One version prefix to the GitHub Copilot catalog base URL", async () => {
|
||||
const models = builtinModels();
|
||||
const decision = models.getModelOfType("classifier", "github-copilot", "microsoft-decision-1");
|
||||
if (!decision) throw new Error("missing GitHub Copilot decision model");
|
||||
const urls: string[] = [];
|
||||
await models.classify(decision, context, {
|
||||
apiKey: "secret",
|
||||
fetch: async (input) => {
|
||||
urls.push(String(input));
|
||||
return Response.json({ answers: { approved: { type: "noul", noul: 0.8 } } });
|
||||
},
|
||||
});
|
||||
expect(urls).toEqual(["https://api.individual.githubcopilot.com/v1/systemone"]);
|
||||
});
|
||||
|
||||
it("routes OpenRouter classifier models through the System One API", () => {
|
||||
const models = builtinModels();
|
||||
for (const model of getBuiltinClassifierModels("openrouter")) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Static } from "typebox";
|
||||
import { Compile } from "typebox/compile";
|
||||
import type { Static } from "@earendil-works/pi-schema";
|
||||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import {
|
||||
AnthropicMessagesCompatSchema,
|
||||
|
|
@ -11,6 +10,16 @@ import {
|
|||
|
||||
type ProviderCompat = Static<typeof ProviderCompatSchema>;
|
||||
|
||||
/** The `default` annotation of each property of an object schema. */
|
||||
function defaults(schema: { readonly [keyword: string]: unknown }): Record<string, unknown> {
|
||||
const properties = schema.properties as Record<string, { default?: unknown }>;
|
||||
return Object.fromEntries(
|
||||
Object.entries(properties)
|
||||
.filter(([, property]) => property.default !== undefined)
|
||||
.map(([name, property]) => [name, property.default]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("compatibility schemas", () => {
|
||||
it("preserves property types in the provider superset", () => {
|
||||
expectTypeOf<ProviderCompat["supportsStore"]>().toEqualTypeOf<boolean | undefined>();
|
||||
|
|
@ -20,21 +29,21 @@ describe("compatibility schemas", () => {
|
|||
});
|
||||
|
||||
it("preserves API-specific defaults", () => {
|
||||
const completionsDefaults = Compile(OpenAICompletionsCompatSchema).Default({});
|
||||
const completionsDefaults = defaults(OpenAICompletionsCompatSchema);
|
||||
expect(completionsDefaults).not.toHaveProperty("thinkingFormat");
|
||||
expect(completionsDefaults).not.toHaveProperty("supportsLongCacheRetention");
|
||||
expect(Compile(OpenAIResponsesCompatSchema).Default({})).toMatchObject({ supportsDeveloperRole: true });
|
||||
const anthropicDefaults = Compile(AnthropicMessagesCompatSchema).Default({});
|
||||
expect(defaults(OpenAIResponsesCompatSchema)).toMatchObject({ supportsDeveloperRole: true });
|
||||
const anthropicDefaults = defaults(AnthropicMessagesCompatSchema);
|
||||
expect(anthropicDefaults).not.toHaveProperty("sendSessionAffinityHeaders");
|
||||
expect(anthropicDefaults).toMatchObject({ supportsLongCacheRetention: true });
|
||||
expect(Compile(BedrockCompatSchema).Default({})).toMatchObject({ supportsStrictMode: false });
|
||||
expect(defaults(BedrockCompatSchema)).toMatchObject({ supportsStrictMode: false });
|
||||
});
|
||||
|
||||
it("does not assign API-specific defaults to the provider superset", () => {
|
||||
const defaults = Compile(ProviderCompatSchema).Default({});
|
||||
expect(defaults).not.toHaveProperty("supportsDeveloperRole");
|
||||
expect(defaults).not.toHaveProperty("sendSessionAffinityHeaders");
|
||||
expect(defaults).not.toHaveProperty("supportsLongCacheRetention");
|
||||
expect(defaults).not.toHaveProperty("supportsStrictMode");
|
||||
const providerDefaults = defaults(ProviderCompatSchema);
|
||||
expect(providerDefaults).not.toHaveProperty("supportsDeveloperRole");
|
||||
expect(providerDefaults).not.toHaveProperty("sendSessionAffinityHeaders");
|
||||
expect(providerDefaults).not.toHaveProperty("supportsLongCacheRetention");
|
||||
expect(providerDefaults).not.toHaveProperty("supportsStrictMode");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Type } from "typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
complete,
|
||||
|
|
@ -7,7 +8,6 @@ import {
|
|||
fauxToolCall,
|
||||
registerFauxProvider,
|
||||
stream,
|
||||
Type,
|
||||
} from "../src/compat.ts";
|
||||
import type { AssistantMessageEvent, Context } from "../src/types.ts";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { completeSimple, getModel } from "../src/compat.ts";
|
||||
import { getEnvApiKey } from "../src/env-api-keys.ts";
|
||||
import type { Api, Context, Model, StopReason, Tool, ToolCall, ToolResultMessage } from "../src/types.ts";
|
||||
import { StringEnum } from "../src/utils/typebox-helpers.ts";
|
||||
import { StringEnum } from "../src/utils/string-enum.ts";
|
||||
import { hasBedrockCredentials } from "./bedrock-utils.ts";
|
||||
|
||||
const calculatorSchema = Type.Object({
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type { Api, Context, ImageContent, Model, StreamOptions, Tool, ToolResult
|
|||
|
||||
type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
|
||||
|
||||
import { StringEnum } from "../src/utils/typebox-helpers.ts";
|
||||
import { StringEnum } from "../src/utils/string-enum.ts";
|
||||
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts";
|
||||
import { hasBedrockCredentials } from "./bedrock-utils.ts";
|
||||
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts";
|
||||
|
|
|
|||
779
packages/ai/test/validation-probes.test.ts
Normal file
779
packages/ai/test/validation-probes.test.ts
Normal file
|
|
@ -0,0 +1,779 @@
|
|||
// The reviewers' probes of the schema switch (round 1) as regression tests: TypeBox-built and plain JSON
|
||||
// (MCP-shaped) tool schemas with arguments through validateToolArguments. Each case's outcome is "ok <coerced
|
||||
// arguments>", "REJECT <first error line>" or "THROW <message>". Cases marked as TypeBox-only record the documented
|
||||
// differences from TypeBox (see the CHANGELOG): Refine and BigInt.
|
||||
|
||||
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: TypeBox template literal syntax.
|
||||
// biome-ignore-all lint/suspicious/noThenProperty: JSON Schema's `then` keyword.
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Tool, ToolCall } from "../src/types.ts";
|
||||
import { validateToolArguments } from "../src/utils/validation.ts";
|
||||
|
||||
/** The outcome of one call, in the probes' format. */
|
||||
function outcome(name: string, schema: object, args: unknown): string {
|
||||
const tool = { name, description: "", parameters: schema } as Tool;
|
||||
const call = { type: "toolCall", id: "1", name, arguments: args } as ToolCall;
|
||||
try {
|
||||
return `ok ${JSON.stringify(validateToolArguments(tool, call), (_key, value) => (typeof value === "bigint" ? `${value}n` : value))}`;
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
return message.startsWith("Validation failed")
|
||||
? `REJECT ${message.split("\n")[1]?.trim()}`
|
||||
: `THROW ${message.split("\n")[0]}`;
|
||||
}
|
||||
}
|
||||
|
||||
type Case = { name: string; schema: object; args: unknown[] };
|
||||
|
||||
const Node = Type.Cyclic(
|
||||
{ Node: Type.Object({ name: Type.String(), children: Type.Array(Type.Ref("Node")) }) },
|
||||
"Node",
|
||||
);
|
||||
|
||||
const cases: Case[] = [
|
||||
{
|
||||
name: "cyclic",
|
||||
schema: Type.Object({ tree: Node }),
|
||||
args: [{ tree: { name: "a", children: [{ name: "b", children: [] }] } }, { tree: { name: 1, children: [] } }],
|
||||
},
|
||||
{ name: "ref-with-$id", schema: Type.Object({ a: Type.Ref("A") }), args: [{ a: { x: "1" } }] },
|
||||
{
|
||||
name: "function",
|
||||
schema: Type.Object({ f: Type.Optional(Type.Function([], Type.Void())) }),
|
||||
args: [{}, { f: "x" }],
|
||||
},
|
||||
{
|
||||
name: "codec",
|
||||
schema: Type.Object({
|
||||
c: Type.Codec(Type.String())
|
||||
.Decode((s) => Number(s))
|
||||
.Encode((n) => String(n)),
|
||||
}),
|
||||
args: [{ c: "12" }, { c: 12 }],
|
||||
},
|
||||
{ name: "undefined-optional", schema: Type.Object({ u: Type.Optional(Type.Undefined()) }), args: [{}, { u: null }] },
|
||||
{ name: "bigint", schema: Type.Object({ n: Type.BigInt() }), args: [{ n: 1 }, { n: "12" }] },
|
||||
{
|
||||
name: "refine",
|
||||
schema: Type.Object({ n: Type.Refine(Type.Number(), (v) => v > 0) }),
|
||||
args: [{ n: 5 }, { n: -5 }],
|
||||
},
|
||||
{
|
||||
name: "template-literal",
|
||||
schema: Type.Object({ t: Type.TemplateLiteral("id-${number}") }),
|
||||
args: [{ t: "id-12" }, { t: "id-x" }, { t: 3 }],
|
||||
},
|
||||
{
|
||||
name: "record-string",
|
||||
schema: Type.Object({ r: Type.Record(Type.String(), Type.Number()) }),
|
||||
args: [{ r: { a: "1" } }, { r: { a: 1, b: "x" } }],
|
||||
},
|
||||
{
|
||||
name: "record-literal-keys",
|
||||
schema: Type.Object({ r: Type.Record(Type.Union([Type.Literal("a"), Type.Literal("b")]), Type.Number()) }),
|
||||
args: [{ r: { a: 1, b: 2 } }, { r: { a: 1 } }, { r: { a: 1, b: 2, c: 3 } }],
|
||||
},
|
||||
{
|
||||
name: "intersect",
|
||||
schema: Type.Intersect([Type.Object({ a: Type.Number() }), Type.Object({ b: Type.Boolean() })]),
|
||||
args: [
|
||||
{ a: "1", b: "true" },
|
||||
{ a: 1, b: true, c: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "union-objects",
|
||||
schema: Type.Object({
|
||||
v: Type.Union([
|
||||
Type.Object({ k: Type.Literal("x"), n: Type.Number() }),
|
||||
Type.Object({ k: Type.Literal("y"), s: Type.String() }),
|
||||
]),
|
||||
}),
|
||||
args: [{ v: { k: "x", n: "2" } }, { v: { k: "y", s: 2 } }],
|
||||
},
|
||||
{ name: "minLength-astral", schema: Type.Object({ s: Type.String({ minLength: 2 }) }), args: [{ s: "😀" }] },
|
||||
{ name: "maxLength-astral", schema: Type.Object({ s: Type.String({ maxLength: 1 }) }), args: [{ s: "😀" }] },
|
||||
{ name: "pattern-astral", schema: Type.Object({ s: Type.String({ pattern: "^.$" }) }), args: [{ s: "😀" }] },
|
||||
{
|
||||
name: "pattern-class-dash",
|
||||
schema: Type.Object({ s: Type.String({ pattern: "^[\\w-]+$" }) }),
|
||||
args: [{ s: "a-b" }],
|
||||
},
|
||||
{
|
||||
name: "pattern-unicode-prop",
|
||||
schema: Type.Object({ s: Type.String({ pattern: "^\\p{L}+$" }) }),
|
||||
args: [{ s: "äb" }, { s: "p{L}" }],
|
||||
},
|
||||
{
|
||||
name: "multipleOf-float",
|
||||
schema: Type.Object({ n: Type.Number({ multipleOf: 0.01 }) }),
|
||||
args: [{ n: 0.07 }, { n: 1.1 }],
|
||||
},
|
||||
{
|
||||
name: "uniqueItems-keyorder",
|
||||
schema: Type.Object({ a: Type.Array(Type.Unknown(), { uniqueItems: true }) }),
|
||||
args: [
|
||||
{
|
||||
a: [
|
||||
{ x: 1, y: 2 },
|
||||
{ y: 2, x: 1 },
|
||||
],
|
||||
},
|
||||
{ a: [1, 1.0] },
|
||||
],
|
||||
},
|
||||
{ name: "const-object", schema: Type.Object({ c: Type.Literal("a") }), args: [{ c: "a" }] },
|
||||
{
|
||||
name: "formats",
|
||||
schema: Type.Object({
|
||||
e: Type.Optional(Type.String({ format: "email" })),
|
||||
u: Type.Optional(Type.String({ format: "uri" })),
|
||||
d: Type.Optional(Type.String({ format: "date-time" })),
|
||||
x: Type.Optional(Type.String({ format: "made-up" })),
|
||||
h: Type.Optional(Type.String({ format: "hostname" })),
|
||||
r: Type.Optional(Type.String({ format: "regex" })),
|
||||
i: Type.Optional(Type.String({ format: "uuid" })),
|
||||
ip: Type.Optional(Type.String({ format: "ipv4" })),
|
||||
}),
|
||||
args: [
|
||||
{ e: "a@b.co" },
|
||||
{ e: "not-an-email" },
|
||||
{ u: "relative/path" },
|
||||
{ u: "https://x.y/z" },
|
||||
{ d: "2024-01-01T00:00:00Z" },
|
||||
{ d: "2024-01-01" },
|
||||
{ x: "anything" },
|
||||
{ h: "under_score.example" },
|
||||
{ r: "[" },
|
||||
{ i: "00000000-0000-0000-0000-000000000000" },
|
||||
{ i: "not" },
|
||||
{ ip: "999.1.1.1" },
|
||||
],
|
||||
},
|
||||
{ name: "integer-big", schema: Type.Object({ n: Type.Integer() }), args: [{ n: 1e21 }, { n: 2 ** 53 + 2 }] },
|
||||
{
|
||||
name: "additional-false",
|
||||
schema: Type.Object({ a: Type.String() }, { additionalProperties: false }),
|
||||
args: [{ a: "x", b: 1 }],
|
||||
},
|
||||
{
|
||||
name: "prototype-key",
|
||||
schema: Type.Object({ constructor: Type.Optional(Type.String()), toString: Type.Optional(Type.Number()) }),
|
||||
args: [{}, { constructor: 1 }],
|
||||
},
|
||||
{
|
||||
name: "proto-key-value",
|
||||
schema: Type.Object({}, { additionalProperties: Type.Array(Type.String()) }),
|
||||
args: [JSON.parse('{"__proto__": "x", "a": "y"}')],
|
||||
},
|
||||
// Plain JSON (MCP-shaped)
|
||||
{
|
||||
name: "mcp-draft7-ref-definitions",
|
||||
schema: {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
type: "object",
|
||||
properties: { t: { $ref: "#/definitions/T" } },
|
||||
required: ["t"],
|
||||
definitions: { T: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } },
|
||||
},
|
||||
args: [{ t: { path: "a" } }, { t: { path: 42 } }],
|
||||
},
|
||||
{
|
||||
name: "mcp-ref-unresolved",
|
||||
schema: { type: "object", properties: { t: { $ref: "#/definitions/Missing" } } },
|
||||
args: [{ t: 1 }, {}],
|
||||
},
|
||||
{
|
||||
name: "mcp-ref-external",
|
||||
schema: { type: "object", properties: { t: { $ref: "https://example.com/schemas/t.json" } } },
|
||||
args: [{ t: 1 }],
|
||||
},
|
||||
{
|
||||
name: "mcp-nullable-openapi",
|
||||
schema: { type: "object", properties: { s: { type: "string", nullable: true } }, required: ["s"] },
|
||||
args: [{ s: null }, { s: "x" }],
|
||||
},
|
||||
{
|
||||
name: "mcp-draft7-tuple",
|
||||
schema: {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
type: "object",
|
||||
properties: { p: { type: "array", items: [{ type: "number" }, { type: "string" }], additionalItems: false } },
|
||||
},
|
||||
args: [{ p: [1, "a"] }, { p: [1, "a", 3] }, { p: ["1", 2] }],
|
||||
},
|
||||
{
|
||||
name: "mcp-draft7-ref-sibling",
|
||||
schema: {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
type: "object",
|
||||
properties: { s: { $ref: "#/definitions/S", maxLength: 1 } },
|
||||
definitions: { S: { type: "string" } },
|
||||
},
|
||||
args: [{ s: "abc" }],
|
||||
},
|
||||
{
|
||||
name: "mcp-draft4-exclusive-bool",
|
||||
schema: {
|
||||
$schema: "http://json-schema.org/draft-04/schema#",
|
||||
type: "object",
|
||||
properties: { n: { type: "number", minimum: 0, exclusiveMinimum: true } },
|
||||
},
|
||||
args: [{ n: 0 }, { n: 1 }],
|
||||
},
|
||||
{
|
||||
name: "mcp-id-draft4",
|
||||
schema: {
|
||||
$schema: "http://json-schema.org/draft-04/schema#",
|
||||
id: "http://x/y",
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
},
|
||||
args: [{ a: "x" }],
|
||||
},
|
||||
{
|
||||
name: "mcp-bad-pattern",
|
||||
schema: { type: "object", properties: { s: { type: "string", pattern: "(?<!x)y" } } },
|
||||
args: [{ s: "y" }],
|
||||
},
|
||||
{
|
||||
name: "mcp-invalid-regex",
|
||||
schema: { type: "object", properties: { s: { type: "string", pattern: "[" } } },
|
||||
args: [{ s: "y" }],
|
||||
},
|
||||
{
|
||||
name: "mcp-type-integer-float-0",
|
||||
schema: { type: "object", properties: { n: { type: "integer" } } },
|
||||
args: [{ n: 1.0 }, { n: 1.5 }],
|
||||
},
|
||||
{
|
||||
name: "mcp-format-unknown",
|
||||
schema: { type: "object", properties: { s: { type: "string", format: "binary" } } },
|
||||
args: [{ s: "xx" }],
|
||||
},
|
||||
{
|
||||
name: "mcp-required-null-string",
|
||||
schema: { type: "object", properties: { q: { type: "string" } }, required: ["q"] },
|
||||
args: [{ q: null }],
|
||||
},
|
||||
{
|
||||
name: "mcp-array-json-string",
|
||||
schema: { type: "object", properties: { p: { type: "array", items: { type: "string" } } }, required: ["p"] },
|
||||
args: [{ p: '["a","b"]' }],
|
||||
},
|
||||
{
|
||||
name: "mcp-integer-float",
|
||||
schema: { type: "object", properties: { n: { type: "integer" } }, required: ["n"] },
|
||||
args: [{ n: 3.7 }, { n: "3.7" }],
|
||||
},
|
||||
{ name: "mcp-empty-schema", schema: {}, args: [{ a: 1 }] },
|
||||
{ name: "mcp-type-object-no-props", schema: { type: "object" }, args: [{ a: 1 }] },
|
||||
{
|
||||
name: "mcp-boolean-true-prop",
|
||||
schema: { type: "object", properties: { a: true, b: false } },
|
||||
args: [{ a: 1 }, { b: 1 }],
|
||||
},
|
||||
{
|
||||
name: "mcp-huge-enum",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { e: { type: "string", enum: Array.from({ length: 5000 }, (_, i) => `v${i}`) } },
|
||||
},
|
||||
args: [{ e: "v4999" }, { e: "v5000" }],
|
||||
},
|
||||
];
|
||||
|
||||
const o = (properties: Record<string, unknown>, extra: Record<string, unknown> = {}) => ({
|
||||
type: "object",
|
||||
properties,
|
||||
...extra,
|
||||
});
|
||||
const keywordCases: [string, object, unknown[]][] = [
|
||||
[
|
||||
"oneOf-overlap",
|
||||
o({ v: { oneOf: [{ type: "string" }, { type: "string", maxLength: 5 }] } }),
|
||||
[{ v: "abc" }, { v: "abcdefgh" }],
|
||||
],
|
||||
["oneOf-number-integer", o({ v: { oneOf: [{ type: "number" }, { type: "integer" }] } }), [{ v: 2 }, { v: 2.5 }]],
|
||||
[
|
||||
"oneOf-objects-open",
|
||||
o({
|
||||
v: {
|
||||
oneOf: [
|
||||
{ type: "object", properties: { a: { type: "string" } } },
|
||||
{ type: "object", properties: { b: { type: "string" } } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
[{ v: { a: "x" } }, { v: {} }],
|
||||
],
|
||||
[
|
||||
"if-then-else",
|
||||
o(
|
||||
{ kind: { type: "string" }, n: { type: "number" } },
|
||||
{ if: { properties: { kind: { const: "a" } } }, then: { required: ["n"] } },
|
||||
),
|
||||
[{ kind: "a" }, { kind: "b" }],
|
||||
],
|
||||
[
|
||||
"dependentRequired",
|
||||
o({ a: { type: "string" }, b: { type: "string" } }, { dependentRequired: { a: ["b"] } }),
|
||||
[{ a: "x" }],
|
||||
],
|
||||
[
|
||||
"dependencies-d7",
|
||||
o({ a: { type: "string" }, b: { type: "string" } }, { dependencies: { a: ["b"] } }),
|
||||
[{ a: "x" }],
|
||||
],
|
||||
["propertyNames", o({}, { propertyNames: { pattern: "^[a-z]+$" } }), [{ abc: 1 }, { ABC: 1 }]],
|
||||
["contains", o({ l: { type: "array", contains: { type: "number" } } }), [{ l: ["a"] }, { l: ["a", 1] }]],
|
||||
["not", o({ s: { not: { type: "null" } } }), [{ s: "x" }, { s: null }]],
|
||||
["minProperties", o({ m: { type: "object", minProperties: 1 } }), [{ m: {} }]],
|
||||
[
|
||||
"unevaluatedProperties",
|
||||
o({ a: { type: "string" } }, { unevaluatedProperties: false }),
|
||||
[{ a: "x" }, { a: "x", b: 1 }],
|
||||
],
|
||||
[
|
||||
"allOf-additional-false",
|
||||
{ allOf: [o({ a: { type: "string" } }, { additionalProperties: false }), o({ b: { type: "string" } })] },
|
||||
[{ a: "x" }, { a: "x", b: "y" }],
|
||||
],
|
||||
["const-object", o({ c: { const: { x: 1, y: 2 } } }), [{ c: { y: 2, x: 1 } }]],
|
||||
["enum-object", o({ e: { enum: [{ x: 1, y: 2 }] } }), [{ e: { y: 2, x: 1 } }]],
|
||||
["type-array-multi", o({ v: { type: ["string", "number"] } }), [{ v: true }, { v: null }]],
|
||||
["nullable-type-array", o({ v: { type: ["string", "null"] } }, { required: ["v"] }), [{ v: null }, { v: 5 }]],
|
||||
["exclusiveMinimum-number", o({ n: { type: "number", exclusiveMinimum: 0 } }), [{ n: 0 }, { n: "0" }]],
|
||||
["maxItems", o({ l: { type: "array", maxItems: 1 } }), [{ l: [1, 2] }, { l: "x" }]],
|
||||
[
|
||||
"discriminator-openapi",
|
||||
o({
|
||||
p: {
|
||||
oneOf: [
|
||||
o({ kind: { type: "string", enum: ["a"] }, x: { type: "number" } }, { required: ["kind"] }),
|
||||
o({ kind: { type: "string", enum: ["b"] } }, { required: ["kind"] }),
|
||||
],
|
||||
discriminator: { propertyName: "kind" },
|
||||
},
|
||||
}),
|
||||
[{ p: { kind: "a", x: "1" } }, { p: { kind: "b" } }],
|
||||
],
|
||||
[
|
||||
"ref-root-defs",
|
||||
{ $ref: "#/$defs/Root", $defs: { Root: o({ a: { type: "string" } }, { required: ["a"] }) } },
|
||||
[{ a: "x" }, { a: 1 }, {}],
|
||||
],
|
||||
[
|
||||
"ref-recursive",
|
||||
{
|
||||
$defs: { N: o({ c: { type: "array", items: { $ref: "#/$defs/N" } }, v: { type: "integer" } }) },
|
||||
...o({ root: { $ref: "#/$defs/N" } }),
|
||||
},
|
||||
[{ root: { v: "2", c: [{ v: "3", c: [] }] } }],
|
||||
],
|
||||
[
|
||||
"ref-percent",
|
||||
{ ...o({ a: { $ref: "#/$defs/a%20b" } }), $defs: { "a b": { type: "string" } } },
|
||||
[{ a: "x" }, { a: 1 }],
|
||||
],
|
||||
["ref-tilde", { ...o({ a: { $ref: "#/$defs/a~1b" } }), $defs: { "a/b": { type: "string" } } }, [{ a: "x" }]],
|
||||
[
|
||||
"defs-with-$id",
|
||||
{ ...o({ a: { $ref: "item.json" } }), $defs: { item: { $id: "item.json", type: "string" } } },
|
||||
[{ a: "x" }, { a: 2 }],
|
||||
],
|
||||
[
|
||||
"anyOf-null-default",
|
||||
o({ v: { anyOf: [{ type: "string" }, { type: "null" }], default: null } }),
|
||||
[{ v: null }, {}],
|
||||
],
|
||||
[
|
||||
"format-date-time-lower",
|
||||
o({ d: { type: "string", format: "date-time" } }),
|
||||
[{ d: "2024-01-01t00:00:00z" }, { d: "2024-01-01 00:00:00Z" }],
|
||||
],
|
||||
["format-url", o({ u: { type: "string", format: "url" } }), [{ u: "not a url" }]],
|
||||
["integer-as-float-1e0", o({ n: { type: "integer" } }), [{ n: 1e3 }]],
|
||||
["required-missing-props", { type: "object", required: ["a"] }, [{}, { a: null }]],
|
||||
["string-to-number-trim", o({ n: { type: "number" } }), [{ n: " 12 " }, { n: "0x10" }, { n: "1e3" }, { n: "" }]],
|
||||
];
|
||||
|
||||
const tree = {
|
||||
type: "object",
|
||||
properties: { t: { $ref: "#/$defs/Tree" } },
|
||||
$defs: { Tree: { anyOf: [{ type: "string" }, { type: "array", items: { $ref: "#/$defs/Tree" } }] } },
|
||||
};
|
||||
const json = {
|
||||
type: "object",
|
||||
properties: { data: { $ref: "#/$defs/Json" } },
|
||||
required: ["data"],
|
||||
$defs: {
|
||||
Json: {
|
||||
anyOf: [
|
||||
{ type: "string" },
|
||||
{ type: "number" },
|
||||
{ type: "boolean" },
|
||||
{ type: "array", items: { $ref: "#/$defs/Json" } },
|
||||
{ type: "object", additionalProperties: { $ref: "#/$defs/Json" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const filter = {
|
||||
type: "object",
|
||||
properties: { where: { $ref: "#/$defs/F" } },
|
||||
$defs: {
|
||||
F: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { field: { type: "string" }, eq: { type: "string" } },
|
||||
required: ["field", "eq"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { and: { type: "array", items: { $ref: "#/$defs/F" } } },
|
||||
required: ["and"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const recursionCases: [string, object, unknown][] = [
|
||||
["tree valid", tree, { t: ["a", ["b"]] }],
|
||||
["tree number", tree, { t: 5 }],
|
||||
["tree [5]", tree, { t: [5] }],
|
||||
["json valid", json, { data: { a: [1, "x", { b: true }] } }],
|
||||
["json null", json, { data: null }],
|
||||
["json nested null", json, { data: { a: [1, null] } }],
|
||||
["filter valid", filter, { where: { and: [{ field: "a", eq: "b" }] } }],
|
||||
["filter bad leaf", filter, { where: { and: [{ field: "a", eq: 1, x: 2 }] } }],
|
||||
["filter deep bad", filter, { where: { and: [{ and: [{ and: [{ and: [{ and: [{ field: 1 }] }] }] }] }] } }],
|
||||
];
|
||||
|
||||
const expected: Record<string, string> = {
|
||||
'cyclic {"tree":{"name":"a","children":[{"name":"b","children":[]}]}}':
|
||||
'ok {"tree":{"name":"a","children":[{"name":"b","children":[]}]}}',
|
||||
'cyclic {"tree":{"name":1,"children":[]}}': 'ok {"tree":{"name":"1","children":[]}}',
|
||||
'ref-with-$id {"a":{"x":"1"}}': "REJECT - a: unresolved reference A",
|
||||
"function {}": "ok {}",
|
||||
'function {"f":"x"}': "REJECT - f: expected function, got string",
|
||||
'codec {"c":"12"}': 'ok {"c":"12"}',
|
||||
'codec {"c":12}': 'ok {"c":"12"}',
|
||||
"undefined-optional {}": "ok {}",
|
||||
'undefined-optional {"u":null}': "ok {}",
|
||||
'bigint {"n":1}': "REJECT - n: expected bigint, got integer",
|
||||
'bigint {"n":"12"}': "REJECT - n: expected bigint, got string",
|
||||
'refine {"n":5}': 'ok {"n":5}',
|
||||
'refine {"n":-5}': 'ok {"n":-5}',
|
||||
'template-literal {"t":"id-12"}': 'ok {"t":"id-12"}',
|
||||
'template-literal {"t":"id-x"}': "REJECT - t: expected to match ^id--?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?$",
|
||||
'template-literal {"t":3}': "REJECT - t: expected to match ^id--?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?$",
|
||||
'record-string {"r":{"a":"1"}}': 'ok {"r":{"a":1}}',
|
||||
'record-string {"r":{"a":1,"b":"x"}}': "REJECT - r.b: expected number, got string",
|
||||
'record-literal-keys {"r":{"a":1,"b":2}}': 'ok {"r":{"a":1,"b":2}}',
|
||||
'record-literal-keys {"r":{"a":1}}': "REJECT - r.b: missing",
|
||||
'record-literal-keys {"r":{"a":1,"b":2,"c":3}}': 'ok {"r":{"a":1,"b":2,"c":3}}',
|
||||
'intersect {"a":"1","b":"true"}': 'ok {"a":1,"b":true}',
|
||||
'intersect {"a":1,"b":true,"c":1}': 'ok {"a":1,"b":true,"c":1}',
|
||||
'union-objects {"v":{"k":"x","n":"2"}}': 'ok {"v":{"k":"x","n":2}}',
|
||||
'union-objects {"v":{"k":"y","s":2}}': 'ok {"v":{"k":"y","s":"2"}}',
|
||||
'minLength-astral {"s":"😀"}': "REJECT - s: expected at least 2 characters",
|
||||
'maxLength-astral {"s":"😀"}': 'ok {"s":"😀"}',
|
||||
'pattern-astral {"s":"😀"}': 'ok {"s":"😀"}',
|
||||
'pattern-class-dash {"s":"a-b"}': 'ok {"s":"a-b"}',
|
||||
'pattern-unicode-prop {"s":"äb"}': 'ok {"s":"äb"}',
|
||||
'pattern-unicode-prop {"s":"p{L}"}': "REJECT - s: expected to match ^\\p{L}+$",
|
||||
'multipleOf-float {"n":0.07}': 'ok {"n":0.07}',
|
||||
'multipleOf-float {"n":1.1}': 'ok {"n":1.1}',
|
||||
'uniqueItems-keyorder {"a":[{"x":1,"y":2},{"y":2,"x":1}]}': "REJECT - a: expected unique items",
|
||||
'uniqueItems-keyorder {"a":[1,1]}': "REJECT - a: expected unique items",
|
||||
'const-object {"c":"a"}': 'ok {"c":"a"}',
|
||||
'formats {"e":"a@b.co"}': 'ok {"e":"a@b.co"}',
|
||||
'formats {"e":"not-an-email"}': "REJECT - e: expected format email",
|
||||
'formats {"u":"relative/path"}': "REJECT - u: expected format uri",
|
||||
'formats {"u":"https://x.y/z"}': 'ok {"u":"https://x.y/z"}',
|
||||
'formats {"d":"2024-01-01T00:00:00Z"}': 'ok {"d":"2024-01-01T00:00:00Z"}',
|
||||
'formats {"d":"2024-01-01"}': "REJECT - d: expected format date-time",
|
||||
'formats {"x":"anything"}': 'ok {"x":"anything"}',
|
||||
'formats {"h":"under_score.example"}': "REJECT - h: expected format hostname",
|
||||
'formats {"r":"["}': "REJECT - r: expected format regex",
|
||||
'formats {"i":"00000000-0000-0000-0000-000000000000"}': 'ok {"i":"00000000-0000-0000-0000-000000000000"}',
|
||||
'formats {"i":"not"}': "REJECT - i: expected format uuid",
|
||||
'formats {"ip":"999.1.1.1"}': "REJECT - ip: expected format ipv4",
|
||||
'integer-big {"n":1e+21}': 'ok {"n":1e+21}',
|
||||
'integer-big {"n":9007199254740994}': 'ok {"n":9007199254740994}',
|
||||
'additional-false {"a":"x","b":1}': "REJECT - b: unknown field",
|
||||
"prototype-key {}": "ok {}",
|
||||
'prototype-key {"constructor":1}': 'ok {"constructor":"1"}',
|
||||
'proto-key-value {"__proto__":"x","a":"y"}': 'ok {"__proto__":["x"],"a":["y"]}',
|
||||
'mcp-draft7-ref-definitions {"t":{"path":"a"}}': 'ok {"t":{"path":"a"}}',
|
||||
'mcp-draft7-ref-definitions {"t":{"path":42}}': 'ok {"t":{"path":"42"}}',
|
||||
'mcp-ref-unresolved {"t":1}': "REJECT - t: unresolved reference #/definitions/Missing",
|
||||
"mcp-ref-unresolved {}": "ok {}",
|
||||
'mcp-ref-external {"t":1}': "REJECT - t: unresolved reference https://example.com/schemas/t.json",
|
||||
'mcp-nullable-openapi {"s":null}': 'ok {"s":"null"}',
|
||||
'mcp-nullable-openapi {"s":"x"}': 'ok {"s":"x"}',
|
||||
'mcp-draft7-tuple {"p":[1,"a"]}': 'ok {"p":[1,"a"]}',
|
||||
'mcp-draft7-tuple {"p":[1,"a",3]}': "REJECT - p.2: no value is allowed here",
|
||||
'mcp-draft7-tuple {"p":["1",2]}': 'ok {"p":[1,"2"]}',
|
||||
'mcp-draft7-ref-sibling {"s":"abc"}': "REJECT - s: expected at most 1 characters",
|
||||
'mcp-draft4-exclusive-bool {"n":0}': 'ok {"n":0}',
|
||||
'mcp-draft4-exclusive-bool {"n":1}': 'ok {"n":1}',
|
||||
'mcp-id-draft4 {"a":"x"}': 'ok {"a":"x"}',
|
||||
'mcp-bad-pattern {"s":"y"}': 'ok {"s":"y"}',
|
||||
'mcp-invalid-regex {"s":"y"}': "THROW Invalid regular expression: /[/: Unterminated character class",
|
||||
'mcp-type-integer-float-0 {"n":1}': 'ok {"n":1}',
|
||||
'mcp-type-integer-float-0 {"n":1.5}': 'ok {"n":1}',
|
||||
'mcp-format-unknown {"s":"xx"}': 'ok {"s":"xx"}',
|
||||
'mcp-required-null-string {"q":null}': 'ok {"q":"null"}',
|
||||
'mcp-array-json-string {"p":"[\\"a\\",\\"b\\"]"}': 'ok {"p":["[\\"a\\",\\"b\\"]"]}',
|
||||
'mcp-integer-float {"n":3.7}': 'ok {"n":3}',
|
||||
'mcp-integer-float {"n":"3.7"}': 'ok {"n":3}',
|
||||
'mcp-empty-schema {"a":1}': 'ok {"a":1}',
|
||||
'mcp-type-object-no-props {"a":1}': 'ok {"a":1}',
|
||||
'mcp-boolean-true-prop {"a":1}': 'ok {"a":1}',
|
||||
'mcp-boolean-true-prop {"b":1}': "REJECT - b: no value is allowed here",
|
||||
'mcp-huge-enum {"e":"v4999"}': 'ok {"e":"v4999"}',
|
||||
'mcp-huge-enum {"e":"v5000"}':
|
||||
'REJECT - e: expected one of ["v0","v1","v2","v3","v4","v5","v6","v7","v8","v9","v10","v11","v12","v13","v14","v15","v16","v17","v18","v19", ... and 4980 more]',
|
||||
'oneOf-overlap {"v":"abc"}': "REJECT - v: matches more than one alternative",
|
||||
'oneOf-overlap {"v":"abcdefgh"}': 'ok {"v":"abcdefgh"}',
|
||||
'oneOf-number-integer {"v":2}': "REJECT - v: matches more than one alternative",
|
||||
'oneOf-number-integer {"v":2.5}': 'ok {"v":2.5}',
|
||||
'oneOf-objects-open {"v":{"a":"x"}}': "REJECT - v: matches more than one alternative",
|
||||
'oneOf-objects-open {"v":{}}': "REJECT - v: matches more than one alternative",
|
||||
'if-then-else {"kind":"a"}': "REJECT - n: missing",
|
||||
'if-then-else {"kind":"b"}': 'ok {"kind":"b"}',
|
||||
'dependentRequired {"a":"x"}': "REJECT - b: required when a is present",
|
||||
'dependencies-d7 {"a":"x"}': "REJECT - b: required when a is present",
|
||||
'propertyNames {"abc":1}': 'ok {"abc":1}',
|
||||
'propertyNames {"ABC":1}': "REJECT - ABC: invalid property name",
|
||||
'contains {"l":["a"]}': "REJECT - l: expected at least 1 matching item",
|
||||
'contains {"l":["a",1]}': 'ok {"l":["a",1]}',
|
||||
'not {"s":"x"}': 'ok {"s":"x"}',
|
||||
'not {"s":null}': "ok {}",
|
||||
'minProperties {"m":{}}': "REJECT - m: expected at least 1 properties",
|
||||
'unevaluatedProperties {"a":"x"}': 'ok {"a":"x"}',
|
||||
'unevaluatedProperties {"a":"x","b":1}': "REJECT - b: unknown field",
|
||||
'allOf-additional-false {"a":"x"}': 'ok {"a":"x"}',
|
||||
'allOf-additional-false {"a":"x","b":"y"}': "REJECT - b: unknown field",
|
||||
'const-object {"c":{"y":2,"x":1}}': 'ok {"c":{"y":2,"x":1}}',
|
||||
'enum-object {"e":{"y":2,"x":1}}': 'ok {"e":{"y":2,"x":1}}',
|
||||
'type-array-multi {"v":true}': 'ok {"v":"true"}',
|
||||
'type-array-multi {"v":null}': "ok {}",
|
||||
'nullable-type-array {"v":null}': 'ok {"v":null}',
|
||||
'nullable-type-array {"v":5}': 'ok {"v":"5"}',
|
||||
'exclusiveMinimum-number {"n":0}': "REJECT - n: expected > 0",
|
||||
'exclusiveMinimum-number {"n":"0"}': "REJECT - n: expected > 0",
|
||||
'maxItems {"l":[1,2]}': "REJECT - l: expected at most 1 items",
|
||||
'maxItems {"l":"x"}': 'ok {"l":["x"]}',
|
||||
'discriminator-openapi {"p":{"kind":"a","x":"1"}}': 'ok {"p":{"kind":"a","x":1}}',
|
||||
'discriminator-openapi {"p":{"kind":"b"}}': 'ok {"p":{"kind":"b"}}',
|
||||
'ref-root-defs {"a":"x"}': 'ok {"a":"x"}',
|
||||
'ref-root-defs {"a":1}': 'ok {"a":"1"}',
|
||||
"ref-root-defs {}": "REJECT - a: missing",
|
||||
'ref-recursive {"root":{"v":"2","c":[{"v":"3","c":[]}]}}': 'ok {"root":{"v":2,"c":[{"v":3,"c":[]}]}}',
|
||||
'ref-percent {"a":"x"}': 'ok {"a":"x"}',
|
||||
'ref-percent {"a":1}': 'ok {"a":"1"}',
|
||||
'ref-tilde {"a":"x"}': 'ok {"a":"x"}',
|
||||
'defs-with-$id {"a":"x"}': 'ok {"a":"x"}',
|
||||
'defs-with-$id {"a":2}': 'ok {"a":"2"}',
|
||||
'anyOf-null-default {"v":null}': 'ok {"v":null}',
|
||||
"anyOf-null-default {}": "ok {}",
|
||||
'format-date-time-lower {"d":"2024-01-01t00:00:00z"}': 'ok {"d":"2024-01-01t00:00:00z"}',
|
||||
'format-date-time-lower {"d":"2024-01-01 00:00:00Z"}': "REJECT - d: expected format date-time",
|
||||
'format-url {"u":"not a url"}': "REJECT - u: expected format url",
|
||||
'integer-as-float-1e0 {"n":1000}': 'ok {"n":1000}',
|
||||
"required-missing-props {}": "REJECT - a: missing",
|
||||
'required-missing-props {"a":null}': 'ok {"a":null}',
|
||||
'string-to-number-trim {"n":" 12 "}': 'ok {"n":12}',
|
||||
'string-to-number-trim {"n":"0x10"}': 'ok {"n":16}',
|
||||
'string-to-number-trim {"n":"1e3"}': 'ok {"n":1000}',
|
||||
'string-to-number-trim {"n":""}': 'ok {"n":0}',
|
||||
"tree valid": 'ok {"t":["a",["b"]]}',
|
||||
"tree number": 'ok {"t":"5"}',
|
||||
"tree [5]": 'ok {"t":["5"]}',
|
||||
"json valid": 'ok {"data":{"a":[1,"x",{"b":true}]}}',
|
||||
"json null": 'ok {"data":"null"}',
|
||||
"json nested null": 'ok {"data":{"a":[1,"null"]}}',
|
||||
"filter valid": 'ok {"where":{"and":[{"field":"a","eq":"b"}]}}',
|
||||
"filter bad leaf": "REJECT - where.and.0.eq: expected string, got integer",
|
||||
"filter deep bad": "REJECT - where.and.0.and.0.and.0.and.0.and.0.eq: missing",
|
||||
};
|
||||
|
||||
/** Every case with the key of its expected outcome: the name and the arguments, or the name alone for recursion. */
|
||||
const all: Array<[string, string, object, unknown]> = [
|
||||
...cases.flatMap(({ name, schema, args }) =>
|
||||
args.map((arg): [string, string, object, unknown] => [`${name} ${JSON.stringify(arg)}`, name, schema, arg]),
|
||||
),
|
||||
...keywordCases.flatMap(([name, schema, args]) =>
|
||||
args.map((arg): [string, string, object, unknown] => [`${name} ${JSON.stringify(arg)}`, name, schema, arg]),
|
||||
),
|
||||
...recursionCases.map(([name, schema, arg]): [string, string, object, unknown] => [name, name, schema, arg]),
|
||||
];
|
||||
|
||||
describe("reviewer probes", () => {
|
||||
for (const [key, name, schema, arg] of all) {
|
||||
it(key, () => {
|
||||
expect(outcome(name, schema, arg)).toBe(expected[key]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("reviewer probes (Sol)", () => {
|
||||
const tree = {
|
||||
type: "object",
|
||||
properties: { t: { $ref: "#/$defs/Tree" } },
|
||||
$defs: { Tree: { anyOf: [{ type: "string" }, { type: "array", items: { $ref: "#/$defs/Tree" } }] } },
|
||||
};
|
||||
const solCases: Array<[string, object, unknown, string]> = [
|
||||
// TypeBox-only: Type.BigInt and Type.Undefined have no JSON form; TypeBox converted to them.
|
||||
[
|
||||
"BigInt from a JSON string",
|
||||
Type.Object({ n: Type.BigInt() }),
|
||||
{ n: "42" },
|
||||
"REJECT - n: expected bigint, got string",
|
||||
],
|
||||
[
|
||||
"Undefined from JSON null",
|
||||
Type.Object({ n: Type.Undefined() }),
|
||||
{ n: null },
|
||||
"REJECT - n: expected undefined, got null",
|
||||
],
|
||||
// TypeBox-only: its additionalProperties also converted declared properties; pi-schema converts only undeclared ones.
|
||||
[
|
||||
"additionalProperties on declared properties",
|
||||
Type.Object({ amount: Type.Number(), label: Type.String() }, { additionalProperties: Type.Integer() }),
|
||||
{ amount: 3.5, label: "7" },
|
||||
'ok {"amount":3.5,"label":"7"}',
|
||||
],
|
||||
// TypeBox-only: property names are names, not regular expressions.
|
||||
[
|
||||
"property names are not patterns",
|
||||
Type.Object({ "a.b": Type.Integer(), axb: Type.String() }),
|
||||
{ "a.b": "4", axb: "5.8" },
|
||||
'ok {"a.b":4,"axb":"5.8"}',
|
||||
],
|
||||
// TypeBox-only: Refine runs only in TypeBox's own validator.
|
||||
[
|
||||
"Refine is not run",
|
||||
Type.Object({ mode: Type.Refine(Type.String(), (value) => value === "safe") }),
|
||||
{ mode: "unsafe" },
|
||||
'ok {"mode":"unsafe"}',
|
||||
],
|
||||
[
|
||||
"scalar-or-array that refers to itself",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { anyOf: [{ type: "number" }, { type: "array", items: { $ref: "#/properties/value" } }] },
|
||||
},
|
||||
required: ["value"],
|
||||
},
|
||||
{ value: "42" },
|
||||
'ok {"value":42}',
|
||||
],
|
||||
[
|
||||
"StringEnum with an empty member from null",
|
||||
Type.Object({ mode: Type.Unsafe({ type: "string", enum: ["", "x"] }) }),
|
||||
{ mode: null },
|
||||
'ok {"mode":""}',
|
||||
],
|
||||
[
|
||||
"Cyclic with named local definitions",
|
||||
Type.Object({
|
||||
node: Type.Cyclic(
|
||||
{ Node: Type.Object({ n: Type.Number(), next: Type.Optional(Type.Ref("Node")) }) },
|
||||
"Node",
|
||||
),
|
||||
}),
|
||||
{ node: { n: 1 } },
|
||||
'ok {"node":{"n":1}}',
|
||||
],
|
||||
[
|
||||
"unpaired high surrogate, minLength 2",
|
||||
Type.Object({ s: Type.String({ minLength: 2 }) }),
|
||||
{ s: "\ud800A" },
|
||||
'ok {"s":"\\ud800A"}',
|
||||
],
|
||||
[
|
||||
"unpaired high surrogate, maxLength 1",
|
||||
Type.Object({ s: Type.String({ maxLength: 1 }) }),
|
||||
{ s: "\ud800A" },
|
||||
"REJECT - s: expected at most 1 characters",
|
||||
],
|
||||
["integer from a string", Type.Object({ n: Type.Integer() }), { n: "42" }, 'ok {"n":42}'],
|
||||
["recursive union with a value no branch converts", tree, { t: {} }, "REJECT - t: expected string, got object"],
|
||||
];
|
||||
for (const [name, schema, arg, result] of solCases) {
|
||||
it(name, () => {
|
||||
expect(outcome(name, schema, arg)).toBe(result);
|
||||
});
|
||||
}
|
||||
|
||||
it("accepts the last member of a 10,000-value enum", () => {
|
||||
const values = Array.from({ length: 10_000 }, (_, i) => `member${i}`);
|
||||
const schema = { type: "object", properties: { choice: { type: "string", enum: values } }, required: ["choice"] };
|
||||
expect(outcome("enum", schema, { choice: "member9999" })).toBe('ok {"choice":"member9999"}');
|
||||
expect(outcome("enum", schema, { choice: "x" })).toBe(
|
||||
`REJECT - choice: expected one of ${JSON.stringify(values.slice(0, 20)).slice(0, -1)}, ... and 9980 more]`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the validator of a schema object, as before, also when the schema is changed later", () => {
|
||||
const parameters = Type.Object({ n: Type.Number({ minimum: 0 }) });
|
||||
expect(outcome("late", parameters, { n: "4" })).toBe('ok {"n":4}');
|
||||
Object.assign(parameters.properties.n, { minimum: 10 });
|
||||
expect(outcome("late", parameters, { n: "4" })).toBe('ok {"n":4}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviewer probes (round 2)", () => {
|
||||
const item = { type: "object", properties: { n: { type: "integer", minimum: 1 } } };
|
||||
const nested: Case[] = [
|
||||
{ name: "declared", schema: { type: "object", properties: { entry: item } }, args: [{ entry: { n: null } }] },
|
||||
{ name: "additional", schema: { type: "object", additionalProperties: item }, args: [{ entry: { n: null } }] },
|
||||
{
|
||||
name: "pattern",
|
||||
schema: { type: "object", patternProperties: { "^entry$": item }, additionalProperties: false },
|
||||
args: [{ entry: { n: null } }],
|
||||
},
|
||||
];
|
||||
for (const { name, schema, args } of nested) {
|
||||
it(`drops an optional null inside ${name} property values`, () => {
|
||||
expect(outcome(name, schema, args[0])).toBe('ok {"entry":{}}');
|
||||
});
|
||||
}
|
||||
|
||||
for (const keyword of ["anyOf", "oneOf"]) {
|
||||
it(`converts a ${keyword} branch together with the node's sibling properties`, () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
[keyword]: [
|
||||
{ type: "object", properties: { a: { type: "array", items: { type: "integer" } } }, required: ["a"] },
|
||||
{ type: "object", properties: { b: { type: "boolean" } }, required: ["b"] },
|
||||
],
|
||||
properties: { c: { type: "integer" } },
|
||||
required: ["c"],
|
||||
};
|
||||
expect(outcome(keyword, schema, { a: "3", c: "2" })).toBe('ok {"a":[3],"c":2}');
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects a deep value every branch of a recursive object union rejects, without exponential work", () => {
|
||||
const S = {
|
||||
anyOf: [
|
||||
{ type: "object", additionalProperties: { $ref: "#/$defs/S" }, required: ["a"] },
|
||||
{ type: "object", additionalProperties: { $ref: "#/$defs/S" }, required: ["b"] },
|
||||
],
|
||||
};
|
||||
const schema = { type: "object", properties: { s: { $ref: "#/$defs/S" } }, $defs: { S } };
|
||||
let value: unknown = { x: 1 };
|
||||
for (let i = 0; i < 40; i++) value = { c: value };
|
||||
expect(outcome("deep", schema, { s: value })).toBe("REJECT - s.a: missing");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { Type } from "typebox";
|
||||
import { Compile } from "typebox/compile";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { JsonValue, Tool, ToolCall } from "../src/types.ts";
|
||||
import { validateToolArguments } from "../src/utils/validation.ts";
|
||||
|
|
@ -61,7 +60,7 @@ describe("validateToolArguments", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("coerces serialized plain JSON schemas with AJV-compatible primitive rules", () => {
|
||||
it("coerces serialized plain JSON schemas like TypeBox schemas", () => {
|
||||
const passingCases: Array<{
|
||||
schema: Tool["parameters"];
|
||||
input: unknown;
|
||||
|
|
@ -75,7 +74,8 @@ describe("validateToolArguments", () => {
|
|||
{ schema: { type: "boolean" } as Tool["parameters"], input: "false", expected: false },
|
||||
{ schema: { type: "boolean" } as Tool["parameters"], input: 1, expected: true },
|
||||
{ schema: { type: "boolean" } as Tool["parameters"], input: 0, expected: false },
|
||||
{ schema: { type: "string" } as Tool["parameters"], input: null, expected: "" },
|
||||
// TypeBox's conversion rules come first: null is "null" for a required string.
|
||||
{ schema: { type: "string" } as Tool["parameters"], input: null, expected: "null" },
|
||||
{ schema: { type: "string" } as Tool["parameters"], input: true, expected: "true" },
|
||||
{ schema: { type: "null" } as Tool["parameters"], input: "", expected: null },
|
||||
{ schema: { type: "null" } as Tool["parameters"], input: 0, expected: null },
|
||||
|
|
@ -90,6 +90,11 @@ describe("validateToolArguments", () => {
|
|||
input: "1",
|
||||
expected: 1,
|
||||
},
|
||||
{ schema: { type: "boolean" } as Tool["parameters"], input: "1", expected: true },
|
||||
{ schema: { type: "boolean" } as Tool["parameters"], input: "0", expected: false },
|
||||
{ schema: { type: "null" } as Tool["parameters"], input: "null", expected: null },
|
||||
{ schema: { type: "integer" } as Tool["parameters"], input: "42.1", expected: 42 },
|
||||
{ schema: { type: "array", items: { type: "number" } } as Tool["parameters"], input: "7", expected: [7] },
|
||||
];
|
||||
|
||||
for (const testCase of passingCases) {
|
||||
|
|
@ -184,10 +189,6 @@ describe("validateToolArguments", () => {
|
|||
{ type: ["array", "null"], items: { type: "string" } } as Tool["parameters"],
|
||||
null,
|
||||
);
|
||||
// The CSP test above selects TypeBox's process-wide interpreted fallback, so exercise the generated validator explicitly.
|
||||
const generatedCheck = new Function(Compile(tool.parameters).Code())() as (value: unknown) => boolean;
|
||||
|
||||
expect(generatedCheck(toolCall.arguments)).toBe(true);
|
||||
expect(validateToolArguments(tool, toolCall)).toEqual({ value: null });
|
||||
});
|
||||
|
||||
|
|
@ -196,10 +197,10 @@ describe("validateToolArguments", () => {
|
|||
schema: Tool["parameters"];
|
||||
input: unknown;
|
||||
}> = [
|
||||
{ schema: { type: "boolean" } as Tool["parameters"], input: "1" },
|
||||
{ schema: { type: "boolean" } as Tool["parameters"], input: "0" },
|
||||
{ schema: { type: "null" } as Tool["parameters"], input: "null" },
|
||||
{ schema: { type: "integer" } as Tool["parameters"], input: "42.1" },
|
||||
{ schema: { type: "boolean" } as Tool["parameters"], input: "yes" },
|
||||
{ schema: { type: "number" } as Tool["parameters"], input: "abc" },
|
||||
{ schema: { type: "null" } as Tool["parameters"], input: "nil" },
|
||||
{ schema: { type: "integer" } as Tool["parameters"], input: "4x" },
|
||||
];
|
||||
|
||||
for (const testCase of failingCases) {
|
||||
|
|
@ -207,6 +208,25 @@ describe("validateToolArguments", () => {
|
|||
expect(() => validateToolArguments(tool, toolCall)).toThrow("Validation failed");
|
||||
}
|
||||
});
|
||||
it("lists pi-schema's errors, then the arguments it received", () => {
|
||||
const { tool, toolCall } = createToolCallWithPlainSchema({ type: "number" } as Tool["parameters"], "many");
|
||||
expect(() => validateToolArguments(tool, toolCall)).toThrow(
|
||||
'Validation failed for tool "echo":\n - value: expected number, got string\n\nReceived arguments:\n{\n "value": "many"\n}',
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a missing required property at the property", () => {
|
||||
const { tool, toolCall } = createToolCallWithPlainSchema({ type: "string" } as Tool["parameters"], "x");
|
||||
expect(() => validateToolArguments(tool, { ...toolCall, arguments: {} })).toThrow(" - value: missing");
|
||||
});
|
||||
|
||||
it("returns a copy of the arguments, also when nothing is coerced", () => {
|
||||
const { tool, toolCall } = createToolCallWithPlainSchema({ type: "string" } as Tool["parameters"], "x");
|
||||
const args = validateToolArguments(tool, toolCall);
|
||||
expect(args).toEqual({ value: "x" });
|
||||
expect(args).not.toBe(toolCall.arguments);
|
||||
});
|
||||
|
||||
// #10897
|
||||
it("reports malformed arguments JSON instead of validating empty arguments", () => {
|
||||
const tool: Tool = {
|
||||
|
|
|
|||
|
|
@ -13,5 +13,8 @@ export default defineConfig({
|
|||
},
|
||||
resolve: {
|
||||
alias: [{ find: /^@earendil-works\/pi-telemetry$/, replacement: telemetrySrcIndex }],
|
||||
// Workspace packages without a build (pi-schema) resolve to their source.
|
||||
conditions: ["source"],
|
||||
},
|
||||
ssr: { resolve: { conditions: ["source"] } },
|
||||
});
|
||||
|
|
@ -2,8 +2,24 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Chord v2's plugins component gains the plugin model (slice 12): `createPlugins({ api: { name, versions } })` checks every import of the app's versioned API (`@acme/app/v3`, per-kind modules such as `@acme/app/v3/web`): an unlisted version, another kind's module, two versions in one facet and a direct import of Chord are build errors naming the file and line. Roots may be `{ path, required }`: a required root's plugin failing at `load` rejects it with a `PluginError` after the load settled. Files named `<name>.<kind>.<ext>` (`contract.web.tsx`) are of that kind, refused in facets of another kind with the chain of imports named. Single-file plugins (`hello.server.ts` at a root's top, grouped by name, host packages only). `installPlugin({ git, ref } | { npm, version }, root, ctx)` installs plugins from git (a shallow clone of a branch, tag or commit; a repository of several plugins too) or npm; a package lists the plugin folders it provides in its `package.json` (`"chord": { "plugins": ["./notes", "plugins/*", "!plugins/draft"] }`), used by discovery and `installPlugin` (without it: one plugin when the package's top has facet files, else its subfolders with a `package.json`). `testPlugin(dir, { kind, modules, stubs, principal }, ctx)` builds a plugin as production does and loads it on an in-memory host beside stub facets, collecting its contributions to registries nobody serves and listing the contracts it waits for. Agents' guide: `docs/next/plugin-authoring.md`.
|
||||
- Chord v2 gains the app kit (slice 13, `src/next-node/app`, to be exported as `@earendil-works/chord/next/app`): `startServer<P>({ name, dataDir?, root?, listen?, api, kinds, principal, workers?, files?, install?, onError }, ctx)` lays out the data directory (`server/store.sqlite`, `workers/<kind>/<address>/`, `plugins/`, `logs/`, `owner-token`; one server per data directory), creates the server's store and host, HTTP, one workers component per worker kind and the plugins component over the app's required root and the user's root, and loads the server's plugins with the kit's objects; `startWorker({ api, kinds, busy?, onError })` is a worker's entry (its directory and store, the link to the server first, its plugins). The start-data objects `AppInfo` and `WorkerInfo` serve process values to plugins. Sessions (`Sessions`: `issue`, `verify`, `revoke`, `list`; tokens kept as SHA-256 hashes, compared in constant time; persisted ones end after 400 days unused and their browser cookie is renewed daily while used, ephemeral ones live in memory, at most `limits.ephemeralSessions`) authenticate a signed `HttpOnly`, `SameSite=Strict` cookie or `Authorization: Bearer`; a one-time owner link per start (`app.ownerLink()`, the token in the URL's fragment, single use, 10 minutes), an owner token file (mode 0600, written atomically) and device pairing (`POST /_chord/pair`, owner approval, a poll returning the token once; bounded in all and per client address) sign the owner in. The data directory, its stores and secret files are made private (0700, 0600) at every start; the home directory, a file system's root and another user's directory are refused. `Files` per host: small and chunked writes at a stored offset, ranged reads, a watchable index, caps, at most `limits.uploads` uploads under way. `app.exportData(address, path, ctx)` and `app.importData(archive, address, ctx)` move the data a worker created (its store and files) as a tar without stopping anything: a running worker writes its own store at one consistent point, else the server reads it under the worker's process lock (import refusing an existing address, another app's archive, every path leaving the target, and archives past `limits.importBytes` or `limits.importEntries`). `app.applyChanges(ctx)` and `app.plugins.install(source, ctx)`. Optional plugins as exported facets: `defineUsers(profile)`, `inviteFacet()`, `ephemeralLoginFacet()`.
|
||||
- Chord v2 gains web and terminal surfaces (slice 11): `webSurface` and `tuiSurface` (`src/next-node/surfaces`) build a client kind's base bundle from the app's entry at start (content-hashed, served forever) and publish its plugins' facets as scripts that register themselves (`chordFacet("<plugin>", "<digest>", function (require, module, exports) {…})`) and one combined file, with `WebScripts` and `TuiScripts` showing the current set (a rollout step publishes without waiting for clients) and the page served through the app's own template (`PageAssets`, `pageScripts`). `startPage` (`src/next-web`) runs a page's client host: the connection loop (`keepConnected`: 250 ms doubling to 5 s, wakes, silent sockets, `canRetry`), plugins applied in place on a new set (a failed one keeps its old facet), a reload when the server's base bundle changed, and the active theme as CSS variables. React bindings (`src/next-react`): `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState` (React is an optional peer dependency). The terminal: `startTerminal` (`src/next-node/tui`) signs in with a token, a token file or device pairing and runs the app's terminal code always in a sandbox (a child process on pipes with Node's permission model: no file system, terminal, processes, workers, network or SQLite; Node 26.5.1 or later), filtering its output, relaying its link and confirming its actions (copy, open a URL, attach a file the user names); `startTerminalRuntime` and `TuiScope` (`src/next-tui`) inside it. Themes: the `Themes` registry, `ThemeChoice` (one dark and one light pick per principal), `followActiveTheme`, `themeVariables`, `terminalColor`. `startServer` gains `web` and `tui`. Plugin builds scope `*.module.css` files per plugin and file. Ephemeral sessions end with their client's last link (after a minute). The HTTP component's links endpoint gains an `opened` hook.
|
||||
- Chord v2's `HttpRequest` gains `remoteAddress`, the peer's address as the listener knows it (for limits per client).
|
||||
- Chord v2's HTTP component gains `readSignedCookie(request, name, secret)`: `readCookie`'s value with its signed expiry, for renewing a cookie before it expires.
|
||||
|
||||
### Changed
|
||||
|
||||
- Chord v2's HTTP component denies by default (slice 13): a request or plugin socket without a principal gets 401 before plugin code runs unless its route or socket is marked `public: true` (`{ handler, public: true }`; a `crossSite` route is public), and the `/chord` links endpoint refuses requests without a principal.
|
||||
- Chord v2 plugins import each other by relative path within a root (copied into the importer's script) and across repositories by package name through the package's `exports`, pinned in `dependencies`; slice 10's imports by plugin name (`@app/notes/contract`) are removed, and a relative path leaving the root is a build error.
|
||||
|
||||
## [1.5.0] - 2026-11-02
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Schemas are JSON Schema checked by `@earendil-works/pi-schema` instead of TypeBox, which is no longer a dependency of Chord. Contracts, `self.state(...)` schemas and method signatures take pi-schema builders or any JSON Schema object (TypeBox-built schemas still validate, being JSON Schema, but type as `unknown`); handler and reference types come from pi-schema's `Static`. Validation, input defaults and the stripping of unknown fields work on any JSON Schema by its shape (before, defaults and stripping applied only to TypeBox-built schemas). `format` is an annotation, no longer checked: `str({ format: "email" })` accepts `"nope"` (TypeBox checked the formats it knew); check a format in the handler where it matters. An `invalid` error's message is now `<path> <message>` with pi-schema's paths and messages (a missing field at its own path, `missing`; `expected string, got number` instead of `must be string`), and its `details` are pi-schema's `ValidationError`s (`{ path, keyword, message }`) instead of TypeBox's errors. Link frames, descriptions, fingerprints and identities are unchanged for schemas with the same JSON. Migration, keeping a contract's fingerprint (it is computed from the schema's JSON): `Type.String/Number/Integer/Boolean/Null/Unknown/Array/Union/Optional(...)` become `str/num/int/bool/nul/unknown/arr/union/opt(...)` with the same options; `Type.Object(p)` becomes `obj(p, { open: true })` (pi-schema's `obj` is closed by default) and `Type.Object(p, { additionalProperties: false })` `obj(p)`; `Type.Enum(values)` becomes `oneOf(values)`; `Type.Literal(v)` becomes `{ type: typeof v, const: v }` (pi-schema's `lit(v)` has no `type`); `Type.Record(Type.String(), v)` becomes `{ type: "object", patternProperties: { "^.*$": v } }` (pi-schema's `map(v)` uses `additionalProperties`); `Type.Intersect(list)` becomes `{ allOf: list }`; `Type.Function(params, result)` and `Type.Void()` become `{ type: "function", parameters, returnType }` and `{ type: "void" }`; `Type.Unsafe<T>(schema)` becomes `schema as Schema<T>`; `Type.Immutable(schema)` becomes the schema (cast its `Schema<readonly T[]>` type); `opt(...)` marks only an `obj` property; `Static` and `TSchema` come from pi-schema (`Static`, `Schema`). pi-schema's own `lit`, `map` and closed `obj` give other JSON and so other fingerprints, which matters only while peers of both versions talk.
|
||||
- `traceIn(options, fn, context)` takes the context last (was `traceIn(context, options, fn)`), like the rest of the API.
|
||||
- Watch listeners take one change object before the context: `WatchHandle.start((change, context) => …)` with `change: WatchChange<T>` = `{ value, ops, retired }`, and `NativeWatchHandle.start(({ value }, context) => …)` with `NativeWatchChange<T>`. A change is plain data, those fields only; the frame's context is the second argument. `Watch.advance(change, context)` takes the same object, and a frame delivered alone, with nothing folded into it, reaches the listener as the object the source passed. Migration: `start(async (value, ops, ctx) => …)` becomes `start(async ({ value, ops }, ctx) => …)`, `start(async (value, ctx) => …)` on a native watch `start(async ({ value }, ctx) => …)`, and `advance(value, ops, ctx)` becomes `advance({ value, ops, retired: false }, ctx)`.
|
||||
- `SqliteExecutor.runCounted(sql, ...params)` runs one statement like `run` and resolves with the rows it inserted, updated or deleted, as SQLite's `changes()` counts them (index writes and rows changed by triggers do not count). The Node driver takes it from `node:sqlite`; the Durable Object driver reads `SELECT changes()` after the statement (the cursor's `rowsWritten` also counts index writes), so `run` stays as cheap as before. Migration: custom `SqliteDatabase` and `SqliteExecutor` implementations add `runCounted`.
|
||||
|
|
@ -13,7 +29,11 @@
|
|||
|
||||
### Added
|
||||
|
||||
- Chord v2's store (`@earendil-works/chord/next`) gains `ChordStore.commitSeries(commits, receive, ctx)`: several commits in one backend transaction, each at its own seq with its own outbox row, the store metadata written once. Each commit's check runs on a step that sees the commits before it, before its writes; a check that throws refuses that commit and stores the ones before it, while a failed statement or write rolls the whole series back, and a failed `COMMIT` stops the store. It replaces `commitInBatch`, which is removed. Durable's `StoreStorage` stores each Session commit call through it.
|
||||
- Chord v2 has a package export again, `@earendil-works/chord/next` (experimental): every layer's API for apps (contracts, data, documents, hosts, jobs, links, state, the store), and, marked internal, the store's surface for adapters (`StoreOwnerTables`, `OwnerTable`, the documents engine's reads and writes, the commit step, `applyBatch`). v2's store gains one id allocator for every id in it, claims per table and per document namespace, Durable's Storage semantics for a refused commit (it rejects that commit only; an unknown outcome stops the store), document histories `none`, `latest` and `rewindable` with reads at a point, archives carrying document revisions, and batch apply; v2 objects gain `self.follow` (another runtime's synchronous observers as sources) and persisted jobs (`persist: { resume }`, a stored `abort(id, ctx)`, deferred state changes).
|
||||
- Chord v2 (`@earendil-works/chord/next`) gains registries, writables, wake signals and root scopes (slice 8): `defineRegistry<C, V>(name, { version })` tokens shared by plugins, served by the defining plugin's facet with `host.serve(R, { compose, accepts? })`; contributions from facets (`defineFacet((host, self) => self.contribute(R, value))`) and host-lifetime objects (`StoreSelf.contribute`), plain values or Chord watchables, in a table sorted by plugin name and composed lazily (once at the end of `load`'s setups, then when read or followed, in the batch that changed the table); readers through `self.registry(R)` (a declared dependency: an object activates once the registry has a value, and fails `transient` with the new reason `unavailable/undeclared` when nobody serves it) and `host.registry(R, ctx)`, a `RegistryValue<V>` with `observe(observer, ctx)`; older contributions and readers adapted by `accepts`; the helpers `keyedEntries` and `orderedEntries`; the `onError` kinds `registry_conflict`, `registry_failed`, `contribution_failed` and `contribution_refused`. `self.writable(initial)` is an in-process value an attempt sets (identity compared); native sources (writables, registry values, `ref.state`) are derive sources. `self.retryOn(...sources)` retries a failed object when a registry value changes. `host.root(fn, context, { principal? })` runs code outside objects with a handler's `ctx`, sealed when `fn` settles, `context` aborts or the host closes.
|
||||
- Chord v2 gains plugins (slice 7c): `src/next-node/plugins` (Node, to be exported as `@earendil-works/chord/next/plugins`) finds plugin directories, builds each facet with esbuild (now an optional peer dependency of `@earendil-works/chord`, imported only there), evaluates it with `vm.Script`, maps plugin stack traces (`mapStack`), installs plugin dependencies, watches plugin and base code, and rolls reloads out across hosts. `host.load` and `host.reload` work per plugin: a plugin whose setup, factory or declarations fail is left out at `load` (`{ failed }`) or kept on its old code at `reload` (`failed` in the report, `reload(facets, ctx, { failed })` for build and evaluation failures), with a closure rule keeping a consumer old when its provider was kept old; an abort after a reload's commit rejects with `details.committed`.
|
||||
- Chord v2 gains HTTP and workers (slice 10): `src/next-node/http` (to be exported as `@earendil-works/chord/next/http`) builds a Web-standard `fetch` handler from the routes, wraps, sockets, hosts, relays and CSP sources plugins contribute to the registry `Http`, with authenticators from the registry `Authenticators`, responses as descriptions (`json`, `html`, `bytes` with ranges, `redirect`, `error`; `fileResponse`), and a security baseline on by default (provenance by the socket and `Host`, foreign requests anonymous, `Origin` checks, default headers with a per-response CSP nonce, signed cookies, safe downloads); Chord's own Node listener as a host-lifetime object, `nodeRequest` for apps mounting it on their own Node server, the links endpoint and plugin sockets with a heartbeat, closed at once when their contribution leaves. `ws` is an optional peer dependency, imported only there. `src/next-node/workers` (`createWorkers`, `workerMain`) runs a forked worker per address of a host kind, started on demand, stopped when idle, never restarted by itself (a failure stays until `retry` or a rollout), its plugins built by the server and shipped. The plugins component defines one definition per facet kind (`kinds: { name: { platform, hostPackages } }`, modules given at `plugins.load(host, { kind, core, modules })`), splits loading into `createPluginRuntime()` (no esbuild), offers `scripts(kind)`, rolls out with one result per host (`RolloutStep` returns `{ hosts }`), and resolves cross-plugin imports by plugin name (`@app/notes/contract`). Kernel: `host.route` returns a `RouteHandle` (`retry`, `suspend`) and a route has no backoff timer (a failed `resolve` fails its holders `waiting`); `host.root(fn, ctx, { principal: undefined })` runs anonymous; a link reads `origin` only on parent and route links (a protocol error on a child link) and drops a per-origin record once it is idle.
|
||||
- Chord v2, experimental and not exported yet: the next object runtime in `src/next` (contracts, hosts and objects, documents, derives, links and relays, routines and jobs, the store), with its tests and runnable examples in `test/next` and its design in `docs/next` (`design.md`, the decision log `decisions.md`, the slice plan `slices.md`). Its API changes with every slice until it gets an entry point.
|
||||
- `NativeWatchable<T>`: an in-process value that changes and can be watched (`value`, `watch(context)` returning a `NativeWatchHandle<T>`), the in-process counterpart of `Watchable<T>`.
|
||||
- `WatchHandle<T>` and `WatchEnd`, the one watch API, moved from Durable: a serialized observation with `value`, `start(listener)`, `stop()`, and `closed`; the listener gets a `WatchChange<T>` (`{ value, ops, retired }`) and the context. `WatchEnd` reasons are `stopped`, `cancelled`, `ended`, `listener_error`, and `failed`.
|
||||
|
|
@ -26,11 +46,13 @@
|
|||
|
||||
### Changed
|
||||
|
||||
- Chord v2 (`@earendil-works/chord/next`) checks its schemas with `@earendil-works/pi-schema` instead of TypeBox: its helpers (`str`, `num`, `int`, `bool`, `nul`, `lit`, `oneOf`, `union`, `obj`, `arr`, `map`, `opt`, `bytes`, `unknown`) build on pi-schema's builders and keep the JSON contracts were fingerprinted with, so every fingerprint, normal form and stored record stays the same (a test records them and reads a store written before the switch). `Static` and `Schema` (was `TSchema`) come from pi-schema; `opt(...)` marks an object field or a table column and is no schema elsewhere (a top-level `opt` had no effect before); a schema with a non-enumerable key (a TypeBox schema's metadata) is refused at declaration; `map(bytes())` is accepted now (it was refused while `bytes()` carried a TypeBox refinement). `format` is an annotation, no longer checked (`{ type: "string", format: "email" }` accepts `"nope"`; TypeBox checked the formats it knew). A failed check's message comes from pi-schema (`args/title: expected string, got integer`), its `details.path` escapes `~` and `/` (JSON Pointer); reasons and codes are unchanged. Declaring and compiling are cheaper (a 20-method contract 1.07 ms to 0.86 ms, a validator's first use 172 µs to 48 µs), a check costs the same (0.25 µs), the message envelope check up to 100 ns more per message.
|
||||
- The delta tracker keeps its one open change in a plain field and needs a `WeakRef` only for a second change open at the same time (one per change before). A `WeakRef` target survives the scavenges of its job, so a run of changes in one job promoted every change's overlay to old space: 200,000 small changes in one job now promote 2 MB instead of 47.5 MB and take 3.1 µs each instead of 3.8 µs (3.0 instead of 3.5 µs when they span jobs). A draft dropped without `prepare()` or `abort()` now stays referenced by its tracker until the tracker's next `beginChange()` or `adopt()`.
|
||||
- `applyImmutable()`, `applyImmutableBatches()` and tracker revisions copy each container on a changed path with spread (or `Object.assign()` onto a null-prototype object) instead of one `Object.defineProperty()` per key, about 25 times faster per container ([#9265](https://github.com/earendil-works/pi/issues/9265))
|
||||
|
||||
### Fixed
|
||||
|
||||
- A host a route resolves is admitted even when the relayed reference that asked for it is released, its client disconnects, or its principal is refused or revoked while the route resolves. Before, the parent never read the new host's link: a spawned worker's own references to its parent (its `lifetime: "host"` objects' upstream references) waited until they failed `unavailable/ready_timeout`, reported as a possible reference cycle across hosts.
|
||||
- The Node SQLite driver keeps the 256 most recently used prepared statements per connection instead of every statement it ever prepared, so callers generating SQL of unbounded shapes (a query builder) no longer grow memory without bound.
|
||||
- Fixed append ops from the delta tracker and `diffRevisions()` keeping the whole string they were cut from alive (V8 represents a slice as a view of its source), so a delta chain, a watch backlog or a replica built from appends to a growing text held one copy of the text per commit ([#9265](https://github.com/earendil-works/pi/issues/9265))
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Chord builds applications out of **objects** that live in several processes (a s
|
|||
- **State** is observable JSON. Remote viewers get a snapshot followed by deltas.
|
||||
- **Hot reload** swaps code while it runs. Work in flight finishes on the code it started with, state is kept, and incompatible changes migrate at a safe point.
|
||||
|
||||
Contracts are runtime values described with [TypeBox](https://github.com/sinclairzx81/typebox) schemas. Hosts exchange JSON frames over links: in memory, a Node IPC channel, or a WebSocket.
|
||||
Contracts are runtime values described with JSON Schema, written with the builders of `@earendil-works/pi-schema` (any JSON Schema object works; `format` is an annotation, never checked). Hosts exchange JSON frames over links: in memory, a Node IPC channel, or a WebSocket.
|
||||
|
||||
## Entry points
|
||||
|
||||
|
|
@ -27,18 +27,18 @@ Contracts are runtime values described with [TypeBox](https://github.com/sinclai
|
|||
## Quick start
|
||||
|
||||
```ts
|
||||
import { Type } from "typebox";
|
||||
import { num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { createHost, hostNamed, method, remote } from "@earendil-works/chord";
|
||||
|
||||
// contract.ts: shared by provider and consumers
|
||||
export const Counter = remote("demo.counter", {
|
||||
version: 1,
|
||||
input: Type.Object({ name: Type.String() }),
|
||||
input: obj({ name: str() }),
|
||||
host: hostNamed("server"), // where its objects live; callers never name a host
|
||||
variants: {
|
||||
default: {
|
||||
state: { count: Type.Number() },
|
||||
methods: { increment: method(Type.Object({ by: Type.Number() })) },
|
||||
state: { count: num() },
|
||||
methods: { increment: method(obj({ by: num() })) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -46,7 +46,7 @@ export const Counter = remote("demo.counter", {
|
|||
const host = createHost({ name: "server", self: { kind: "server" } });
|
||||
|
||||
host.serve(Counter, ({ name }, _principal, self) => {
|
||||
const count = self.state("count", Type.Number(), 0);
|
||||
const count = self.state("count", num(), 0);
|
||||
return {
|
||||
variant: "default",
|
||||
count,
|
||||
|
|
|
|||
|
|
@ -36,17 +36,17 @@ Chord builds applications out of **objects** that live in several processes (a s
|
|||
## Quick start
|
||||
|
||||
```ts
|
||||
import { Type } from "typebox";
|
||||
import { num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { createHost, method, remote } from "@earendil-works/chord";
|
||||
|
||||
// contract.ts: shared by provider and consumers
|
||||
export const Counter = remote("demo.counter", {
|
||||
version: 1,
|
||||
input: Type.Object({ name: Type.String() }),
|
||||
input: obj({ name: str() }),
|
||||
variants: {
|
||||
default: {
|
||||
state: { count: Type.Number() },
|
||||
methods: { increment: method(Type.Object({ by: Type.Number() })) },
|
||||
state: { count: num() },
|
||||
methods: { increment: method(obj({ by: num() })) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -54,7 +54,7 @@ export const Counter = remote("demo.counter", {
|
|||
const host = createHost({ name: "server", self: { kind: "server" } });
|
||||
|
||||
host.serve(Counter, ({ name }, _principal, self) => {
|
||||
const count = self.state("count", Type.Number(), 0);
|
||||
const count = self.state("count", num(), 0);
|
||||
return {
|
||||
variant: "default",
|
||||
count,
|
||||
|
|
@ -82,7 +82,7 @@ Remote types can be reached from other hosts. Everything that crosses the wire h
|
|||
```ts
|
||||
export const Chat = remote("pi.chat", {
|
||||
version: 1,
|
||||
input: Type.Object({ session: Type.String(), conversation: Type.Number() }),
|
||||
input: obj({ session: str(), conversation: num() }),
|
||||
host: hostFamily("session:", "session"), // lives on `session:S1` for `{ session: "S1", … }`
|
||||
variants: {
|
||||
viewer: {
|
||||
|
|
@ -91,7 +91,7 @@ export const Chat = remote("pi.chat", {
|
|||
participant: {
|
||||
state: { view: ConversationViewSchema },
|
||||
methods: {
|
||||
send: method(SendInputSchema, Type.Object({ submissionId: Type.Number() })),
|
||||
send: method(SendInputSchema, obj({ submissionId: num() })),
|
||||
stop: method(), // no arguments, no result: `chat.stop(ctx)`
|
||||
},
|
||||
},
|
||||
|
|
@ -119,10 +119,10 @@ Local types are reachable only inside their host. Their members may be anything:
|
|||
```ts
|
||||
export const Index = local("pi.index", {
|
||||
version: 1,
|
||||
input: Type.Object({ session: Type.String() }),
|
||||
input: obj({ session: str() }),
|
||||
members: {
|
||||
search: method(Type.Object({ query: Type.String() }), Type.Array(HitSchema)),
|
||||
onChange: method(Type.Function([ChangeSchema], Type.Void()), UnsubscribeSchema),
|
||||
search: method(obj({ query: str() }), arr(HitSchema)),
|
||||
onChange: method({ type: "function", parameters: [ChangeSchema], returnType: { type: "void" } }, UnsubscribeSchema),
|
||||
harness: opaque("pi.durable-harness@1"),
|
||||
},
|
||||
});
|
||||
|
|
@ -181,7 +181,7 @@ A reload runs the factory again over the same object. Declarations that keep som
|
|||
```ts
|
||||
host.serve(StatusStore, ({ session }, _host, self) => {
|
||||
const status = self.state("status", StatusSchema, { running: false, tasks: 0 });
|
||||
const errors = self.state("errors", Type.Array(Type.String()), []);
|
||||
const errors = self.state("errors", arr(str()), []);
|
||||
const catalog = self.get(Catalog, {});
|
||||
const db = self.resource("db", {
|
||||
version: 1,
|
||||
|
|
@ -230,7 +230,7 @@ host.serve(Visits, (_, _p, self) => {
|
|||
|
||||
// right
|
||||
host.serve(Visits, (_, _p, self) => {
|
||||
const visits = self.state("visits", Type.Number(), 0);
|
||||
const visits = self.state("visits", num(), 0);
|
||||
return {
|
||||
hit: async (ctx) => {
|
||||
visits.change(ctx, (n) => n + 1);
|
||||
|
|
@ -240,7 +240,7 @@ host.serve(Visits, (_, _p, self) => {
|
|||
});
|
||||
```
|
||||
|
||||
**Methods** are declared with `method(args?, result?)` in the contract. Without `args` a method takes none: callers write `ref.hit(ctx)` (or `ref.hit()`), and its implementation is `(ctx) => …`. Without `result` it returns nothing: the implementation returns nothing, and callers get `undefined`. `method(undefined, Type.Number())` takes no arguments and returns a number.
|
||||
**Methods** are declared with `method(args?, result?)` in the contract. Without `args` a method takes none: callers write `ref.hit(ctx)` (or `ref.hit()`), and its implementation is `(ctx) => …`. Without `result` it returns nothing: the implementation returns nothing, and callers get `undefined`. `method(undefined, num())` takes no arguments and returns a number.
|
||||
|
||||
## Activation
|
||||
|
||||
|
|
@ -428,7 +428,7 @@ chat.release();
|
|||
## State
|
||||
|
||||
```ts
|
||||
const todos = self.state("todos", Type.Array(TodoSchema), []);
|
||||
const todos = self.state("todos", arr(TodoSchema), []);
|
||||
|
||||
todos.change(ctx, (draft) => {
|
||||
draft.push({ id: "t1", text: "Write docs", done: false });
|
||||
|
|
@ -457,7 +457,7 @@ view.replace(ctx, rebuilt, { diff: true }); // always diff
|
|||
|
||||
```ts
|
||||
const db = self.resource("db", { version: 1, open: () => openSqlite(path), close: (db) => db.close() });
|
||||
const notes = self.state("notes", Type.Array(NoteSchema), { init: () => db.value.all("SELECT * FROM notes") });
|
||||
const notes = self.state("notes", arr(NoteSchema), { init: () => db.value.all("SELECT * FROM notes") });
|
||||
```
|
||||
|
||||
`init(ctx)` runs once the resources are open and the references live, before the object goes live, so no method,
|
||||
|
|
|
|||
|
|
@ -583,3 +583,392 @@ each; the head lookup and scans under it are at parity or faster in the micro-be
|
|||
memory, 55.5 µs persisted (two commits: the record and state created, then the reservation); a state change inside
|
||||
`run` 2.7 µs in memory, 21.3 µs persisted (one delta commit, as the gate's (c)), 3.2 µs deferred (stored with the
|
||||
outcome).
|
||||
|
||||
## Slice 8: registries and `host.root` (2026-10-28)
|
||||
|
||||
Node 24.4.1, one CPU (`taskset -c 6`), runs alternating. HEAD is `391e83fb1`, extracted at a path of the same length
|
||||
(`/home/badlogic/pi-h8`); the build was measured in the worktree and, where the worktree itself skewed a result (below),
|
||||
in an extract of the same sources at `/home/badlogic/pi-x8`.
|
||||
|
||||
**Paths under every object** (`test/next/bench/calls.ts`, new: runs on HEAD as it is), median per operation over nine
|
||||
bursts, heap allocated per operation, three alternating runs each, HEAD → build:
|
||||
|
||||
| Case | HEAD | build |
|
||||
| --- | --- | --- |
|
||||
| local method call (its ctx) | 2.80-2.81 µs, 6,894 B | 2.81-2.83 µs, 6,894 B |
|
||||
| commit through a watched derive (`batch.compute`) | 10.34-10.43 µs, 15,312 B | 10.46-10.51 µs, 15,310 B |
|
||||
| activation churn: get, live, release (an object with params) | 57.5-59.3 µs, 43,696-43,717 B | 58.1-58.9 µs, 43,782-43,787 B |
|
||||
| retained per live object (10,000 kept) | 6,586 B | 6,602 B |
|
||||
|
||||
The attempt keeps its registry reads, contributions, wake signals and writables in one record made by the first such
|
||||
declaration, and iterates nothing when there is none: the first build allocated 1,020 B more per activation (six
|
||||
arrays per attempt, an array iterator per `for … of` over an empty list, a copy from `splice`) and retained 72 B more
|
||||
per live object; now +70 B allocated and +16 B retained (one field on the attempt, one on the object).
|
||||
|
||||
**Existing benchmarks**, HEAD → build: the derive diamond (`derives.ts`, three runs) 9.4-9.8 → 9.5-9.9 µs median; its
|
||||
pooled p95 16.5-17.1 → 18.4-20.8 µs comes from the run in which V8 tiers the commit path up (per run, after it: HEAD
|
||||
10.7-11.3 µs, build 10.1-11.3 µs; the first measured runs 15.0-25.6 µs on either side, in another run each), the same
|
||||
with HEAD's `batch.ts` and `derive.ts` in the build. The store gate (`store.ts`, three runs): every median within
|
||||
0.4 µs (Chord store 13.0-13.9 → 13.0-14.2 µs, Chord host 17.3-18.2 → 17.3-17.8 µs, in-memory document 2.1 → 2.2 µs),
|
||||
heap 3,498 → 3,144 KiB. Jobs (`jobs.ts`): start 8.4 → 8.5 µs in memory, 55.4 → 56.2 µs persisted; state change 2.7 →
|
||||
2.7, 21.3 → 21.8, 3.4 → 3.5 µs. Links (`links.ts`, three runs): wire sizes, encodes, decodes and heap per message
|
||||
unchanged; its round trips are one cold burst each and vary as much between HEAD runs (commit to frame, text codec,
|
||||
0.055-0.076 ms). A warmed remote call (2,000 calls per burst, nine bursts, a memory link): binary 7.16-7.24 → 7.17-7.29
|
||||
µs, text 7.66-7.72 → 7.70-7.83 µs in the extracts; the worktree itself runs the same sources at 7.8-7.9 µs (binary),
|
||||
an effect of the tree, not the code.
|
||||
|
||||
**Registries and writables** (`test/next/bench/registries.ts`, new), median over nine bursts: `writable.set` delivered
|
||||
to one watch 0.81-0.91 µs (2.9 KB allocated: the batch and the watch's call); a set of the same value 0.008 µs, 0 B
|
||||
(one comparison: its batch work is a method of its own, so `set` allocates no closure context); one of 10 contributions
|
||||
changed while the registry is watched 0.74-0.77 µs, of 100 1.52-1.59 µs (the table built again over every entry, one
|
||||
compose per set: the counted `chord.registry.composes` is 1.00 per set).
|
||||
|
||||
**Fix round 1** (2026-10-28), HEAD extract → build extract (`/home/badlogic/pi-x8`), CPU 6. `calls.ts`, two runs: local
|
||||
method call 2.80 → 2.80-2.81 µs, 6,894 B both; commit through a watched derive 10.51-10.65 → 10.72 µs; activation churn
|
||||
57.9-59.0 → 59.3-60.0 µs, 43,714 → 43,769-43,787 B. The derive diamond (five runs): median 9.4-9.8 → 9.5 µs, pooled p95
|
||||
16.4-16.7 → 19.0-19.2 µs. Per run, both have one slow run while V8 tiers the commit path up (HEAD its second, median/p95
|
||||
15.5/22.6-22.9 µs; the build its first, 16.4/26.7-27.2 µs) and equal runs after it (HEAD 8.8-9.6/9.6-11.2 µs, build
|
||||
8.9-9.8/9.7-10.6 µs). `batch.compute` is not the cause: HEAD's `batch.ts` and `derive.ts` in the build give the same
|
||||
p95, and swapping host files moves it without a pattern (code layout, not work on the path). `registries.ts`:
|
||||
`writable.set` 0.87 µs, the same value 0.011-0.016 µs and 0 B (the seal check now comes first: a set of an ended
|
||||
attempt's writable throws whatever its value); one of 10 contributions set 0.71-0.78 µs, of 100 1.43-1.61 µs (one run
|
||||
in six 3.4 µs on either variant tried: JIT).
|
||||
|
||||
## Slice 7a: the dependency model (2026-10-29)
|
||||
|
||||
Node 24.4.1, one CPU (`taskset -c 6`), runs alternating. HEAD is `babe30a7e`, extracted at a path of the same length
|
||||
(`/home/badlogic/pi-h7`); the build in the worktree (`/home/badlogic/pi-7a`).
|
||||
|
||||
**Paths under every object** (`test/next/bench/calls.ts`), median per operation over nine bursts, heap allocated per
|
||||
operation, three alternating runs each, HEAD → build:
|
||||
|
||||
| Case | HEAD | build |
|
||||
| --- | --- | --- |
|
||||
| local method call (its ctx) | 2.81-2.82 µs, 6,894 B | 2.80-2.82 µs, 6,894 B |
|
||||
| commit through a watched derive | 10.43-10.70 µs, 15,310 B | 10.38-10.53 µs, 15,310 B |
|
||||
| activation churn: get, live, release (an object with params and one dependency) | 58.5-59.9 µs, 43,716-43,719 B | 53.7-55.4 µs, 41,540-41,559 B |
|
||||
| retained per live object with one dependency (10,000 kept, an ad-hoc probe) | 7,398-7,406 B | 6,931-6,936 B |
|
||||
|
||||
An activation whose dependencies are live at the check does not park: one check of its bindings, then its own steps a
|
||||
microtask later (as HEAD's wait did). The first build parked every attempt with a dependency (a waiter list, a
|
||||
`parked_long` record, a microtask per wake even outside parking) and cost 62.0-63.6 µs and 580 B more per activation;
|
||||
now a wake outside parking is a flag, the first-outcome promise is made only for whoever waits (the start barrier),
|
||||
and an object keeps no `Set` of waits and no settlement promise per attempt (R18's), hence the lower numbers.
|
||||
|
||||
**Links** (`links.ts`, three runs each): wire sizes, encodes, decodes, heap per message and retained heap per hold
|
||||
(176.0 → 175.4-176.0 KiB) unchanged. Hold to live over two hops (mean of 100), binary: 1,000-item list 0.781-0.817 →
|
||||
0.807-0.811 ms, 500 KB document 1.250-1.278 → 1.289-1.310 ms (+1-3%: the hold counted at its arrival, the index
|
||||
check of the publication); text 1.545-1.580 → 1.521-1.573 and 3.299-3.379 → 3.317-3.393 ms. Commits to frames,
|
||||
streamed appends, call round trips and reconnects within the runs' spread.
|
||||
|
||||
**Existing benchmarks**, HEAD → build: the derive diamond (`derives.ts`, three runs) 9.4-10.1 → 9.5-9.6 µs median,
|
||||
p95 19.3-19.9 → 19.3-19.4 µs. Registries (`registries.ts`, three runs): `writable.set` 0.82-0.97 → 0.77-0.87 µs; one of
|
||||
10 contributions set 0.72-2.71 → 0.70-1.06 µs; of 100 1.50-3.46 → 1.44-3.40 µs (one slow run per side: JIT);
|
||||
allocation within 20 B. A registry's last good composition keeps the list of contributions it was built from: the
|
||||
list is replaced (not spliced) when a contributor comes or goes, so a composition costs no copy.
|
||||
|
||||
**Fix round 1** (2026-10-29: revocation over paths, retry operations on the wire, the provider's own host-address
|
||||
check for same-host remote requests, mapped values' provenance), `calls.ts`, three alternating runs, HEAD → build:
|
||||
local call 2.81 → 2.79-2.82 µs (6,894 B both), watched derive commit 10.45-10.70 → 10.35-10.47 µs, activation churn
|
||||
58.6-59.8 → 53.9-54.2 µs (43,716-43,720 → 41,559 B); retained per live object with one dependency 7,399-7,405 →
|
||||
6,930-6,938 B. The address check runs only for a remote type whose consumer and provider `hostAddress` differ; a
|
||||
retry's operation id is made per `ref.retry()`, and an object keeps its handled ids only once it was retried.
|
||||
|
||||
## Slice 7b: reload in the kernel (2026-10-29)
|
||||
|
||||
Node 24.4.1, one CPU (`taskset -c 6`), runs alternating. HEAD is `0c3675e64`, extracted at a path of the same length
|
||||
(`/home/badlogic/pi-hb`); the build in the worktree (`/home/badlogic/pi-7b`).
|
||||
|
||||
**Paths under every object** (`test/next/bench/calls.ts`), median per operation over nine bursts, heap allocated per
|
||||
operation, alternating runs (the last two after the fixes below), HEAD → build:
|
||||
|
||||
| Case | HEAD | build |
|
||||
| --- | --- | --- |
|
||||
| local method call (its ctx) | 2.80-2.83 µs, 6,894 B | 2.67-2.70 µs, 6,757 B |
|
||||
| commit through a watched derive | 10.38-10.69 µs, 15,309 B | 10.22-10.39 µs, 15,312 B |
|
||||
| activation churn: get, live, release (an object with params) | 53.6-55.3 µs, 41,539-41,560 B | 54.2-55.6 µs, 41,606-41,630 B |
|
||||
| retained per live object with params and one dependency (10,000 kept, an ad-hoc probe, median of 5) | 8,696-8,700 B | 8,761 B |
|
||||
|
||||
The first build cost 60.4-62.7 µs and 2,480 B more per activation: every attempt made an `AbortController` for its
|
||||
routines and two maps (routine calls, job invocations) whether it had any. Now they are made when routines start or a
|
||||
run is admitted, and the first dependency check's answer is a shared constant unless someone asks for it; what a reload
|
||||
did to an attempt is one record made by the first such step. What remains: a few fields on the object (its params key)
|
||||
and the attempt (its routines, its reload record, its first check), +46-70 B allocated and +61-65 B retained (0.7%).
|
||||
A local call allocates 137 B less: the method path is split (`#invoke`) so the current version's call builds no
|
||||
mapping closure.
|
||||
|
||||
**Links** (`links.ts`, two runs each): wire sizes, encodes, decodes and heap per message unchanged. Hold to live over
|
||||
two hops (mean of 100), binary: 1,000-item list 0.780-0.794 → 0.797-0.821 ms (+1-4%), 500 KB document 1.246-1.275 →
|
||||
1.270-1.275 ms; commits to frames and streamed appends within the runs' spread. Retained per hold of 10: 175.8 → 176.2-176.5
|
||||
KiB (the hold keeps its request, to be placed again after a reload); the single-hold row 176.0 → 180.2 KiB is one
|
||||
fixed amount (~4 KiB, code of the new paths), divided by ten in the other row.
|
||||
|
||||
**Existing benchmarks**, HEAD → build: the derive diamond (`derives.ts`, two runs) 9.5-9.6 → 9.6 µs median, p95
|
||||
16.4-19.3 → 14.1-14.3 µs. Registries (`registries.ts`, two runs): `writable.set` 0.94-0.96 → 0.77-0.86 µs; one of 10
|
||||
contributions set 0.77-1.05 → 0.69-0.72 µs; of 100 1.57-1.58 → 1.54-3.30 µs (one slow run: JIT, as in 7a). Jobs
|
||||
(`jobs.ts`, two runs): start 8.6-8.7 → 8.5 µs in memory, 55.3-56.1 → 55.3-55.6 µs persisted; state changes within 0.1
|
||||
µs. The store (`store.ts`): every median within 0.3 µs. The relay (`relay.ts`, three runs): tab B alone 0.014 ms both;
|
||||
10,000 calls at once 530-723 → 491-785 ms (the spread of either side).
|
||||
|
||||
**Reload** (`test/next/bench/reload.ts`, new): 20 plugins, 1,000 live objects (with params, a document, a derive and a
|
||||
method each), one plugin reloaded 20 times; medians after two warm-up reloads, two runs:
|
||||
|
||||
| Case | reload resolves | all live again | heap after 20 reloads, above after the first |
|
||||
| --- | --- | --- | --- |
|
||||
| swap, 50 of the 1,000 objects in the reloaded plugin | 1.58-1.59 ms | 1.64-1.65 ms | 332-338 KiB |
|
||||
| swap, all 1,000 in it | 35.5-36.3 ms | 36.6-37.2 ms | 191-192 KiB |
|
||||
| restart (a native each), 50 of 1,000 | 1.20-1.54 ms | 1.90-2.05 ms | 105-106 KiB |
|
||||
| restart, all 1,000 | 20.0-20.2 ms | 35.8-40.8 ms | 57-58 KiB |
|
||||
|
||||
A swap costs about 36 µs per object (its staging record, the new attempt's activation, the hand-over of its old one),
|
||||
a restart about 20 µs until the reload resolves (staging and the start of each fate; the new attempts activate after
|
||||
the drains). The heap after reloads levels off (swap, 50 objects: 332 KiB after 20, 437 KiB after 80, 550 KiB after
|
||||
300 reloads): compiled code and feedback of the new facets, not objects (the GC tests collect old attempts, removed
|
||||
objects, providers and facets).
|
||||
|
||||
**Fix round 1**, CPU 6, alternating against HEAD. `calls.ts`, three runs: local call 2.80-2.82 → 2.66-2.68 µs
|
||||
(6,894 → 6,757 B); watched derive commit 10.42-10.66 → 10.31-10.47 µs; activation churn 53.7-55.8 → 54.3-55.9 µs,
|
||||
41,540-41,560 → 41,566-41,584 B (an `?? []` per end, found here, is gone: the round's first measurement allocated
|
||||
41,698 B); retained per live object with one dependency 8,699-8,700 → 8,768 B. Links (a review measured binary
|
||||
commit-to-frame +19% and small calls +6% on another CPU): `links.ts`, two runs, binary commit to frame
|
||||
0.085/0.097-0.099/0.063-0.065 → 0.087/0.097/0.064 ms (append/change/removal), small call 0.038-0.039 → 0.035 ms. A
|
||||
warmed ad-hoc probe (binary memory link, 2,000 calls and 1,000 commits per burst) measures no regression: call round
|
||||
trip 9.8-10.2 → 9.9-10.1 µs, commit to frame 60.6-65.1 → 61.0-61.6 µs. Reload benchmark after the round: swap 50 /
|
||||
1,000 objects 1.50 / 35.9 ms, restart 1.67 / 21.4 ms until the reload resolves (2.12 / 38.1 ms until all are live);
|
||||
heap after 20 reloads 339 / 193 / 104 / 68 KiB.
|
||||
|
||||
**Fix round 2**, alternating against HEAD. Stock `links.ts` on CPU 22, six rounds each before and after the fix
|
||||
(medians, binary / text): before, small call 0.062 → 0.068 / 0.058 → 0.064 ms (+10%), commit to frame within 1%
|
||||
(append 0.156 → 0.156, changed field 0.165 → 0.162, removal 0.120 → 0.119 ms binary). Cut-down copies located it:
|
||||
calls alone, or after the snapshot, commit and stream sections, or after the hold-memory trials alone, measured
|
||||
equal; only the full sequence showed the gap, and a CPU profile of its calls section put the difference in the
|
||||
remote call path (`Hold.call` with the attempt's call inlined, about 6.7 µs per call). The cause was `Attempt.call`
|
||||
delegating the invocation to a helper method shared with the older-version path; one body for every version, and
|
||||
no `async` (a local call allocated about 250 B more as an async function), fixed it. After: small call 0.063 →
|
||||
0.065 / 0.058 → 0.058 ms, append 0.156 → 0.157, changed field 0.164 → 0.163, removal 0.119 → 0.120 ms, hold to live
|
||||
(1,000 items) 1.037 → 1.023 ms. `calls.ts` on CPU 6: local call 2.89-2.92 → 2.77-2.85 µs (6,894 → 6,660 B), watched
|
||||
derive commit 10.44-10.58 → 10.65-10.81 µs (15,309 → 14,969 B), activation churn 54.8-55.1 → 54.6-54.7 µs (41,560 →
|
||||
41,592 B); retained per live object with one dependency 8,700 → 8,777 B. Reload benchmark: swap 50 / 1,000 objects
|
||||
1.84 / 33.7 ms, restart 1.31 / 22.3 ms until the reload resolves (2.18 / 41.0 ms all live); heap after 20 reloads
|
||||
323 / 200 / 104 / 70 KiB.
|
||||
|
||||
**Fix round 3: the commit-to-frame gap is V8's young generation, not frame work.** Astra (CPU 22, five pairs) measured
|
||||
binary append commit-to-frame 132 → 161 µs, changed field 148 → 168, removal 112 → 121 on the stock `links.ts`.
|
||||
- *Path length.* The HEAD copy `/home/badlogic/pi-hb` measured append 0.155 ms; Astra's `/tmp/s7b-hd-astra`, with
|
||||
identical sources, 0.129 ms. A copy of the same tree at a path of Astra's length (17 characters) measured 0.130 ms,
|
||||
at a longer one 0.155 ms: on the same code the result depends on the checkout path alone. Compare copies at paths of
|
||||
equal length.
|
||||
- *Stock numbers, equal paths* (`/tmp/s7b-hd-astra` vs a fresh build copy `/tmp/s7b-xx-astra`, CPU 22, three
|
||||
alternating runs, means): append 0.128-0.129 → 0.155-0.156 ms binary, 0.106-0.107 → 0.116-0.117 text; changed field
|
||||
0.144-0.145 → 0.161-0.162 binary, 0.097-0.098 → 0.107-0.110 text; small call 0.061-0.063 → 0.065-0.066 binary,
|
||||
0.058-0.059 → 0.058-0.059 text.
|
||||
- *Same work.* Heap allocated per commit is equal (191.9 vs 191.4 KB including the codec rows); so are the telemetry
|
||||
records per commit (5.012, the same names: two validations, two link checks, one encode).
|
||||
- *The difference is scavenges.* In the timed append loop HEAD ran 5 scavenges costing 3.6 ms in total, the build 3
|
||||
costing 21 ms (changed field: 2 for 2.7 ms vs 1 for 10.9 ms). At the loop's start the young generation was 33.5 MB
|
||||
in HEAD and 67 MB in the build (134 MB by the changed-field loop): V8 grows it from the bytes surviving its
|
||||
collections, and the build crossed that threshold a few scavenges earlier. Over the whole run the GC work is equal
|
||||
(scavenge time 1,620 vs 1,605 ms, survived bytes 471 vs 480 MB, 285 vs 284 scavenges); where the expensive
|
||||
scavenges fall relative to the timed loops differs. Tracing the GC (`--trace-gc-nvp`) moves HEAD into the build's
|
||||
pattern by itself.
|
||||
- *With the young generation pinned* the two are equal: means 0.139/0.139 ms (append), 0.145/0.144 (changed field),
|
||||
0.116/0.115 (removal), 0.065/0.064 (small call), HEAD/build; per-commit medians and p90s too (three runs, warmed
|
||||
script): append 0.085-0.086 / 0.085-0.086 ms median, 0.101 / 0.101-0.103 p90; changed field 0.073 / 0.072-0.073,
|
||||
0.083-0.084 / 0.083; removal 0.060-0.061 / 0.060-0.061, 0.073 / 0.072-0.073 (binary; text alike, within 1 µs).
|
||||
- *The extra live heap is code.* Build minus HEAD after a full collection: +249 KB right after loading the modules
|
||||
(old space +229 KB, before any object), +290 KB after a snapshot section, +296 KB after 1,000 appends, +281 KB after
|
||||
1,000 edits, +345 KB after 70 hold trials (old space +259 KB, the rest bytecode and code as more paths compile). It
|
||||
does not grow with commits or holds beyond the retained-per-hold and per-object figures above.
|
||||
|
||||
So `links.ts` changed: each commit section runs 1,000 untimed commits first (`WARM_UP_COMMITS`; the removal section
|
||||
starts with 1,000 more notes for them), and reports commit-to-frame as mean, median and p90 of the 1,000 timed commits.
|
||||
The warm-up stays because the timed loop then runs nearer the young generation's steady size: the warmed medians
|
||||
match the pinned ones (append 0.082 vs 0.085 ms, changed field 0.072 vs 0.073, removal 0.060 vs 0.060), and HEAD and
|
||||
build agree in five of six sections, against two of six without it (run-to-run spread is about 1 µs either way). The
|
||||
mean still carries the scavenges that fall in the loop. After the change, equal paths, CPU 22, three alternating runs,
|
||||
HEAD → build (mean; median; p90, ms):
|
||||
|
||||
| Section | binary | text |
|
||||
| --- | --- | --- |
|
||||
| append | 0.106-0.108 → 0.142-0.144; 0.081-0.083 → 0.090-0.091; 0.093-0.096 → 0.125-0.127 | 0.095-0.119 → 0.121-0.125; 0.078-0.080 → 0.080-0.082; 0.090 → 0.094-0.095 |
|
||||
| changed field | 0.084-0.087 → 0.098-0.099; 0.071-0.075 → 0.072-0.073; 0.081-0.084 → 0.081-0.082 | 0.086-0.090 → 0.099-0.101; 0.067-0.071 → 0.070-0.071; 0.079-0.082 → 0.082-0.084 |
|
||||
| removal | 0.103-0.104 → 0.107-0.109; 0.060-0.061 → 0.060-0.061; 0.071-0.073 → 0.072-0.073 | 0.100-0.102 → 0.077-0.103; 0.058-0.060 → 0.059; 0.067-0.069 → 0.067-0.070 |
|
||||
| small call | 0.052-0.058 → 0.053-0.054 | 0.054-0.065 → 0.059-0.069 |
|
||||
|
||||
The binary append differs unpinned (median +11%, p90 +31%) and is equal pinned: the young generation's size in that
|
||||
section, not per-append work. To compare commit-to-frame across two copies, run both at paths of equal length, pinned
|
||||
to one CPU, alternating, with the young generation pinned:
|
||||
|
||||
taskset -c 22 node --min-semi-space-size=16 --max-semi-space-size=16 --conditions=source --expose-gc test/next/bench/links.ts
|
||||
|
||||
Fix round 3 hot paths, `/tmp/s7b-hd-astra` → a build copy at a path of equal length, CPU 22, three alternating runs
|
||||
(`calls.ts`): local call 3.69-3.71 → 3.56-3.63 µs (6,894 → 6,660 B), watched derive commit 17.39-17.50 → 17.41-17.53
|
||||
µs (15,310 → 14,967 B), activation churn 104.2-104.8 → 107.6-108.3 µs (+3%; 41,526 → 41,426 B); retained per live
|
||||
object with one dependency 8,700 → 8,585 B (one table per version replaced the current version's separate member
|
||||
map). Reload benchmark, same CPU: swap 50 / 1,000 objects 2.88 / 51.9 ms, restart 2.49 / 35.7 ms until the reload
|
||||
resolves (3.22 / 65.7 ms all live); heap after 20 reloads 345 / 188 / 119 / 85 KiB.
|
||||
|
||||
Sol's independent runs (CPU 24, five pairs of the stock `links.ts`) show the same commit-to-frame gap (binary append
|
||||
135 → 162 µs, changed field 145 → 168, text 109 → 119 and 100 → 108): the young-generation effect above. Sol's
|
||||
isolated round trips (`sol-isolated-links.ts`, nine bursts each, no telemetry) measured a binary call at 44.83 → 47.20
|
||||
µs (+5.3%) on the round-2 code. On this round's code, `/tmp/s7b-hd-astra` → a build copy at a path of equal length,
|
||||
CPU 24, five alternating pairs, medians: binary call 47.23 → 45.74 µs (-3.2%; HEAD's samples are bimodal, 44.3 or
|
||||
46.9-47.9), binary commit 92.68 → 93.28 (+0.6%), text call 39.02 → 39.76 (+1.9%), text commit 82.86 → 85.28 (+2.9%);
|
||||
with the young generation pinned: binary call 45.45 → 43.85 (-3.5%), binary commit 92.73 → 93.06, text call 39.97 →
|
||||
39.65, text commit 83.65 → 85.46 (+2.2%).
|
||||
|
||||
**Rework: references are the only indirection** (decision of the same name). `/tmp/s7b-hd-astra` (HEAD) → a build
|
||||
copy at a path of equal length, CPU 22, three alternating runs. `calls.ts`: local call 3.72-4.03 → 3.62-3.77 µs
|
||||
(6,894 → 6,660 B); watched derive commit 17.46-17.92 → 17.53-18.40 µs (15,310 → 14,900 B); activation churn
|
||||
104.7-107.9 → 114.7-118.3 µs (+9.6%, accepted by the owner, 2026-10-29; 41,507-41,526 → 41,473-41,501 B). The churn's object derives over its dependency's
|
||||
member: that derive now attaches through its own reference's member (one `ReferenceSource` made, bound and ended per
|
||||
cycle) where HEAD shared the provider's one member source; the allocation is HEAD's, the time is the new node's (a
|
||||
copy of round 6, before the rework, measured 55.2 µs against HEAD's 53.5-54.6 µs and the rework's 58.4 µs on CPU 6).
|
||||
Retained per live object with one dependency (an ad-hoc probe, 2,000 objects, median of five): 6,924 → 6,738 B; a
|
||||
first cut made a source for every member at the binding and allocated four sets per source (8,627 B): sources are made
|
||||
on first use or when a reference that had values detaches, and a source's watch, subscriber and pass-through sets are
|
||||
made by their first user. `links.ts` (warmed, mean / median / p90 in ms), binary: append 0.107-0.113 / 0.082-0.083 /
|
||||
0.095-0.097 → 0.141-0.142 / 0.089-0.090 / 0.123-0.124 (the young-generation effect above), changed field 0.084-0.086 /
|
||||
0.071-0.072 / 0.081-0.082 → 0.098-0.099 / 0.072-0.073 / 0.081-0.082, removal 0.102-0.108 / 0.060 / 0.071-0.073 →
|
||||
0.107-0.109 / 0.060-0.061 / 0.072; with the young generation pinned, append 0.140-0.143 / 0.086 / 0.100-0.102 →
|
||||
0.143-0.147 / 0.085-0.087 / 0.101-0.103, changed field 0.091-0.092 / 0.072-0.073 / 0.082-0.084 → 0.091-0.092 /
|
||||
0.073-0.074 / 0.082-0.084, removal 0.108-0.111 / 0.061-0.062 / 0.072-0.075 → 0.108-0.110 / 0.060-0.062 / 0.072-0.076;
|
||||
text alike within 3 µs. Small call 0.058-0.060 → 0.053-0.056 ms. Retained per hold (both ends): 175.4-176.0 →
|
||||
176.4-177.0 KiB with one hold, 175.8 → 176.4 KiB with ten.
|
||||
|
||||
The 1 MiB call row (final review): one burst of 50 calls read 0.389 → 1.088 ms (binary, young generation pinned) in
|
||||
one review run. Not per-call work: a probe with the row's setup and a collected heap before each of 20 bursts of 50
|
||||
(CPU 22, three alternating pairs) measured, per call without the collector's pauses, 0.27-0.31 → 0.30-0.31 ms binary
|
||||
and 0.31-0.36 → 0.33-0.36 ms text, heap 23,025 → 22,707 B binary and 23,964 → 23,650 B text. A burst's time is about
|
||||
0.3 ms per call plus 0.2-0.27 ms per call when a full collection falls in it (the arguments fill memory outside the
|
||||
heap); one burst of 50 sees one or none, in either tree. `links.ts` now collects the heap before each of 20 bursts and
|
||||
reports the median and p90; three alternating pairs of the new row, stock, binary median / p90 0.386-0.455 /
|
||||
0.409-0.473 → 0.279-0.357 / 0.306-0.394, text 0.344-0.395 / 0.510-0.566 → 0.363-0.500 / 0.522-0.621; pinned,
|
||||
binary 0.689-0.910 / 0.738-1.050 → 0.695-0.882 / 0.829-0.992, text 0.415-0.896 / 0.958-1.049 → 0.534-0.864 /
|
||||
1.042-1.175. The row still moves by run with the collector's phase; compare it across several runs, not one.
|
||||
The small call, measured apart (ten bursts of 1,000 after 3,000 warm-up calls, three alternating pairs): stock, binary
|
||||
29.5-30.6 → 27.6-27.9 µs and text 25.1-25.9 → 27.5-28.3 µs; with the young generation pinned, binary 29.7-30.7 →
|
||||
30.3-30.7 µs and text 23.6-23.7 → 23.9-24.1 µs. The stock text gap is the young generation's sizing, as for commit to
|
||||
frame above (pinned, the two match); the snapshot before the final review's fixes measures the same.
|
||||
|
||||
## Simplification audit (2026-10-30)
|
||||
|
||||
HEAD `4b535d57e` → the audit build (A1, A2, A5, A9-A12), copies at paths of equal length, CPU 6, alternating.
|
||||
`calls.ts`, three runs: local call 2.70-2.73 → 2.69-2.73 µs (6,660 B both), watched derive commit 10.41-10.75 →
|
||||
10.42-10.74 µs (14,899 B both), activation churn 59.0-60.9 → 59.1-61.4 µs (41,473-41,482 → 41,478 B). Retained per live
|
||||
object with one dependency (10,000 kept) 6,745-6,754 → 6,745-6,752 B; a first cut of the declaration-ordered
|
||||
dependency list (A11) kept it on live attempts, +225 B, and is now let go after the dependency check. `links.ts` with
|
||||
the young generation pinned: commit to frame, hold to live and retained per hold equal within run spread (176.4 →
|
||||
176.2-176.6 KiB per hold).
|
||||
|
||||
**Text-codec calls in isolation** (Sol's probe: a remote object over one memory link, 5,000 warm-up calls, ten bursts
|
||||
of 1,000 small calls, then 200 warm-up and 30 bursts of 10 calls with a 1 MiB argument; binary first, then text; the
|
||||
heap collected before each burst and the collections in it recorded: none in any burst), seven alternating pairs,
|
||||
median of the pairs' medians:
|
||||
|
||||
| Case | stock | young generation pinned |
|
||||
| --- | --- | --- |
|
||||
| binary small | 26.53 → 26.37 µs | 25.72 → 25.90 µs |
|
||||
| text small | 19.31 → 20.95 µs (+8.5%) | 19.11 → 19.93 µs (+4.3%) |
|
||||
| binary 1 MiB | 985 → 993 µs | 539 → 412 µs (bimodal: 187-607 µs per run) |
|
||||
| text 1 MiB | 958 → 963 µs | 273 → 284 µs (221-352 µs per run) |
|
||||
|
||||
Heap per call is the same (text small 15,171 → 15,160-15,202 B). The text small gap is not per-call work: the codec
|
||||
alone (encode, decode and the message check of a call and a result message, 20,000 per burst) measures the same in both
|
||||
trees (text call 1.55 µs and 52 B, result 1.52 µs and 171 B), deoptimizations and inlining decisions are the same, and
|
||||
the gap follows the shape of the text decoder's `fromFields`, not what runs: the build with HEAD's `text.ts` matches HEAD
|
||||
(19.0-19.2 µs), the one change in it that matters is the never-executed `T` (retry) case, and writing that case with an
|
||||
object spread again (`...(f.length >= 2 ? { hops } : …)`) restores HEAD's 19.1 µs while other edits of the same size do
|
||||
not. With text measured first the gap is 22.4-22.5 → 23.0-23.3 µs (+2-3%). V8 code generation, recorded as noise.
|
||||
|
||||
## Slice 7c: plugins (2026-10-30)
|
||||
|
||||
**The plugins component** (`test/next/bench/plugins.ts`, new): 20 plugins, one worker facet each (a store object over a
|
||||
document, a derive, a method, a helper module and a copied contract; 80.9 KiB of scripts in all), Node 24.4.1. Medians
|
||||
of seven runs; three runs pinned to CPU 8, one unpinned:
|
||||
|
||||
| Measurement | CPU 8 | unpinned (32 CPUs) |
|
||||
| --- | --- | --- |
|
||||
| cold build of the 20 worker facets (one esbuild build each, all at once) | 240.1-240.7 ms | 26.4 ms |
|
||||
| rebuild of one facet after an edit | 13.1-13.3 ms | 11.0 ms |
|
||||
| evaluation of one facet (`vm.Script`, its source map registered) | 0.16-0.27 ms | 0.09 ms |
|
||||
| reload of one plugin on a host (evaluation and `host.reload`, a pure swap) | 0.44-0.45 ms | 0.44 ms |
|
||||
| heap after 100 reloads of one plugin, against after a warm-up | +230-235 KiB | +229 KiB |
|
||||
|
||||
The heap does not grow per reload: 2,000, 4,000 and 8,000 reloads leave +897, +1,186 and +1,432 KiB (sublinear); the
|
||||
same evaluations through plain `vm.Script` with V8's compilation cache off leave +420 KiB at 8,000 against +465 KiB
|
||||
through `evaluateFacet` (V8's own per-script bookkeeping), and the kernel's reloads alone wander between +200 and
|
||||
+600 KiB at any count. Old facets and their closures are collected (the GC test: 200 reloads, every facet but the last
|
||||
two collected, the heap within 2 MiB of the warm-up).
|
||||
|
||||
Fix round 1 (inline source maps without `sourcesContent`: positions only), CPU 8: the 20 scripts take 53.4 KiB (80.9
|
||||
KiB before); cold build 241.1 ms, rebuild 13.0 ms, evaluation 0.14 ms per facet, a reload 0.39 ms, heap after 100
|
||||
reloads +238 KiB.
|
||||
|
||||
**Kernel hot paths** (per plugin at load and reload changes only staging), HEAD `18a155e2f` → build at paths of equal
|
||||
length, CPU 6, three alternating runs. `calls.ts`: local call 2.69-2.71 → 2.71-2.75 µs (6,660 B both), watched
|
||||
derive commit 10.34-10.36 → 10.43-10.49 µs (14,899 → 14,901 B), activation churn 58.93-59.58 → 59.44-59.73 µs
|
||||
(41,452-41,470 → 41,470 B). `links.ts` binary commit to frame (median / p90): append 0.035-0.036 / 0.058-0.059 →
|
||||
0.034-0.036 / 0.058-0.060 ms, changed field 0.027-0.028 / 0.031-0.033 → 0.027-0.028 / 0.031-0.033 ms, removal 0.022 /
|
||||
0.026-0.028 → 0.022 / 0.026-0.027 ms; with the young generation pinned alike within 2 µs; text alike; small call
|
||||
0.026-0.030 → 0.028-0.033 ms (pinned 0.030-0.035 → 0.030-0.032 ms); retained per hold equal (one binary hold 176.2 →
|
||||
170.1 KiB). `reload.ts`: swap of 50 / 1,000 objects 1.44-1.73 / 30.83-32.71 → 1.52-1.53 / 30.55-33.25 ms, restart
|
||||
1.01-1.05 / 15.54-16.87 → 0.95-0.99 / 15.90-16.02 ms until the reload resolves.
|
||||
|
||||
## Slice 10: HTTP and workers (2026-10-31)
|
||||
|
||||
`test/next/bench/http.ts` and `test/next/bench/workers.ts`, Node 24.4.1, CPU 6 (the forked workers inherit the pin).
|
||||
|
||||
| Measurement | Result |
|
||||
| --- | --- |
|
||||
| keep-alive round trip, a JSON body: plain Node handler / a route (same process as the client) | build: 42.0-42.5 / 46.4-47.0 µs: +4.2-4.9 µs (9.9-11.7%); fix round 1: 42.5-42.9 / 62.8-64.4 µs: +20.3-21.5 µs |
|
||||
| the same after 30,000 warm-up requests (fix round 1) | 30.9-31.3 / 43.4-44.0 µs: +12.1-12.9 µs; with an authenticator reading `request.headers`: +15.9 µs |
|
||||
| the same, server and client in two processes (CPU 6 and 8; build) | 36.4-37.0 / 41.7-42.4 µs: +5 µs |
|
||||
| 100 MiB file: `pipeline` of a read stream / `fileResponse` | build (64 KiB reads): 602-618 / 487-565 MiB/s; fix round 1 (256 KiB, pulled): 477-751 / 560-611 MiB/s |
|
||||
| a worker's cold start with one shipped script: fork to `started` / to `ready` (median of 7) | 531-532 / 538-539 ms |
|
||||
| the server's heap per idle worker (20 workers, one reference each) | 49.5 KiB |
|
||||
|
||||
A request's overhead was 16 µs at first: `host.root` cost about 7 µs of it per request (a linked abort signal, a
|
||||
`Promise.race` and an `awaitWithContext` per scope). Root scopes now take the caller's signal with one listener and
|
||||
settle one promise (`root.ts`; behaviour and tests unchanged): 4.2-4.9 µs, about the bar of 10% of a round trip. The
|
||||
rest is the request's scope (its abort controller, `CallContext`), admission and the response's header merge. The cold
|
||||
start is mostly Node's startup stripping the types of the source tree it imports; the start timer (60 s) is far from
|
||||
it.
|
||||
|
||||
**Fix round 1** (the `fetch` core, the security baseline, Chord's listener as a native object). A route's overhead in
|
||||
the standard run grew from +4.5 µs to +20-21 µs (+41 µs before the Node fast path: the listener reads Node's parsed
|
||||
headers and makes a Web `Request` only when something reads `request.headers` or the body or a relay verifies; whole
|
||||
bodies are written as strings, in one write with the head). Warmed, +12-13 µs; a CPU profile of the warmed route path
|
||||
(per request, `test/next/bench/http.ts`'s route): the listener's request function 13.6 µs inclusive against the plain
|
||||
handler's 8.9 µs, of which writing the answer 8.3 µs (the plain handler's `writeHead` and `end`: 9.1 µs); `serve` 4.2
|
||||
µs (the root scope 2.1, the URL parse 0.9, default headers 0.6, admission and routing 1.1, the answer 0.7); the root
|
||||
scope's seal in a later microtask 0.9 µs. The baseline's extra headers cost 0.4 µs (a plain handler sending them).
|
||||
The rest, about 5 µs, sits in no function of the component: Node's tick and microtask machinery (33 µs inclusive
|
||||
against 18 µs for the plain handler). Opus's two trims are in (`lookup` decodes only segments with `%`; one abort
|
||||
controller per request: the Node side's own is gone). Kernel rows, HEAD `bc9a20f84` → fix round 1, CPU 6, alternating:
|
||||
binary 1 MiB call median 0.182-0.220 → 0.196 ms; reconnect of 1 / 100 references 1.02-1.10 / 79.1-80.3 → 1.04-1.05 /
|
||||
81.3-81.5 ms; watched `writable.set` 0.836-0.868 → 0.861-0.863 µs; watched derive commit 10.42-10.58 → 10.36-10.60 µs;
|
||||
activation churn 58.9-60.5 → 59.1-60.8 µs.
|
||||
|
||||
**Kernel hot paths** (routes without a timer, `RouteHandle`, `Path` events, root scopes), HEAD `bc9a20f84` → build at
|
||||
paths of equal length, CPU 6, alternating runs. `calls.ts`: local call 2.70-2.83 → 2.70-2.83 µs (6,660 B both),
|
||||
watched derive commit 10.36-10.60 → 10.34-10.79 µs, activation churn 59.43-61.30 → 59.48-60.72 µs. `links.ts`: commit
|
||||
to frame and encode/decode alike; reconnect of 1 / 100 references 0.94-1.15 / 80.8-84.0 → 0.94-1.15 / 80.6-86.5 ms;
|
||||
1,000 parked calls 24.4-25.2 → 24.3-24.6 ms. `reload.ts`: swap of 50 / 1,000 objects 1.44-1.55 / 30.78-31.42 →
|
||||
1.32-1.56 / 31.20-31.83 ms, restart 0.96-0.98 / 15.75-16.34 → 0.95-1.10 / 15.91-15.94 ms until the reload resolves.
|
||||
`registries.ts` alike (one of 100 contributions set 1.47-2.45 → 1.48-3.41 µs, run to run).
|
||||
|
||||
## Slice 12: the plugin model (2026-11-01)
|
||||
|
||||
**The build's checks** (`test/next/bench/plugins.ts`, the 7c set: 20 plugins, one worker facet each, a copied contract
|
||||
by relative path), Node 24.4.1, CPU 8, medians of seven runs, three alternating runs. HEAD `ebce9debd` (its bench
|
||||
importing the contract by plugin name, which HEAD resolves) → this build: cold build of the 20 facets 257.6-257.8 →
|
||||
257.2-258.8 ms, rebuild of one facet 13.2-13.5 → 13.3-13.9 ms. With the app's API set and the kinds named (every
|
||||
import checked by version and kind, every resolved file by its kind segment; the plugins importing `@bench/app/v1`):
|
||||
257.4-258.8 ms cold, 12.9-14.3 ms rebuild, against 258.2-259.7 ms without. The checks cost nothing measurable; no hot
|
||||
path changed (the kernel gains two accessors read only by `testPlugin`).
|
||||
|
||||
Fix round 1: the bench itself now builds with the app's API and the kinds (its plugins import `@bench/app/v1`; Opus
|
||||
L5), and every package file's real path is checked against the plugin's `node_modules`. CPU 8, three runs: cold
|
||||
build 261.1-270.2 ms, rebuild 13.6-14.4 ms (the first two runs as before, the third with a slower cold build on a busy
|
||||
machine).
|
||||
|
|
|
|||
|
|
@ -1530,7 +1530,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
- **Slice 6a build reviews (2026-10-27, under the owner's delegation to the reviewers' majority):** two rounds of four
|
||||
build reviews (`/tmp/s6a-build-review{,2}-{astra,fable,opus,sol}.md`), merged into two fix lists
|
||||
(`/tmp/s6a-fix{1,2}-brief.md`, 28 and 16 items) by majority, a point raised by one reviewer taking that reviewer's
|
||||
recommendation. Rules they settle beyond fixes of the spec: (1) **members across attempts:** a local object's state
|
||||
recommendation. Rules they settle beyond fixes of the spec: (1) **members across attempts** (**superseded in part** by "References are the only indirection": the reference, not a member source on the object, follows the object across instances): a local object's state
|
||||
member is followed by its references and by other objects' derives through one member source per object and member
|
||||
name; when a new attempt goes live every member re-binds by name (one diff frame from the last value, consumers'
|
||||
derives recompute, a member the new variant lacks fails `invalid/member`), as a remote reference re-holds and gets a
|
||||
|
|
@ -1763,12 +1763,717 @@ validation and copies the v2 kernel added around Delta.
|
|||
back: `host.serve(R, { compose, accepts: { [old]: { contribution?: (old) => current, value?: (current) => old } } })`;
|
||||
a reader bundling an older token gets the composed value mapped to its version (computed once per composition and
|
||||
version, only while such a reader exists); without a `value` mapping, or for a reader newer than the provider, its
|
||||
value is failed `contract_mismatch` (reported).
|
||||
value is failed `contract_mismatch` (reported); such a reader fails `broken`, not `transient` (nothing heals it before
|
||||
a reload, which retries broken objects; build review 2026-10-27).
|
||||
- **Slice 8 S3: `self.registry` is a dependency, as `self.get` (owner, 2026-10-27: "isn't self.registry meant to wait
|
||||
like self.get?"; my spec had it as a plain read, a mistake against design §6, §7 and §17):** activation waits until
|
||||
the registry has a value (served and composed once; an empty table composes too, at the serve), bounded by
|
||||
`readyTimeout`; without one in time the object fails `transient` (`unavailable/undeclared`) and is retried when the
|
||||
registry gets its first value (R18); once live, a later failure of the registry changes nothing (last good value).
|
||||
the registry has a value (served and composed once; an empty table composes too, at the serve); after `load`'s
|
||||
setups a registry without a value cannot get one before a reload, so a reader then fails at once `transient`
|
||||
(`unavailable/undeclared`) and is retried when the registry gets its first value (R18), as `self.get` of a type not
|
||||
served here fails at once (refined 2026-10-27 after the second spec review: no `readyTimeout` wait at start); once live, a later failure of the registry changes nothing (last good value).
|
||||
- **Slice 7b's open points, as recommended (owner, 2026-10-29: "yes"; brief `slice-7b/`):** (1) the API is
|
||||
`host.reload(facets, ctx)`, by plugin name (added, changed; a plugin missing from the list is removed), resolving with
|
||||
a report of what each object did, rejecting with the code error and nothing changed; `load` stays the first call. (2)
|
||||
A `writable` across a pure swap goes back to its initial value (the new factory makes a new one; no positional
|
||||
matching). (3) The rollout across hosts is the app's: it calls `host.reload` on each host in order (server, workers,
|
||||
clients); Chord guarantees per host (staging, commit, `installing`, rollback). (4) No install watchdog: `reload`
|
||||
takes a context and the app passes its deadline. (5) The loader lives in Chord (`chord/next/loader`, slice 7c). (6)
|
||||
Chord ships the app kit as optional subpaths: `loader` and `dev` (7c), `http` and `workers` (slice 10), `web` and
|
||||
`tui` (slice 11); telemetry (slice 9) last.
|
||||
- **Slice 7b's build decisions (owner, 2026-10-29, answering the builder; `slice-7b.md` §13):** a loaded plugin passed
|
||||
as the same facet object (`===`) is unchanged and untouched, any other facet value for a loaded name is changed;
|
||||
activating (not parked) and backoff-waiting objects of a reloaded plugin are retried; the report's `parked` covers
|
||||
swaps and retries (their first dependency check), restarts are reported at once; a swapped-out attempt is sealed
|
||||
once its calls settled (bounded by `closeTimeout`) and the swap ended, its job invocations finishing on their old
|
||||
code; a stuck old routine call is reported `drain_timeout` and fenced by the old routine calls' shared token; a swap
|
||||
to another variant sends holds a fresh `bound`; a native restart ends in-memory runs `aborted { reload }` while
|
||||
persisted ones follow 6b R3 (stopped, resumed per `resume`, no restart counted); staging is synchronous
|
||||
(`installing` tested from a staged factory); a removed object's member sources and pinned keys are handed to the next
|
||||
object of its identity (fix round 3: one hand-over per identity for removal, version refusal and an object ending
|
||||
while requests wait, `slice-7b.md` §13 item 11); keyed members' versions are built in 7b (L1's keyed edits and an accepts `keyed` section,
|
||||
decision "Keyed state members, local contracts only"). The builder's own choice, accepted by the owner (2026-10-29):
|
||||
adapted state members are derives per object and version, not per binding. (**Superseded in part** by "References
|
||||
are the only indirection": the hand-over of member sources and the per-version sources that outlive an object go.)
|
||||
- **References are the only indirection (owner, 2026-10-29: "go"; after seven fix rounds of 7b found one edge case after
|
||||
another in sources handed between objects):** supersedes in part 6a's "members across attempts" (one member source
|
||||
per object and member name) and slice 7b decisions 8 and 11 (member sources and pinned keys frozen and handed to the
|
||||
next object of the identity; per-version outward sources owned by (identity, version); `host/handover.ts`). The
|
||||
problem: a consumer's watch or derive over `ref.count` was attached to the referenced object's own document or derive
|
||||
(the reference was bypassed), so every change of what the reference points at needed a stable node somewhere else
|
||||
(`MemberSource` on the object, then sources that outlive the object and move to the next one). The rule: (1) a
|
||||
consumer's watches and derives over a referenced object's member attach through the reference's member, which passes
|
||||
the subscription straight through to the object's own document or derive (no extra hop on commits). (2) When what the
|
||||
reference points at changes (a new object instance after a factory re-run: reload, retry, restart; a new object
|
||||
after the type was removed and served again; another variant), the reference moves its subscriptions to the new one
|
||||
and sends one frame, a diff from the last value. (3) While it points at nothing, its members keep their last values
|
||||
(a pointer, no copy): while loading (a restart, a retry, a swap whose new instance is not live yet) its watches get
|
||||
no call; while failed (a live failure, its version refused, its type removed, a failed dependency) one `failed` call
|
||||
with the cause's error, and they stay open (replaces 6a's "no frames while failed"); a reference that never had a
|
||||
value reads `not_ready` while loading and its failure's error while failed, and its watches wait for its first values
|
||||
(a member the new variant lacks fails `invalid/member`, as before). Keyed watches and pins re-pin on each new instance the same way (the
|
||||
cross-attempt key sources of 6a go; a key keeps its last value until the new instance loaded it; a dependent's
|
||||
factory pin is still loaded before the dependent goes live). Once hydrated, a reference has last values for every state member of its bound variant, read or not: when it
|
||||
detaches it takes them at its version from the outgoing instance (through that instance's mapping for an older
|
||||
version), keeping no node of it. After a `failed` call, the next good value arrives as `[["r", value]]` even when
|
||||
unchanged; a watch opened while failed starts at the last value and gets the failure once. No source, mapping,
|
||||
document handle or pin of an instance outlives it through a reference: a detached reference keeps only plain last
|
||||
values and its failure; releasing it drops them and its bindings; stopping a keyed watch drops its pin. (4) The same rule over a link: a replica keeps its last values
|
||||
readable while its hold is failed. (5) An older consumer (`accepts`) attaches to the object instance's shared mapping
|
||||
for its version: per member one derive `(current) => old`, made on first use, dropped when no reference of that
|
||||
version is left, gone with the instance (one map and one diff per commit per version, whatever the number of old
|
||||
references); a version the instance does not accept gives no member (unbound, last values kept). A reference knows
|
||||
its version from the contract token it was made with. Nothing outlives its object instance; nothing is handed over.
|
||||
Kept: requests never end on a code change, healing on rebind, `accepts` for arguments and results. A simplification
|
||||
audit of slices 6a to 7b follows 7b's commit (owner: "how much other such bullshit was added in the last slices").
|
||||
Refined after the gap checks (Astra, Sol, Opus) and the build (owner, 2026-10-29: "i accept the recommendations"):
|
||||
a detaching reference follows the old instance until it stops changing (its seal after admitted commits, a restart
|
||||
after its drain), then takes its last values for every member of its bound variant, read or not, never from an
|
||||
instance it never followed; after a `failed` call the next good value is `[["r", value]]`; a watch on a reference that
|
||||
never hydrated resolves at its first binding (rejects on release, revocation, close, abort); release drops last
|
||||
values and bindings; registry values and job state are not affected. **Derives over a failed reference member compute
|
||||
on its last value** (G2 (A), as 6a): a contribution built on it stays in its table; only the reference member's own
|
||||
watches get the `failed` call, and `ref.state` shows the failure. **Activation churn +9.6% accepted** (CPU 22, 104.7-107.9
|
||||
→ 114.7-118.3 µs, allocation equal): an object whose derive reads another object's member makes one pass-through
|
||||
node per reference member per instance; calls, commits, links and memory are within the bar. Two references to one
|
||||
key hold two pins (counted on the key's one source; the document is loaded and kept once). **Binary append commit-to-frame
|
||||
median +10% accepted** (owner, 2026-10-29; CPU 10, 0.031 → 0.034-0.035 ms): equal with the young generation pinned, fewer
|
||||
scavenges over the whole benchmark (V8's young-generation sizing, slice 7b fix round 3).
|
||||
- **Per plugin at startup and reload (owner, 2026-10-30: "yes", "1. lgtm 2. yes"; analyses
|
||||
`/tmp/per-plugin-{opus,fable}.md`):** supersedes "Rollback is all-or-nothing per host" (2026-10-2x, 7b §2, design §21)
|
||||
and 7c's Q2 ("a facet throwing while evaluated fails its host's whole step"). Why all or nothing was chosen: per plugin
|
||||
was decided first and p2's plugin reviewers found counter-traces (a dropped argument field written into a transcript; a
|
||||
copied Durable document written by two versions; a provider's failed factory leaving the harness hanging; mixed copied
|
||||
code and registries); v2 has since closed them (closed argument schemas for every caller, Q12, Q15; version binding
|
||||
with waiting, 7a; ownership of documents; registry versions with `accepts`, Q8; copied cross-plugin files limited to
|
||||
contracts without dependencies, 7c). Rules: (1) **at startup** a plugin whose build, evaluation or setup fails is left
|
||||
out; `load` resolves with a report `{ failed: [{ plugin, error }] }` and never closes the host for it (the core facet
|
||||
included: base code decides whether that is fatal); its consumers wait `failed { waiting }` as for any missing plugin.
|
||||
(2) **At reload** a plugin whose new code fails (build, evaluation, setup, a factory in staging, a declaration error,
|
||||
`checkAccepts`) keeps its old code (its loaded facet, unchanged), and every other plugin of the reload applies; the
|
||||
report lists it (`failed: [{ plugin, error }]`, as `load`'s; an added plugin that fails is left out); staging retries without it. (3) **The closure rule** (Opus): after
|
||||
staging, a changed plugin whose recorded requests would not bind because the plugin they need was kept old or left
|
||||
out is kept old too, so a consumer edited together with a broken provider keeps working on its old code. Its "missing"
|
||||
branch (owner, 2026-10-30: "keep", against narrowing it to names served before): a request nobody serves keeps its
|
||||
plugin old whenever any plugin of the reload failed, since a failed new setup may be what would have served it. (4) What no
|
||||
rule detects: a consumer relying on new behaviour of a kept provider whose contract (version, fingerprint) did not
|
||||
change: the same risk as a rollout across hosts, documented. (5) `rollout` keeps a plugin dirty while it was kept old
|
||||
on any host. Built with slice 7c (the kernel's `load` and `reload` change with it).
|
||||
- **Slice 7c's points (owner, 2026-10-30; `slice-7c.md` §5):** (1) esbuild is an optional peer dependency of Chord,
|
||||
imported only by the plugins component. (2) **No dev/prod split** (owner: "chord apps are supposed to not have a
|
||||
prod/dev split. they are supposed to be self modifying applications"): watching, rebuilding and rolling out are core
|
||||
machinery; one component `chord/next/plugins` (discovery, build, evaluation, `pluginFile`, the watcher, `rollout`) in
|
||||
`src/next-node/plugins/`, exported with v2's entry point; it turns V8's compilation cache off when created; reloads
|
||||
stay explicit (the watcher marks, never applies). Supersedes the separate `loader` and `dev` subpaths of "Slice 7b's
|
||||
open points" (5), (6). (3) `installDependencies(plugin, ctx)` as coding-agent installs package dependencies (`npm
|
||||
install --omit=dev --legacy-peer-deps --ignore-scripts --no-audit --no-fund` in the plugin's real directory, the
|
||||
command overridable). (4) Cross-plugin imports: any file of a discovered plugin, copied, documented as contracts and
|
||||
side-effect-free modules only, listed by the build, no static check yet. (5) `pluginFile` answered per facet by the
|
||||
`require` evaluation passes.
|
||||
- **Slice 7c build: an abort after the commit names what it applied (owner, 2026-10-30: "yes"; fix round 1, item
|
||||
11, Astra):** `host.reload` still rejects `aborted` when its context aborts after the commit (the commit stands, only
|
||||
the wait ends), and the error carries `details.committed`, a `CommittedReload { added, changed, removed, failed }`
|
||||
(plugin names; `failed`: kept on their old code). Why: the plugins component keeps per host the facet each plugin
|
||||
runs and adopts it when the reload resolves; on this rejection it would keep the old facet and pass it at the next
|
||||
reload, swapping the committed plugin back silently. Rejected: resolving with the report after the commit despite
|
||||
the abort (it would change the rule that an abort after the commit rejects). Examples for 7c (owner): full coverage,
|
||||
each example a folder with the host code and real plugin directories (35-39).
|
||||
- **Slice 10's points (owner, 2026-10-30; `slice-10.md`):** (D1, "a"; `httpServer` **superseded** by `createHttp`, decisions "The HTTP core is a fetch handler" and "Settings are arguments") the HTTP component defines the registry `Http`
|
||||
(`"chord.http"`) and serves it through a facet of its own (`http.facet`, plugin name `chord.http`) that the app loads
|
||||
with its plugins; its `compose` builds the router; the server reads it with `host.registry(Http, ctx)` per request.
|
||||
(D2, "a") Wraps are built in slice 10 as designed (decision "HTTP routes and wraps", design §23): a contribution's
|
||||
`wraps` run around routes in the component's phases (`observe`, `headers`, `transform`, outermost first), within a
|
||||
phase in contributor order, by default around their own plugin's routes (`scope: "own"`; `"all"` explicit; `route`
|
||||
narrows to one, a missing one a reported conflict). The server's `headers` option stays for every response, also
|
||||
those no route makes (a 404, a refused request).
|
||||
(D3, "a"; **superseded** by "The HTTP server is a native object": the `Authenticators` registry) Authentication is one app function, `authenticate(request, ctx) => P | undefined`, called once per request
|
||||
and per upgrade; no Chord registry of resolvers (p3 composes its own from sharing, devices and the relay, with its
|
||||
own precedence rules).
|
||||
(D4, "k": a) Request bodies: `request.json(limit?)` (parsed JSON, 1 MiB by default, malformed: 400) and
|
||||
`request.body(limit?)` (an async iterable of byte chunks, for uploads); nothing is read until a route asks; past its
|
||||
limit the read stops and the request is answered 413; a disconnect aborts `ctx` and ends the iteration.
|
||||
(D5, "a") Responses are descriptions: `json`, `html` (a full document; replaces the design's `page { title, body }`),
|
||||
`bytes` (`mime`, `size`, `read(offset, length, ctx)`, `download?`, `ranges?`; 206/416, `HEAD`, safe
|
||||
`Content-Disposition`, 64 KiB reads with backpressure, stopped on disconnect), `redirect`, `error`; `headers` on every
|
||||
kind (cookies, a preview's own CSP); `fileResponse(path, options)` over a file; a throwing route is a 500 without its
|
||||
message.
|
||||
(D6, "a") Plugin WebSockets are `sockets` in the `Http` contribution: `{ "/terminal": (socket, request, ctx) => … }`;
|
||||
the component checks the origin and runs `authenticate` (no principal: refused, 403, before plugin code), upgrades
|
||||
and hands the plugin a `ws` WebSocket for its own protocol; patterns as routes; wraps do not apply; the links path is
|
||||
the component's (`host.accept`). The design's channel component (acknowledged byte channels) waits for a second user
|
||||
and would be built on top of it.
|
||||
(D7, "a") `ws` is an optional peer dependency of Chord (pinned exactly), imported only by `next-node/http`, as
|
||||
esbuild for plugins.
|
||||
(D8, "a") Route patterns: exact segments, `:name` (one non-empty segment, percent-decoded, `params.name`) and a
|
||||
trailing `/*` (the rest, possibly empty, `params["*"]`); the most specific match wins, segment by segment from the
|
||||
left (exact before `:name` before `*`); one pattern from two contributions is a reported conflict, the first by
|
||||
plugin name wins; `HEAD` falls back to the `GET` route.
|
||||
(D9, "a") Worker keep-alive: the child reports `host.activity` and the app's `busy` counts (a JSON watchable of
|
||||
numbers, e.g. Durable's active tasks) whenever they change; the server stops a worker whose counts were all zero for
|
||||
`idleMs` (default 5 min, checked every second), never while it starts; a stop sends `shutdown` (the host closes),
|
||||
the process tree killed after 5 s.
|
||||
(D10, "a"; **superseded** by R2 below: no automatic restarts, no route backoff) A failed worker start uses the kernel's route backoff (1 s doubling to 60 s while holds exist,
|
||||
`resolve_failed` reported); no sticky failure; `workers.state` shows the error and when it is tried again.
|
||||
(D10b, "b") `host.route(Kind, options, ctx)` returns a `RouteHandle` with `retry(address)`: it ends that address's
|
||||
backoff, so `resolve` runs now if the address has holders (a Retry button for a worker that failed to start, wired
|
||||
by the app to the handle; the workers component has no `retry` of its own).
|
||||
(D11, "a") Workers never build: the server's plugins component discovers and builds every plugin; a worker's start
|
||||
message carries its kind's current scripts (`Built`, plain data) and a reload ships new ones (D12); the child's
|
||||
`plugins` only evaluates (no esbuild, no watcher). So plugins are defined where the server can read them, one set for
|
||||
every host (owner: "no cwd specific plugins, which might be alright; that way workers can live anywhere"); project
|
||||
plugins (design §26 #14) would be discovered and built by the server too. A worker on another machine needs the
|
||||
plugin directories there only for `chord.external` packages and `pluginFile`.
|
||||
(D12, "a") A rollout step returns one report per host, `{ hosts: [{ host, report }] }` (7c's `RolloutStep` changes;
|
||||
the in-process server step returns a list of one); the workers step reloads every running worker in parallel; a
|
||||
worker that closed during it is listed with its error and does not stop the rollout; a plugin's dirty mark clears
|
||||
only when it applied on every host of every step.
|
||||
Spec review (Fable, Opus): (R1, "a") `RouteHandle.suspend(address)`: while suspended the route does not resolve the
|
||||
address, its holders stay `loading`; `retry(address)` ends the suspension; `workers.stop` suspends, then stops (an
|
||||
archived session stays stopped while a tab still holds it).
|
||||
(R2, owner: "take it"; **supersedes D10 and the kernel's route backoff**) **Workers start on demand and never
|
||||
restart by themselves** (owner: "if it crashes, chances are it had a good reason to crash, and restarting it won't
|
||||
fix that"; D10 as recommended was a restart loop whenever a consumer holds). A worker starts for a hold of an address
|
||||
that has not failed (the first, or one after an idle stop or `stop`). A crash or a failed start marks the address
|
||||
failed with its error until `workers.retry(address)` (`RouteHandle.retry`) or a rollout that reaches workers (new
|
||||
code may fix it, as a reload retries failed objects); its holders and new holds see `failed` with that error, nothing
|
||||
forks. Kernel: a route has no backoff timer; it resolves only for a hold, on `retry`, or after its link dropped while
|
||||
held, and a rejected `resolve` fails the route's holders with its error (no endless `loading`); a network route that
|
||||
wants to reconnect keeps that policy in its own `resolve`. The workers component owns its route
|
||||
(`createWorkers(host, Kind, options)` calls `host.route` and keeps the handle) and answers a resolve of a failed
|
||||
address with the stored failure at once. A start finding the lock still held by a draining old worker waits for it in
|
||||
the child (bounded by the start timeout). R1's `suspend` and D10b's `retry` stay.
|
||||
Other timed retries (owner, 2026-10-30: "keep them as is for now"): objects' transient failures (1 s to 60 s while
|
||||
held), `self.on` handlers that throw (1 s to 60 s while live) and `self.onLive` restarts (the same, reset after a
|
||||
minute's run) stay; they heal real transient failures (a network down for a while) and cost at most one try a minute
|
||||
each; revisit if one shows up as a loop in practice.
|
||||
(R3, "a"; refines D9) The child reports only the flip: `{ idle: true }` when every count (`host.activity` and `busy`)
|
||||
becomes zero, `{ idle: false }` when one becomes nonzero; the server's idle clock starts at `true` (a status message
|
||||
costs about 6 µs on each side, a local call 2.9 µs: no report per call).
|
||||
(R4; **superseded in part** by "allowed hosts only through contributions" and "The HTTP component's security baseline": no `allowedHosts` option, provenance by the socket; "a if configurable at runtime so allowed domains can be added and removed by a plugin") Before `authenticate`, the
|
||||
component refuses (403) a `Host` that is neither loopback nor allowed, and an upgrade (`/chord`, a plugin socket)
|
||||
whose `Origin` is not the request's own origin; `request.origin` comes only from an accepted `Host` (`http://` on
|
||||
loopback, `https://` for an allowed host); `headers(request, principal)` gets the principal. Allowed hosts are the
|
||||
server option `allowedHosts` plus every `hosts` list in `Http` contributions: a plugin adds and removes its domains
|
||||
at runtime by contributing a writable (`self.writable`, design §17), the router recomposed on each change (a tunnel or
|
||||
relay plugin names its public host).
|
||||
**The app kit is malleable by plugins** (owner, 2026-10-30: "all of this should be malleable via plugins"; "yes"):
|
||||
in `http`, `workers` (and slice 11's components) every option that is not bootstrap is either a contribution to
|
||||
`Http` (routes, wraps, sockets, hosts; writables where it changes at runtime) or a function the component calls per
|
||||
use (`authenticate`, `headers`, a worker's `start`, `idleMs`), which the app backs with its own registries; nothing is
|
||||
copied out of a contribution or an option at startup. Bootstrap stays base code, changed by a restart: the listen
|
||||
address and port, the links path, the worker entry module with its `execArgv` and `env`, the host kind workers serve.
|
||||
(R5, "agree") `workers.state` per address key: `{ status: "starting" | "running" | "stopping", pid, startedAt }` or
|
||||
`{ status: "failed", error: { message, code?, reason? }, at, stderrTail? }`; no entry when stopped or never started
|
||||
(a clean stop removes it); `failed` stays until Retry or a rollout; plain JSON (a core object may serve it).
|
||||
(R6, "b, common enough") `bytes` may omit `size`: chunked transfer encoding, no ranges (no `Accept-Ranges`), `read`
|
||||
called with growing offsets until it returns an empty chunk (a zip built on the fly, a growing log, an export paged
|
||||
from a worker).
|
||||
Spec review round 2 (Fable, Opus, Astra, Sol): (Q1, recommended) holders of a failed worker get `failed { waiting }`
|
||||
with the stored error as cause and stay on the address's path: a later link of the address (Retry, a rollout) binds
|
||||
them without a re-hold; a `get` waits its `readyTimeout` meanwhile.
|
||||
(Q2, "a") A rollout that reaches workers clears the failures from before it, then calls `RouteHandle.retry` for the
|
||||
cleared addresses that have holders (they start with the new code and are listed in the step); an unheld one starts
|
||||
at its next hold; a worker that fails during this rollout stays failed (no loop: one start per held failed address per
|
||||
rollout a person triggers).
|
||||
(Q3, "a"; refines R1) A `stop`'s suspension lasts while the address has holders; they see `failed { waiting }`
|
||||
("suspended"); with the last holder the record goes, and a later hold starts the worker on demand (as after an idle
|
||||
stop); whether an archived session may be opened is the app's (`authorize`, its views). Memory stays bounded.
|
||||
(Q4, "a") The start timer (60 s) covers fork, lock and connect (`started`) only; after `started` a slow `load` is
|
||||
objects showing `loading`; `ready` sets `state` to `running` and lets idle stops begin; a `load` that never ends
|
||||
shows `starting` until `stop` (no kill, no retry).
|
||||
(Q5, "a"; refines R3) A worker's idleness is `references`, `jobs`, `lingering` and the app's `busy`, not `calls`: a
|
||||
remote call runs on a hold (counted), local calls come from routines (not counted, §14), jobs, Durable tasks (`busy`)
|
||||
and handlers under a hold; no message per local call, no timer. The first report goes with `ready` (`ready { idle }`),
|
||||
so a worker idle from its start is stopped too.
|
||||
(Q6, owner: "if sockets are a contribution, why keep them when the contribution is removed?": design §17's rule, an
|
||||
entry lives as long as its contributor and consumers keep nothing beyond a use) A plugin socket is closed (1012,
|
||||
"service restart") when the contribution that handed it out leaves the table (a reload changing or removing it); a
|
||||
`/chord` link or socket whose `Host` is no longer allowed closes (1008) when the allowed hosts change.
|
||||
- **Slice 11's points (owner, 2026-10-30; `slice-11.md`):** the surfaces are generic plumbing; the app keeps
|
||||
everything visible (owner: "this one needs to be generic, so apps can do whatever they want, css, tui components,
|
||||
themes"). (P1, "a") `chord/next/web` ships serving the surface (the base bundle, the plugins' web scripts,
|
||||
stylesheets and assets, the page through the app's template, all as `Http` contributions), the browser's client host
|
||||
with its connection loop, and loading and applying the plugins' web facets; not HTML, CSS, components, themes,
|
||||
navigation, slots or layout.
|
||||
(P2, "script tag") A plugin's web script is served as a file that registers itself
|
||||
(`chordFacet("<plugin>", "<digest>", function (require, module, exports) { … })`) and is loaded with `<script src>`
|
||||
under a `'self'` CSP: no `eval`, no `new Function`.
|
||||
(Kinds, owner: "ya"; changes 7c's `createPlugins` options, built in slice 10) One definition per facet kind:
|
||||
`kinds: { server: { platform: "node", hostPackages: [...names] }, web: { platform: "browser", hostPackages: [...] } }`
|
||||
replaces `kinds` plus `browserKinds` and the component-wide `hostPackages` map. The platform decides the build (Node:
|
||||
builtins and `chord.external` external, no CSS or `?url` assets; browser: CSS and assets collected, builtins and
|
||||
`chord.external` refused); the names keep those imports external. The module objects are given where scripts are
|
||||
evaluated: the server's own at `plugins.load(host, { kind, core, modules })`, a worker's in `workerMain`'s
|
||||
`hostPackages`, a page's in `startPage`.
|
||||
- **Slice 10 build: plugin sockets get a heartbeat (owner, 2026-10-30: "does it make sense? then yes"):** the HTTP
|
||||
component pings every plugin socket with WebSocket ping frames after 15 s of silence and terminates one silent for
|
||||
30 s (the link heartbeat's numbers); browsers and `ws` answer pings by themselves, so plugin code is unchanged. A dead
|
||||
peer (a phone that vanished) no longer keeps a plugin's resources (a terminal view) until TCP gives up.
|
||||
- **Origins only from relays (owner, 2026-10-31: "why would child links carry origin? the server keeps track of origin
|
||||
per connection and relays that to workers"; slice 10 security audit, Opus K1/K2, a hole since slice 4a):** `origin`
|
||||
is read only on parent and route links (where a relay's messages arrive); on a child link (a tab, a TUI) a message
|
||||
carrying `origin` is a protocol error that closes the link; the server numbers each child connection itself, as it
|
||||
does. Every link drops a per-origin record once it has no holds, no calls and nothing in flight (origins are never
|
||||
reused). Was: honoured on every link, so a tab could invent origins, each with its own call window (5,120 concurrent
|
||||
calls from one guest socket) and a record kept until the socket closed (about 1 KiB each). Supersedes design §3's
|
||||
"`origin` is honoured on every link". Fixed in slice 10's fix round.
|
||||
- **Slice 10 fix round, worker errors in public (owner, 2026-10-31: "a"; refines R5):** two levels. Public (`workers.state`
|
||||
and the holders' `failed { waiting }`): a `fatal` as the child's `redact` made it; a start error or a crash as a fixed
|
||||
message with a code (`"worker failed to start"`, `"worker exited (SIGSEGV)"`); no stderr in `state`. Private: the full
|
||||
error and the stderr tail go only to the server's `onError` (`worker_failed { address, error, stderrTail }`), the app's
|
||||
logs to redact. Was: `state.stderrTail` and the raw start error, which leaked secrets to guests (security audit, Astra,
|
||||
Opus H3).
|
||||
- **Slice 10 fix round, allowed hosts only through contributions (owner, 2026-10-31: "a, and we close everything that's
|
||||
been removed"; refines R4):** the server option `allowedHosts` goes; the app contributes its hosts like any plugin
|
||||
(fixed ones from its core facet, changing ones from a host-lifetime object's writable). Whatever is removed is closed
|
||||
at once: when a host leaves the allowed set, or a plugin's contribution (its sockets) leaves the table, every link and
|
||||
socket that came through it is terminated immediately (no graceful close the peer could ignore, Astra A1), its scope
|
||||
sealed; an upgrade still authenticating is checked again against the current set and router before it is handed out
|
||||
(Astra A2).
|
||||
- **The HTTP component's security baseline (owner, 2026-10-31: "this mechanism should come out of the box in http/app kit;
|
||||
basically encode all the best practices so users never have to think about it again"; "yes"; p2's rules from
|
||||
`hosts/principals.ts`, `hosts/server.ts`, `loopback-cookie.ts`, devices, sharing):** on by default in
|
||||
`chord/next/http`. (1) Provenance: local only when the socket's local address is loopback, `Host` is a loopback
|
||||
form with the port, and no `Forwarded`/`X-Forwarded-*` header; otherwise only an allowed host (https); else 403;
|
||||
duplicate `Host` and absolute-form targets 403; `request.provenance` `{ kind: "local" } | { kind: "remote", host }`.
|
||||
(2) Foreign requests (Fetch Metadata: `Sec-Fetch-Site` `cross-site`/`same-site`, or a frame another site embeds)
|
||||
never reach `authenticate`: they run anonymous, except a top-level navigation (`GET`, `navigate`) to a route marked
|
||||
`page: true`. (3) A foreign `Origin` is 403 on every request and upgrade; unsafe methods from a browser need `Origin`
|
||||
equal to the app's; a route may opt out (`crossSite: true`, a webhook), then it always runs anonymous. (4) Default
|
||||
headers on every response, the component's own included: `Cache-Control: no-store`, `nosniff`,
|
||||
`Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, a CSP with a per-server script nonce (`default-src 'none'`,
|
||||
scripts `'self'` and the nonce, `connect-src` self and its WebSocket, `frame-ancestors 'none'` or the contributed
|
||||
`framers`); a route widens its own CSP through its response headers. (5) `cookie(name, value, options)`: `HttpOnly`,
|
||||
`Path=/`, `SameSite=Strict` (or `Lax`), `Secure` on a remote origin, signed with a given secret. (6) `bytes` and
|
||||
`fileResponse` refuse foreign requests, are `attachment` unless the route says inline, and inline ones get a
|
||||
`sandbox` CSP. The app decides only who someone is, which routes are pages, its extra CSP sources and its framers.
|
||||
Replaces the CSRF question of the security audit; extends R4.
|
||||
Relays (owner: "sure"): a contribution may declare `relays: [{ name, verify(request) => { origin, framer } |
|
||||
undefined }]`; admission calls them for a request that would be refused for forwarded headers on loopback (p2's
|
||||
Radius relay); a verified one gets `provenance: { kind: "relayed", relay, origin }` and its framer allowed for it,
|
||||
an unverified one stays refused; `authenticate` decides its principal.
|
||||
- **The HTTP server is a native object (owner, 2026-10-31: "still unconvinced the server can't be a native object; it's a
|
||||
singleton, unlikely to change"; supersedes D3 and the `httpServer` function of D1):** `chord.http`'s facet serves the
|
||||
registry `Http` and a host-lifetime object whose native is the listener; its configuration (`HttpConfig`: port,
|
||||
links path) and `Authenticators` are registries the app (or the app kit) contributes to. The facet is passed as the
|
||||
same object at every reload, so the listener never closes before the process ends. The port opens right after
|
||||
`load`'s publication (a few hundred milliseconds later than listening before `load`; a reconnecting tab retries).
|
||||
- **The app kit is opinionated (owner, 2026-10-31: "the app kit should be super duper opinionated and make for small
|
||||
app code"; "i suppose"):** after slice 10's fix round and before slice 11, a design pass on `chord/next/app`: one
|
||||
entry (`startApp`, `startWorker`), conventions for layout, hosts, principals, authentication, storage, plugins, web
|
||||
and TUI, decided point by point; the slice 10 components are its inside. Inputs from the owner: user management is
|
||||
part of it ("user management will also need to be a thing we support"), and app shapes without workers (server and
|
||||
clients only: "a chat app doesn't need workers").
|
||||
- **Plugins import each other by name (owner, 2026-10-31: "i like it"; changes 7c's cross-plugin rule; **superseded** by "Contracts come from where the plugin's code came from", 2026-11-01):**
|
||||
`import { Notes } from "@app/notes/contract"` resolves to the discovered plugin named `@app/notes`, its file
|
||||
`contract.ts`, wherever its directory is (any root), copied into the importer's script as before (contracts and
|
||||
side-effect-free files only; they may import only host packages and files of their own plugin); a missing plugin is
|
||||
a build error naming it; relative paths into another plugin's directory are refused. Owner's notes for the app kit
|
||||
pass: third-party plugins need the imported plugins' sources or types for type checking (publishing each plugin to
|
||||
npm works but is unpleasant); plugins should be testable on their own, with their dependencies mocked.
|
||||
- **The HTTP core is a `fetch` handler; Chord's own Node server is the easy path (owner, 2026-10-31: "could http just plug
|
||||
into hono/express?"; "we should still provide the easy path using just node's shit as well, for small apps or apps
|
||||
where we don't care"):** the component builds one Web-standard handler, `(request: Request) => Promise<Response>`,
|
||||
from `Http` (admission, the security baseline, router, wraps, descriptions), plus per-runtime upgrade adapters for
|
||||
`/chord` and plugin sockets. The default is Chord's own listener, the native host-lifetime object of `chord.http`
|
||||
over Node's `http` (decision "The HTTP server is a native object"); an app with its own server mounts the handler
|
||||
(`hono.all("/*", (c) => http.fetch(c.req.raw))`, an Express adapter) and passes upgrades to the adapter. The same
|
||||
routes then run on other runtimes later (Bun, Durable Objects, design §26 #16). Built in slice 10's fix round
|
||||
(reworks `server.ts`, `request.ts`, `response.ts`; router, admission rules and descriptions stay).
|
||||
- **Slice 10 fix round, three small points (owner, 2026-10-31: "yes on fileResponse etc"):** (1) `fileResponse(path, {
|
||||
root? })`: with a `root`, a file whose real path lies outside it (a symlink out) is refused 403; without one the path
|
||||
is trusted (a plugin's own file). (2) The worker lock on macOS and the BSDs fails closed on a stale lock file, with an
|
||||
error naming it (no takeover; Linux and Android use abstract sockets and are unaffected). (3) A route's overhead:
|
||||
Opus's two trims, measured again after the `fetch` rework, and the numbers brought to the owner.
|
||||
- **Slice 10: a route's overhead accepted; no header accessor (owner, 2026-10-31: "1 yes, 2 no"):** after the `fetch`
|
||||
rework and the Node fast path a route costs +20-21 µs over a plain Node handler on the standard bench, +12-13 µs warmed
|
||||
(before the rework +4.5 µs): the request's scope (2.1 µs), URL parsing, admission and routing, the baseline's headers,
|
||||
the scope's seal, and about 5 µs of Node's microtask machinery an async path triggers. Accepted against the 10% bar.
|
||||
No `request.header(name)` accessor: an authenticator reading headers builds the Web `Request` (about 3.5 µs more).
|
||||
- **A CSP nonce per response (owner, 2026-10-31: "csp ok"; Fable's recheck):** the HTTP component makes a fresh script
|
||||
nonce for every response (`request.nonce`), not one per server: a nonce read from one page cannot be reused by an
|
||||
injected script elsewhere.
|
||||
- **Settings are arguments, extensions are registries (owner, 2026-10-31: "making that config a registry is dumb; it's a
|
||||
general problem for all host-lifetime natives that need initialization at host startup"; "k"; supersedes the
|
||||
`HttpConfig` registry of "The HTTP server is a native object"):** what the app decides once at startup (a port, frame
|
||||
limits, a timeout, a data directory, which implementation to use) is an argument to the component's facet, which base
|
||||
code constructs; the facet and its host-lifetime objects close over it; changing it is a restart; no plugin can
|
||||
touch it. What plugins add while the app runs (routes, authenticators, tools, panels, more indexers) is a registry.
|
||||
HTTP: `createHttp(host, { listen, links, closeTimeout, onError }, ctx)`; `Http` and `Authenticators` stay registries.
|
||||
The same for the harness (its store and defaults as arguments; models, environments, extensions as registries) and
|
||||
the app kit's components (a session index takes its indexer as an argument, and may also follow a registry of
|
||||
indexers if plugins should supply them).
|
||||
- **API versions in the import path (owner, 2026-10-31: "this sounds about right"; "must be declared by the plugin in
|
||||
code, we do not want to dig around package.json files anymore"):** the app's base code is internal except its API
|
||||
modules, which it exposes as host packages with a version in the path (`@acme/app/v3`; Chord's
|
||||
`@earendil-works/chord/next/v2`); the app lists the versions it still provides (an older one as the same module, or a
|
||||
thin layer); an import of an unlisted version is a build error naming the available ones. No `package.json` field
|
||||
and no npm ranges for this. Contracts and registry tokens inside an API keep their own versions. The rest of the
|
||||
plugin model is a proposal (`docs/next/plugin-model/`).
|
||||
- **A schema package beneath pi-ai and Chord (owner, 2026-10-31: "i want the schema package"; "if we keep it a
|
||||
dependency in pi-ai, we must be able to rip it out"; TypeBox is "horrible to treeshake"):** a small package with no
|
||||
dependencies (working name `@earendil-works/schema`) under both pi-ai and Chord (siblings: neither depends on the
|
||||
other): helpers returning plain JSON Schema, a structural `JsonSchema` type, `Static` from the helpers' literal
|
||||
types, and a validator for general JSON Schema (tool schemas from MCP servers and extensions use `$ref`, `anyOf`,
|
||||
`pattern`, ...), without `eval` or `new Function` (CSP-safe). Chord v2 drops TypeBox; pi-ai's core never imports
|
||||
it (TypeBox schemas still pass as JSON Schema; TypeBox-typed extras only in an optional subpath, so ripping it out
|
||||
is deleting that subpath). Not omptype (its validator compiles with `new Function`; its 10.7k lines are mostly
|
||||
ArkType's API). Open: which validator (measure `@cfworker/json-schema` and TypeBox against the official
|
||||
JSON-Schema-Test-Suite and Chord's argument checks first), and moving `Type` out of pi-ai's main export.
|
||||
**Built** (owner, 2026-10-31: "clone pi add the package, done"; pi `fab2c53`): `packages/schema`,
|
||||
`@earendil-works/pi-schema`: `compile(schema, { draft, formats, schemas })` with `check` (narrowing) and `errors`
|
||||
(JSON Pointer paths), Chord-style builders typed with `Static`, formats as the opt-in `./formats` entry, leaf and regex
|
||||
checks shared through weak caches (GC-tested); its own closure-compiled validator, chosen after measuring
|
||||
(`/tmp/schema-measure.md`, Radius 01m69x3dz2fsg9k1r33vde7hv6): 2,222 of 2,223 core suite tests (TypeBox 2,200,
|
||||
cfworker 2,167, omptype 1,522), Chord checks at 0.95-1.3x TypeBox's compiled speed and the same with `new Function`
|
||||
blocked, 7.9 MB for 3,200 distinct schemas (cfworker 7.6, TypeBox 54.7), 8.5 KB gzip. Still open: switching Chord v2
|
||||
and pi-ai to it (`Type` out of pi-ai's main export), formats on by default in pi-ai.
|
||||
- **The schema switch (owner, 2026-10-31):** TypeBox leaves Chord (v1 too: "remove typebox wherever we can"),
|
||||
pi-ai, pi-agent-core and pi-durable; extensions must not break ("absolutely 100%"), proven by a guard over our own
|
||||
extensions, built-in tools and generated arguments (verdicts, coerced arguments, provider payloads and inferred
|
||||
parameter types identical; owner: "just use ours", no npm corpus). **Coercion lives in pi-schema** as an opt-in,
|
||||
the superset of TypeBox's `Value.Convert` rules and pi-ai's own (TypeBox's rule first where they conflict):
|
||||
`compile(schema, { coerce: true })` adds `coerce(value)` (a copy, never mutating) and `parse(value)` (`{ ok, value }
|
||||
| { ok, errors }`); pi-ai uses it for every tool, whatever built the schema (so plain-JSON MCP tools gain the
|
||||
TypeBox rules). **Error texts are pi-schema's own** (no mapping to TypeBox's wording). Extensions keep `Type`,
|
||||
`Static`, `TSchema` from the coding agent's bundled TypeBox (the pi-ai they see is compat plus those); an extension
|
||||
author type-checking against the published pi-ai no longer finds them there (accepted: "fine"). **`format` is an
|
||||
annotation in Chord** ("no format check": no format module in Chord's or Durable's entries; pi-ai still checks
|
||||
formats on tool arguments). **Chord's envelope check costs +10 to +100 ns per message** from untrusted links
|
||||
(pi-schema reads fields by name from a list; no eval): accepted ("negligible"), no hand-written check.
|
||||
- **Contracts come from where the plugin's code came from (owner, 2026-11-01: "ok"; plugin model point 1, primer
|
||||
`docs/next/plugin-primer/`; supersedes "Plugins import each other by name"):** a plugin carries the contract it was
|
||||
written against, never the running app's newest file. The problem: by name, a third-party plugin's next build copied
|
||||
the provider's newest `contract.ts` (version 2), so its version-1 code claimed version 2, `accepts` was never used
|
||||
and it computed wrong results silently. Rule: (A) plugins that change together, the app's own in its repository or a
|
||||
user's in one root, import each other by relative path (`../notes/contract.ts`), copied into the importer's script
|
||||
and listed in `copied`; a copied file may import only host packages and files of its own plugin; a relative path
|
||||
leaving its root (into base code or another root) is a build error pointing to the API or an npm dependency. (B)
|
||||
Plugins that do not change together take contracts as an npm dependency at a fixed version, bundled at the installed
|
||||
version (decision "A plugin's npm dependencies are bundled"); the provider serves it through `accepts` or the
|
||||
consumer waits "contract mismatch". Which package carries the app's contracts is point 5. The by-name lookup and the
|
||||
`tsconfig` path aliases go; built in the plugin slice (examples 35-38, fixtures, design §21, the plugins README, the
|
||||
guide's chapter "Plugins Using Plugins").
|
||||
- **Plugins import only the app's versioned API; Chord is re-exported in it (owner, 2026-11-01: "makes sense";
|
||||
plugin model point 2):** a plugin never imports Chord directly: Chord is not a host package plugins may name, so a
|
||||
direct import is a build error pointing to the app's API. The app's API module per version (`@acme/app/v3`)
|
||||
re-exports the Chord surface the app supports (contract and schema builders, `defineFacet`, `defineRegistry`, the
|
||||
types plugins see) next to its own small part (host kinds, the principal type, `defineFacet` bound to it, core
|
||||
contracts, registry tokens). One version number, the app's, describes everything a plugin gets from its host. The
|
||||
process runs one Chord: an older API version still listed is a layer over today's Chord; when Chord breaks its
|
||||
API it keeps its previous API as a compatibility module for a while, which the app's older version re-exports;
|
||||
once that is dropped the app drops the version and old plugins fail at build naming the available versions.
|
||||
Contracts are data and bind through `accepts` whatever Chord runs. One facet imports from one API version only
|
||||
(checked at build). Only base code imports Chord directly. Bundling is slice 10's host packages: the version is part
|
||||
of the name (`kinds.hostPackages` lists `@acme/app/v3`, `@acme/app/v2`), each process provides its own modules.
|
||||
- **The plugin model, points 3, 4, 5 and 8, imports and builds (owner, 2026-11-01, by voice: "yeah that checks out"; "lock the decisions on the
|
||||
points which was discussed"; primer `docs/next/plugin-primer/`):**
|
||||
(3) **Split by host kind, for the app and for plugins alike.** The app's API per version has a shared module
|
||||
(`@acme/app/v3`: Chord's re-exported surface, host kinds, the principal type, `defineFacet` bound to it, contracts
|
||||
and registries that cross hosts) and per-kind modules (`/v3/web`, `/v3/tui`, `/v3/server`, `/v3/worker` where
|
||||
needed: UI components, registries and `local` contracts that live on that kind only). A plugin's contract files
|
||||
mirror it: `contract.ts` (shared) and `contract.server.ts`, `contract.worker.ts`, `contract.web.tsx`,
|
||||
`contract.tui.ts` as needed. A contract file mentions native things (React components, functions) only through
|
||||
`import type`; at runtime it imports only the shared API and its plugin's data-only files (a hard rule). The build
|
||||
checks every import by the file it resolves to: a per-kind file (`.web.` etc.) or per-kind API module only for that
|
||||
kind, and a file importing a package the kind does not provide (React in a worker) refused. Shared UI between
|
||||
plugins: a plain component through an imported file (inlined into the importer's script, frozen at its build), or
|
||||
a registry when one live instance is needed (React contexts, a feature others embed).
|
||||
(4) **Host kinds on registry tokens** are covered by placement: a token used on one kind lives in that kind's file,
|
||||
so other kinds cannot import it; an explicit `hosts` marker only if a shared token must be limited later.
|
||||
Registries version by hand: readers and writers work across versions through the token's version and the defining
|
||||
plugin's `accepts`; no fingerprint for native values (decided earlier, unchanged).
|
||||
(5) **Third parties get the app's contracts through its versioned API:** each built-in plugin keeps its contract
|
||||
files; the API re-exports the ones the app promises (`export { Notes } from "../plugins/notes/contract.ts"`), so a
|
||||
plugin built against `/v2` keeps v2's contracts; the others stay private. No per-area subpackages.
|
||||
(Imports) **Plainly, with no resolution magic:** within one repository (a root, or a repository of several
|
||||
plugins), relative paths to the files (`../notes/contract.web.tsx`); across repositories, the package name with its
|
||||
exports (`@bob/notes`, `@bob/notes/web`), pinned in `dependencies` by an npm version or a git ref
|
||||
(`github:bob/notes#<commit>`; npm's git dependencies, no registry publishing needed; a plugin in a subfolder of a
|
||||
multi-plugin repository is cloned and linked by the plugins component, which npm cannot do). Rejected: one
|
||||
specifier resolving to a sibling or an npm package depending on `dependencies` ("kinda magic"). Plugins are
|
||||
installed from git by cloning a branch, tag or commit into a plugins root (as the coding agent's git packages).
|
||||
(8, base code) **Base code shrinks to the API, bootstrap and a start-data facet:** the vocabulary (the versioned API modules,
|
||||
host packages, never reloaded), bootstrap (creating hosts, opening the store, components such as `createHttp` and
|
||||
`createWorkers`, which take settings and process values and return facets, their natives opened by Chord, passed as
|
||||
the same object at every reload) and one small facet serving process values (start data, the opened store) as core
|
||||
objects. Every implementation, the harness and the providers of core registries included, is a plugin in the app's
|
||||
own root, loaded, reloaded and failing per plugin like any other; the app may mark its root required, so a core
|
||||
plugin failing at startup is fatal. Built-in plugins follow the same rules as any plugin.
|
||||
(Build) **No build hooks from plugins:** the server builds every plugin with one esbuild call from TypeScript source
|
||||
(no `dist` needed; installs run with scripts off); what the build handles (TS, TSX, JS, JSON, CSS, assets and WASM
|
||||
by URL) is the plugin's toolbox, generated files are committed; a CSS preprocessor or similar, if ever needed, is
|
||||
enabled by the app per host kind, never by a plugin (a stranger's web plugin must not run code on the server).
|
||||
To do: a skill and documentation for agents describing a plugin's layout, its contract files, imports and how the
|
||||
host builds it. Open from the primer: (6) `testPlugin` with stub facets, (7) single-file plugins.
|
||||
- **Single-file plugins (owner, 2026-11-01: "agreed"; plugin model point 7):** a root may hold plain files named
|
||||
`<name>.<kind>.ts(x)` (`hello.server.ts`, `hello.web.tsx`): files with the same first name are one plugin named
|
||||
after it, one facet per file. The name must be a valid unscoped name; a collision with a directory plugin of the
|
||||
same root is reported like any duplicate. No `package.json`, so no npm dependencies; it imports only the app's API
|
||||
(host packages), no other files and no contract files for others; it may serve objects, define contracts for
|
||||
itself inline and contribute to registries. When it needs more, it becomes a directory with the same name, which a
|
||||
reload treats as the same plugin. The watcher's `set` mark covers files appearing and disappearing at a root's top.
|
||||
- **Testing a plugin alone (owner, 2026-11-01: "let's do that"; plugin model point 6):** `testPlugin(dir, { kind,
|
||||
modules, stubs, start?, principal? }, ctx)` builds the plugin as production does and loads it on an in-memory host
|
||||
of that kind with an in-memory store. A plugin depends on four things, each answered without a mocking framework:
|
||||
the app's API and Chord are the real modules (definitions only); other plugins' and core objects are reached
|
||||
through contracts, so the test passes stubs, ordinary facets serving those contracts with fake data; registries
|
||||
the plugin contributes to are served by the helper, which collects the contributions for the test to read (a
|
||||
registry it reads gets fake entries from a stub); process values come as fake start data. The result is a live host:
|
||||
the test gets the plugin's objects, calls them, watches their state, and acts as any principal. A contract no stub
|
||||
serves leaves its requests waiting `not_served`; the helper lists those names, so the test says what to stub.
|
||||
Several hosts at once (a server plugin calling a worker) is a later, larger helper over memory links; the app kit
|
||||
may ship stubs for its core contracts.
|
||||
- **The app kit, point 1: its shape (owner, 2026-11-01, by voice: "yeah"; app kit design pass):** the app's own
|
||||
layout is fixed: `src/` with the API versions (`src/api/v3/`, per host kind as decided for the plugin model) and the
|
||||
built-in plugins root (`plugins/`). The data directory is deployment configuration, outside the app: a `startApp`
|
||||
option set by the app's own start script (no environment variables: owner, "no environment variables"), default a
|
||||
dot directory named after the app in the home directory; it holds the store, the user's plugins root, logs and the
|
||||
workers' data. The server is fixed (one per app, clients connect to it). Workers are a list of worker kinds the app
|
||||
defines, each a host kind with the app's own params (sharding is the app's: a project, a tenant, a document; the kit
|
||||
knows no sessions) and how to start it (entry, start data per address, principal, idle time: the workers
|
||||
component's options); zero kinds is a server with clients only (a chat app).
|
||||
- **The app kit, point 2: principals and users (owner, 2026-11-01, by voice: "yeah I guess. just make it nice and
|
||||
concise and easy to use and flexible"; "too opinionated might be a problem"):** three things kept apart. (1) The
|
||||
principal is tiny (a kind and an id: it travels on every call and is part of objects' identity) and the app defines
|
||||
its type; the kit requires only the variants it needs itself (the server and its worker kinds). (2) User data
|
||||
(avatar, bio, availability) is ordinary app data in objects keyed by the user's id, never in the principal. (3)
|
||||
Authentication turns a connection into a principal. The kit ships optional plugins: a users plugin storing accounts
|
||||
with a profile schema the app supplies (a Slack-like profile or just a name), and authenticator plugins (device
|
||||
pairing, invite links, an ephemeral one for an IRC-like "pick a name and you're in": a fresh id with the name, no
|
||||
stored account, gone with the connection); an app picks, replaces or writes its own. Rejected: a fixed principal type
|
||||
with roles.
|
||||
- **The app kit, point 3: authentication (owner, 2026-11-01, by voice: "yeah, I guess something like that"):** (a)
|
||||
no owner by loopback alone (any local process or OS user could claim it): the start script prints a link with a
|
||||
one-time token (as Jupyter), whose visit sets a signed `HttpOnly` owner cookie for that browser; drive-by requests
|
||||
from pages in that browser stay blocked by the security baseline (foreign requests, also other localhost ports,
|
||||
never authenticated; Host and Origin checks). (b) Deny by default, in the HTTP component itself (a change to slice
|
||||
10's routes and sockets): a request without a principal is refused before plugin code unless its route or socket is
|
||||
marked public (a landing page, a webhook, the ephemeral name login, which hands out a principal). Authenticators stay
|
||||
the `Authenticators` registry, filled by the authenticator plugins the app chose; a session helper issues and revokes
|
||||
sessions so authenticators do not each reinvent cookies and logout.
|
||||
(c) Command-line clients (owner: "that sounds good"; the TUI client too) connect over the same WebSocket with a
|
||||
token in a header (no Origin, no Fetch Metadata: not a browser request; the token decides): on the same machine an
|
||||
owner token file in the data directory readable only by the OS user running the app; from another machine a device
|
||||
pairing flow (the tool shows a code and a link, the owner approves in a browser, the tool stores the token), the
|
||||
token a session like any other, listed and revocable.
|
||||
- **The app kit, point 4: storage (owner, 2026-11-01, by voice: "yeah, I suppose that sounds right"):** in the data
|
||||
directory the server has one store; each worker has its own directory under its worker kind, named by its address,
|
||||
with its store in it; the kit opens and closes them. Plugins never use paths of the data directory: their state is
|
||||
in their objects' documents and tables; a plugin that needs raw files gets a subdirectory of its own, owned by one of
|
||||
its objects.
|
||||
- **The app kit, point 5: plugins, files and export (owner, 2026-11-01: "okay", "yeah"):** two plugin roots: the
|
||||
app's built-in `plugins/` (required: a plugin failing at startup stops the app with its error) and the user's in the
|
||||
data directory (failures reported). Installs from git or npm into the user's root. One "apply changes" call builds the
|
||||
dirty plugins and rolls them out to the server, then every running worker; the app exposes it as a button, a
|
||||
command or an agent tool; changes are never applied by themselves. **Files:** the kit ships a files plugin, one owner
|
||||
per address (`self.get(Files, { scope })`: `put`, chunked transfers for large files (design §23), a watchable
|
||||
index, caps), its files in that worker's directory; apps build on it (p3's artifacts: versions, previews, sharing;
|
||||
design §26 #17). Raw directories only where a real path is needed (a git checkout, a library writing its own
|
||||
database), each owned by one object, under the worker's directory. **Export and import per address:** the store's
|
||||
archive plus the worker's directory as one archive; importing it under another address copies or moves it.
|
||||
Refines point 4 (storage): plugins use the files plugin instead of raw subdirectories of their own.
|
||||
- **Slice 11, P3: the base bundle and plugin scripts (owner, 2026-11-01, by voice: "yep, that sounds good"):** the
|
||||
server builds one base bundle per client kind from an entry in the app's source (the app's web start code, its API
|
||||
with the kind's module, Chord, React), content-hashed and cached by browsers forever; a change of base code reloads
|
||||
the page (as a restart of the server). Plugin scripts: at page load the server serves the current set of the kind's
|
||||
plugins as one combined file named by a hash of the set (one request, cacheable); while a page is open, a rollout's
|
||||
changed plugins are fetched one script each. A fresh page makes two requests, an update downloads only what changed.
|
||||
Rejected: a prebuilt base bundle shipped with the app (could disagree with the plugins' build).
|
||||
- **Slice 11, P4: new plugin scripts apply in place (owner, 2026-11-01: "yep, agree"):** a page's host reloads the
|
||||
changed plugins per plugin (`host.reload`, as on the server): objects swap or restart, the UI re-renders from its
|
||||
registries, keeping what is on screen (scroll, a half-typed message). A change of base code still reloads the whole
|
||||
page. Rejected: reloading the page for every plugin change (p2's behaviour, which lost the screen on every agent
|
||||
edit).
|
||||
- **Slice 11, P5: pages learn of new scripts through Chord (owner, 2026-11-01: "agree"):** the web component serves a
|
||||
small object whose state is the current set of the kind's plugin scripts (names, digests, URLs); every page holds
|
||||
it; a rollout publishes the new set without waiting for pages; each page gets one frame and fetches what changed;
|
||||
after a reconnect the hold's snapshot brings the current set, so a page catches up on anything it missed. Rejected:
|
||||
polling a manifest (p2: every 5 s while visible).
|
||||
- **Slice 11, P6: the connection loop (owner, 2026-11-01: "agree"):** p2's `keepConnected`: after a drop, reconnect
|
||||
with a growing pause (250 ms to 5 s); try at once when the tab becomes visible, the browser comes back online or the
|
||||
network changes; replace a socket open but silent; stop when the server reports an outdated protocol (the page
|
||||
reloads) or the app's `canRetry` says no (a guest's share ended). References keep their last values and wait while
|
||||
down, so the UI shows a banner from the connection state. A network reconnect, not a crash restart.
|
||||
- **Slice 11, P7: React bindings (owner, 2026-11-01: "we definitely want something like that"; p2's `useObject`,
|
||||
`useReadable`, `useRegistry` in `runtime/web.tsx`):** an optional subpath (`chord/next/react`): `HostProvider`,
|
||||
`useHold(Type, params)`, `useWatch(watchable)`, `useRegistry(R)`, `useRefState(ref)`; releasing holds on unmount,
|
||||
and a component unmounted by a reload never counts as leaving (design §21). No components, no styling; non-React apps
|
||||
never load it.
|
||||
- **Slice 11, P8: the terminal client always runs sandboxed (**superseded in part** by "The terminal's sandbox is a
|
||||
child process", 2026-11-02: no longer a worker thread) (owner, 2026-11-01: "the terminal should always use the
|
||||
sandbox approach"):** `chord/next/tui` is a Node client host that connects with a token (decision "The app kit, point
|
||||
3", (c)), downloads the base bundle and the TUI plugin scripts from the server and runs the app's TUI entry, always
|
||||
inside p2's sandbox: a permission-dropped worker thread with a bridge terminal, the trusted launcher outside it
|
||||
holding the credentials, the terminal and local preferences. No unsandboxed mode, also for the owner's own server.
|
||||
- **Slice 11, P9: a terminal component scope, library-agnostic (owner, 2026-11-01: "yeah, sure"; p2's `TuiScope`
|
||||
in `src/runtime/tui.ts`):** the counterpart of the React bindings: a scope tied to a component's life with `hold`,
|
||||
`watch` and `registry`, releasing everything on dispose, calling one `changed` callback the app wires to its redraw
|
||||
(one line with pi-tui; any library works). p2's retry with backoff for gone references is not carried over (v2
|
||||
references wait and rebind by themselves). No pi-tui dependency.
|
||||
- **`chord.plugins` is a package's manifest of plugins, as pi's (owner, 2026-11-01: "yes"; slice 12 review, Opus H1;
|
||||
refines "The plugin model ... imports and builds" (Imports) and `slice-12.md` §2, §5):** a package (npm, git, local)
|
||||
declares the plugins it provides in its `package.json`, `"chord": { "plugins": ["./notes", "./search"] }` (globs and
|
||||
exclusions as pi's `pi.extensions`); installing it picks up exactly those folders. Without the field discovery is
|
||||
conventional: the package is one plugin when its top has facet files, else its subfolders with a `package.json`.
|
||||
Depending on a plugin inside a multi-plugin repository is an ordinary dependency on the whole repository (npm version
|
||||
or `github:bob/plugins#<commit>`, installed by plain npm), imported by path into it (`@bob/plugins/notes/contract.ts`)
|
||||
or through its `exports`. Removed: the dependency form of `chord.plugins` (`"name": "github:...#ref:subfolder"`) and
|
||||
its own clone-and-link installer (it could delete outside `node_modules`).
|
||||
- **Looks and themes (owner, 2026-11-02: "i suppose", then "do all the things"; proposal `docs/next/theming/`):**
|
||||
shared components come from the app's per-kind API modules (one instance in the base bundle); looks are design
|
||||
tokens, CSS custom properties on `:root` on the web, which the app's components and plugins' CSS read; plugins scope
|
||||
their CSS with CSS modules (`*.module.css`); the terminal gets the app's components and a theme lookup by role. A
|
||||
theme is data in one registry, `Themes`, shipped by the kit in its shared module with the code that writes the active
|
||||
theme as CSS variables (web) and the lookup (TUI): a base set of roles the kit defines (accent, bg, fg, muted, error,
|
||||
warning, success), apps may add roles; a user picks one dark and one light theme, the system preference chooses;
|
||||
`web` extras may name fonts and assets of the plugin under the page's CSP. Plugins contribute themes; they cannot
|
||||
change the app's base CSS or components. The open questions' recommendations taken.
|
||||
- **p3 meeting, 2026-11-02 (Mario and Armin; Armin's summary, relayed by the owner):** session sync: the session
|
||||
repository talks to the server process to enumerate sessions; the session index in the server is updated by the
|
||||
worker from events and sent to the server by RPC. Sync to R2 goes through Radius: Radius keys each session by id and
|
||||
keeps its last sequence number, tells pi which sessions it has with their last sequence numbers (optimization: time
|
||||
of last sync), and pi sends the delta commit log from that sequence number; R2 stores the SQLite file and the commit
|
||||
log (SQLite DO to R2); sessions have an owner id. The index is a Chord table replicated like sessions; its entry is
|
||||
updated only on a session metadata change, a session idle-state change and a subagent idle-state change. p3: each
|
||||
host (server, worker, tui, web) has a kernel loading the plugins with contribution points via registries; p2's look
|
||||
and feel made great; new session storage; hands (machine daemons) good, secure and connecting proactively.
|
||||
- **App kit naming and export (owner, 2026-11-02: "startApp > startServer?", "am i exporting an address? i'm exporting
|
||||
the store a host created", "export should stop nothing before the export, it slots into the commit queue"; refines
|
||||
slice 13):** `startApp` is `startServer` (the pair `startServer`/`startWorker`; an app always has one server).
|
||||
`exportAddress`/`importAddress` are `exportData(address, path)`/`importData(path, address)`: the data a host created
|
||||
(its store and files directory). Export stops nothing: a running worker exports its own store (`store.export()` holds
|
||||
the commit queue while it reads, so the archive is one consistent point) and its finished files to the given path,
|
||||
asked through a kit object on the worker; with no worker running, the server takes the worker's process lock (no
|
||||
worker can start meanwhile) and reads the store itself. Import still takes the lock and refuses an address whose
|
||||
directory exists (nothing is overwritten; replacing a session is delete, then import; a copy is an import under a new
|
||||
address); registering the imported host in the app's index is the app's.
|
||||
- **p3 notes, 2026-11-02 (owner):** subagents may start with the main agent's context: `context: "inherit"` forks the
|
||||
parent conversation (Durable's `conversation.fork`) at the last entry before the assistant message with the spawning
|
||||
call; model, tools and location may differ; the tool's description names the cost (prompt cache only with the same
|
||||
model). pi's `/tree` (in-session branches) is wanted for p3: proposed as forks inside the session's store (edit an
|
||||
earlier message to fork, a branch list to switch; a full navigator and branch summaries later).
|
||||
- **Sync applies batches with the host stopped (owner, 2026-11-02: "sure"; sync example `60-session-sync`):**
|
||||
`applyBatch` refuses while a host holds claims on the store; the sync layer stops the session's worker, applies,
|
||||
restarts it. Usually nothing runs: Radius receiving uploads runs no host for a session it does not drive, and taking
|
||||
a session back pulls before its worker starts. A live view of a replica (watching a session continue elsewhere) is a
|
||||
later refinement: fewer pulls while watched, or a read-only view without the worker.
|
||||
- **The terminal's sandbox is a child process (owner, 2026-11-02: "yes"; refines "Slice 11, P8"; slice 11 recheck,
|
||||
Opus E1, E2):** the hardened worker thread was escaped twice in one review round, because downloaded code shares
|
||||
one JavaScript realm with Node's internals: E1 replaced `Set.prototype.has`, which the `require` allowlist used, and
|
||||
`require("tty")` wrote raw to the terminal; E2 patched `EventEmitter.prototype.emit` and caught Node's real
|
||||
`process` when Node emitted a warning on it (environment, `kill`, every builtin). Each fix closes one road and the
|
||||
next reachable internal is another. So the sandbox is a child process of the launcher's Node binary: stdin, stdout
|
||||
and stderr are pipes to the launcher (no descriptor is the terminal), its environment empty (only
|
||||
`LD_LIBRARY_PATH` copied when the launcher has one: needed to run the same binary, set by the user), its directory
|
||||
a private empty one, `--permission` with nothing granted (no file system, network, child processes, workers,
|
||||
addons, inspector, WASI), `--no-experimental-sqlite` (SQLite opens files past the permission model, Node gist
|
||||
https://gist.github.com/badlogic/a83cd80d0669dbcf77e029ac7b25ac96). Messages are length-prefixed binary frames on
|
||||
the pipes, not Node's IPC. The JavaScript layer stays as a second barrier (evaluation only through the bootstrap,
|
||||
the builtin allowlist, a frozen substitute `process`); the V8 code-generation toggle goes (the bridge evaluates
|
||||
strings anyway). Known gaps, accepted until Node closes them: the child can signal the user's processes
|
||||
(`process.kill`) and allocate memory outside its heap. Cost: one more Node process per terminal session (measured
|
||||
by the build against the worker). Back to a worker only if Node ships per-worker permissions.
|
||||
- **Messages and languages in the app kit (owner, 2026-11-03: "i18n proposal is a go"; proposal `docs/next/i18n/`):**
|
||||
text people read is translated per viewer, never per server. (1) **Messages in code, per plugin:**
|
||||
`defineMessages(namespace, { en: {...}, de: {...} })` in the app's shared API returns a typed `t`; the first catalog
|
||||
defines the keys and argument types, every other catalog must match them (a missing key or a wrong argument is a
|
||||
type error); values may be functions (plurals, parameters in each language's order); dates and numbers through
|
||||
`Intl` helpers (`formatTime`, `formatNumber`) in the viewer's locale. Named keys (`newNote`), the English catalog the
|
||||
source of truth. A plugin keeps its messages in one shared file (`messages.ts`) every facet that renders imports.
|
||||
(2) **Languages are contributions:** a `Translations` registry (`{ namespace, locale, messages, override? }`) in the
|
||||
shared API; `MessagesOf<typeof t>` types a translation; a missing key falls back to the locale's base (`pt-BR` to
|
||||
`pt`), then to the first catalog, key by key, reported once; two contributions for one namespace and locale: the first
|
||||
by plugin name wins, the other reported; a plugin's own catalogs are its built-in contributions, `override: true`
|
||||
replaces one. Contributions are per host, so a translation plugin keeps its catalogs in a shared file and contributes
|
||||
them from each facet that renders (web, tui; server and worker facets only when they render final text, an email or
|
||||
a push notification). Catalogs never cross links (they hold functions). (3) **The locale is a user preference**
|
||||
(kept per principal like the theme choice), defaulting to the browser's `navigator.languages` or the system locale
|
||||
in the terminal, matched against the locales the catalogs offer; switching re-renders live (`useMessages(t)` on the
|
||||
web, the scope's redraw in the terminal), nothing reloads; the kit sets `lang` and `dir` on the page's root
|
||||
(right-to-left through logical CSS properties, the app's components). (4) **Servers and workers send keys, not
|
||||
text:** `t.message(key, ...args)` builds typed data `{ namespace, key, params }` (a `Message<"notes">`) for object
|
||||
state, notices, tool summaries and errors shown to people (a code plus parameters); each client renders it with
|
||||
`t.render` in its own language (checked at runtime across a link: an unknown key shows the key). Never translated:
|
||||
the model's prompts and answers, log lines, error codes. No translator file format now; an export/import tool to JSON
|
||||
when someone needs it. Built after slice 11 (carry-over "After slice 11").
|
||||
- **Chord v2 becomes the package's root (owner, 2026-11-03: "src/old", "subpaths", "yes", "yes", "no, chord is
|
||||
wip"; for merging pi-private into pi this week; trial `/home/badlogic/chord-root-move/report.md`):** `src/next` is
|
||||
the package's `src` (its main export), the Node parts `src/next-node` are `src/node` with short subpaths (`/http`,
|
||||
`/workers`, `/plugins`, `/plugins/runtime`, `/app`, and slice 11's `/web`, `/web/server`, `/react`, `/tui`,
|
||||
`/tui/launcher`), the shared foundations Durable and env use get their own subpaths (`/json`, `/watch`, next to
|
||||
`/context`, `/delta`, `/sqlite`), `test/next` and `docs/next` move up (`test/`, `docs/`). Chord v1, which only p2
|
||||
still runs on, moves to `src/old` (exported as `/old`, `/old/node`; its tests `test/old`, its docs `docs/old`), not
|
||||
into p2 (its vitest tests and about 140 imports would need porting); it is deleted in the change that moves p2 to
|
||||
v2. No version bump: Chord is work in progress. Done by a re-runnable script (move, import and link rewrites,
|
||||
generated exports and aliases) on the base that includes slice 11; p2's imports updated in private.
|
||||
- **A plugin's npm dependencies are bundled; external only by opt-in (owner, 2026-10-29: "explicit opt in + (a)"):**
|
||||
a plugin's `dependencies` are installed into its own `node_modules` (`npm install --ignore-scripts`) and bundled into
|
||||
each facet that imports them (host packages, its `peerDependencies`, stay external and are passed in). On Node a
|
||||
plugin may keep named packages external with an explicit list in its `package.json` (`"chord": { "external": [...] }`):
|
||||
server and worker facets then load them from the plugin's `node_modules` (`createRequire`, as pi 1.0 loads extension
|
||||
dependencies), native addons included; they stay in Node's module cache until the process ends, so a new version
|
||||
takes effect after a restart. A browser or TUI facet importing an external package fails to build. Slice 7b
|
||||
(the loader).
|
||||
- **The dependency model is decided (owner, 2026-10-29: "just build this"):** `packages/chord/docs/next/dependencies/model.md`
|
||||
(explainer `book.md`, `index.html`) as decided, with its remaining four points taken as recommended: (1) the barrier
|
||||
is (a), `load` resolves at the first outcomes of this host's host-lifetime objects (local blockers still in their
|
||||
first attempt are waited for; waits on other hosts or failed dependencies end the first pass); (3) `waiting` is a new
|
||||
`FailureCause` kind; (4) a pure swap whose new factory needs a missing dependency parks the object, no overlap of old
|
||||
and new code; (5) a registry's last good value is kept only while every contribution it was built from is still in
|
||||
the table. Supersedes R18's dependency deadline and backoff, S2's `broken` and S3's backoff, §7's throw for a missing
|
||||
local type and getters' rejection, §21's `gone` on removal and its rollout `updating` deadline, the start barrier's
|
||||
admission gate (holds wait only for the publication). Built as slice 7a (`slice-7a.md`); design.md is folded with
|
||||
the build.
|
||||
- **Cycles across hosts: no probe, a diagnostic (owner, 2026-10-29: "yes"; dependency model, decision 6):** cycles stay
|
||||
found as today: on one host by the walk over waiting edges at `self.get` (exact), across hosts by the activation chain
|
||||
in holds (objects that start one after another). The remaining gap, a ring across hosts whose objects start at the
|
||||
same moment, is not detected: under the dependency model, which has no dependency deadline, such a ring stays parked.
|
||||
It is made visible: an attempt parked longer than its `readyTimeout` on a remote attempt that is itself only
|
||||
`loading` is reported once per parking episode as `HostError` `parked_long { object, waitingFor, since }`; nothing
|
||||
fails or retries. A cycle is a plugin bug (owner: "if no plugin bug, we don't need it"); the guide's rules for plugin
|
||||
authors and agents: (1) a factory declares only what the object needs, never a type that needs it back; (2)
|
||||
back-references and occasional lookups go into methods and routines (`ctx.get`); (3) data flows up through state and
|
||||
registries, not mutual `self.get`; (4) host-lifetime objects on the server never declare worker types in their
|
||||
factory. Rejected: the probe (edge-chasing searches with a check along the recorded trail, `/tmp/model/review*`,
|
||||
seven review rounds; complete only with a re-search timer or a full search lifecycle, the most intricate code in
|
||||
Chord for a plugin bug) and the slice 4b timeout narrowed (it polls chains across hosts again, misses a ring of
|
||||
waiting attempts, and retries in lockstep without finding the ring). Supersedes slice 4b review decision (2)'s
|
||||
timeout once the dependency model is built (slice 7); folded into design §6 with it.
|
||||
- **Getters wait through `waiting` (owner, 2026-10-29: "(b) with a default seems fine; UI stays responsive anyways
|
||||
because it usually holds instead of get"; dependency model, decision 2):** `host.get`, `ctx.get` and `host.registry`
|
||||
wait while the reference is `loading`, `failed { transient }` or `failed { waiting }`, bounded by their
|
||||
`readyTimeout` (default 60 s; callers choose their own), and reject at the deadline with the last cause; only
|
||||
`failed { broken }` rejects at once. Calls stay as they are: on a `loading` reference they park, bounded; on a
|
||||
`failed` one (any cause) they reject at once. A UI holds (`host.hold`) and shows `ref.state`; code that needs the
|
||||
object now gets it. Reviewers (`/tmp/model/stw-*.md`): Fable (b, calls included), Opus (a, plus a `whenLive`
|
||||
helper), Sol and Astra (c: callers park until their own context aborts, with caller deadlines and caps on pending
|
||||
calls; (b)'s default deadline is that cap). Sol's p2 note: cleanup in a `finally` after an abort needs its own
|
||||
bounded context, not the aborted one.
|
||||
- **Only hosts with an address serve remote types (owner, 2026-10-29: "b"):** a host serving a `remote` type needs an
|
||||
address and a principal; `load` refuses a facet that serves one on a host without an address (until now a host with
|
||||
a principal but no address served remote types and placed every one of them "here"). So "here" is decided by the
|
||||
address alone, as the host address of the type. Holds carry their `address` always (required on the wire).
|
||||
- **`hostAddress` instead of `placement` (owner, 2026-10-29: "yes, rename"):** the field of a `remote` contract that
|
||||
computes where its object lives is `hostAddress: (params) => Address` (it returns the address of the host that serves
|
||||
the object, the vocabulary of `createHost({ address })`, routes and holds); supersedes the name of "`placement`, not
|
||||
`host`" (2026-10-21). The error reason `invalid/placement` is `invalid/host_address`; the internal module that decides
|
||||
where a request goes (`host/placement.ts`, `Placement`) is `host/destination.ts`, `Destination`. Earlier entries' and
|
||||
slice specs' "placement" reads "host address"; they keep their wording as history.
|
||||
- **Every remote type declares its placement (owner, 2026-10-29: "yes, let's remove it"):** `placement` is required in
|
||||
`remote()` (a type error without it, and a declaration error at runtime); a remote type without placement, a leftover
|
||||
of slice 3a (remote objects over one link, before host kinds in 4a), is gone, and with it the fallback "here if served
|
||||
here, otherwise the parent" and its re-placement at the first parent link. Every remote request is placed by its
|
||||
address alone (dependency model, `packages/chord/docs/next/dependencies/`). Tests and examples get `placement: () =>
|
||||
Server()` or their own host kind.
|
||||
- **No client providers for now (owner, 2026-10-28: "yeah, no client providers for now"; reviews
|
||||
`/tmp/barrier-{astra,fable,opus,sol}.md`):** clients stay unaddressable and serve only `local` types (design §3).
|
||||
Client-side capabilities (a phone's camera, clipboard, a page's WebMCP tools) go through an owner's state, as the
|
||||
Questions service: a tool asks an owner store (`request`, `wait`), clients watching its open requests carry them out
|
||||
and answer with a method call (the answer is method arguments, checked against the owner's schema; bytes as `bytes()`
|
||||
leaves, chunked if large); a client may advertise the tools it can run to such a store, tied to its presence. Why:
|
||||
a client as a provider would feed untrusted results, state and ops into server and worker objects (consumers never
|
||||
validate them), needs client addresses, routing down through relays and a principal for clients; the owner pattern
|
||||
needs nothing new in Chord and survives link drops and several devices by the owner's rule.
|
||||
- **Design audit of slices 1-6a (owner, 2026-10-27; `/tmp/design-audit-{opus,sol}.md`):** the built code follows the
|
||||
design except for five bugs, fixed after 6b's commit with the auditors' reproductions as tests: on a failed
|
||||
activation references are released after the natives closed (§6, §13); a dependency going live while the dependent
|
||||
|
|
@ -2132,6 +2837,81 @@ validation and copies the v2 kernel added around Delta.
|
|||
descriptions on the wire (the `describe` message kind of R1 and the per-link description cache go away), R3 is moot,
|
||||
the coverage check across fingerprints of one version is gone.
|
||||
|
||||
## Simplification audit (2026-10-30)
|
||||
|
||||
The owner asked, after slice 7b's seven fix rounds, how many other mechanisms serve a rare case at a high cost. Audits
|
||||
by Opus and Sol (`/tmp/audit-{opus,sol}.md`) and this session; each item decided by the owner with its code and case.
|
||||
|
||||
- **A1, `installing` is cut (owner, 2026-10-30: "cut"):** model §1's state for a hold or request that does not fit the
|
||||
running code while a reload stages: staging is synchronous (`stagePlugins`, `recordFactory`), so no message or caller
|
||||
can arrive while it runs (the only test reached it from a factory calling back into the host). The `Serving` kind, the
|
||||
host's map, `Hold.start`'s branch and the test go. During the app's bundling before `host.reload` the old code
|
||||
answers a mismatch `failed { waiting }` until the commit wakes the request (model §5's accepted gap). Supersedes
|
||||
model §1's `installing` row and design §21's installing sentences.
|
||||
|
||||
- **A2, static registry cycle edges are cut (owner, 2026-10-30: "yeah cut"):** the edges a contribution of another
|
||||
registry's value recorded (`RegistryEdge`, `#reads` counts, `assertNoCycle`, `#reaches`, an attempt's pending edges,
|
||||
staging's edges) go; the runtime guard stays (a registry read while it composes throws `invalid/cycle`; the
|
||||
contribution that read it is left out and reported `contribution_failed`). A ring of registries is caught at its first
|
||||
composition instead of at `self.contribute`: the contributing object goes live, the rest of the registry works.
|
||||
Supersedes slice 8's "a cycle of registries reading each other refused when the second one serves or contributes".
|
||||
|
||||
- **A3, routine calls keep their ops (owner, 2026-10-30: "keep"):** `self.on` handlers keep `{ value, ops, retired }`
|
||||
with the routine's own folding (`PendingOps`, the basis after a throw).
|
||||
|
||||
- **A4, cross-host activation chains stay (owner, 2026-10-30: "yep keep"):** they catch the common, sequential form of a
|
||||
cross-host dependency ring (one request whose activation creates the other side) with an immediate `invalid/cycle`
|
||||
naming the path; `parked_long` stays the slow net for the rare simultaneous race.
|
||||
|
||||
- **A5, retry propagation by a hop budget (owner, 2026-10-30: "b if correct"):** `ref.retry()` still passes on from a
|
||||
parked object to its failed dependencies (model §3.7), but its dedupe is simpler: on one host one retry's walk keeps a
|
||||
visited set (a diamond retries a shared dependency once); across links the `retry` message carries a hop budget (an
|
||||
integer, 16 from the first host, one less per host, dropped at 0) instead of the operation id (uuidv7), the objects'
|
||||
last-8 remembered ids and the `through` list. Bounded effects, both harmless: a diamond across hosts may send a shared
|
||||
dependency two retries (the second meets it activating and is ignored, or starts one more attempt if the first
|
||||
already failed); a waiting ring across hosts (the undetected simultaneous cycle) passes the retry round until the budget
|
||||
runs out, starting no attempt (parked objects only pass it on). Instance ids stay (activation chains, A4).
|
||||
|
||||
- **A6, staging factories against a recording `self` stay (owner, 2026-10-30: "keep"):** a throwing new factory keeps
|
||||
rejecting the whole reload (the host stays on its old code), and each object's fate is decided from its recorded
|
||||
declarations before the commit.
|
||||
|
||||
- **A7, registry last-good with provenance stays (owner, 2026-10-30: "a").**
|
||||
|
||||
- **A8, deferred job-state persistence keeps its held ops (owner, 2026-10-30: "keep").**
|
||||
|
||||
- **A9, a pure swap sends holds a fresh `bound` (owner, 2026-10-30: "simplify"):** a hold over a link whose object
|
||||
swaps in place gets a fresh `bound` (one snapshot of its members), the path a restart, a variant change and a version
|
||||
change already take, instead of a re-subscription with `[["r", value]]` per changed member (`Hold.#rebound`, its
|
||||
constant-to-source and failed-member cases, `currentOf`). A tab's watchers may get one frame for an unchanged member;
|
||||
nothing is lost or stale. Supersedes design §21/§22's per-member `r` rule for holds after a swap.
|
||||
|
||||
- **A10, one rule after a live failure (owner, 2026-10-30: "simplify"):** every member of a failed live instance keeps
|
||||
being followed (references and holds) until the commits it admitted published (`documents.settled()`), then all stop:
|
||||
the restarts' rule. The "own" against "foreign" split (`#follows === "own"`, `Attempt.owns` and its mapping-derive
|
||||
case, `Hold.#stopForeign`) goes; a re-exposed member of another object may show one more change in that window.
|
||||
Refines 6a's build-review rule "members following another object's sources freeze at once".
|
||||
|
||||
- **A11, `parked_long` widened (owner, 2026-10-30: "widen"):** reported once per parking episode when an attempt has been
|
||||
parked longer than its `readyTimeout`, whatever its blocker (the first in declaration order named, with its state);
|
||||
the remote-loading condition, `heldOnLink` and the clock restart at a link go. Also covers a cross-host ring one of
|
||||
whose members waits on a failed dependency. Supersedes decision 6's narrow condition.
|
||||
- **A12, the routine fence token folds into the seal (owner, 2026-10-30: "fold"):** one seal token per attempt; after a
|
||||
swap the old routine calls get `closeTimeout` to finish, then the whole old attempt is sealed (its method calls had
|
||||
the same bound). `RoutineContext.fence` and the token choice per transaction go. Supersedes slice 7b decision 4's
|
||||
separate token.
|
||||
- **A13, the table version fence stays (owner, 2026-10-30: "keep"):** an old call stuck past a restart's drain never
|
||||
issues SQL against a migrated table (which would stop the store and close the host).
|
||||
- **A14, key pins keep their hand-over between instances (owner, 2026-10-30: "keep").**
|
||||
|
||||
- **A15, keyed members across objects stay, with their versioning (owner, 2026-10-30: "keep essential"):** they fill
|
||||
the gap P3 creates (a view with params following one entry of a persisted store live); the alternatives (a singleton
|
||||
map, a method) are weaker.
|
||||
|
||||
Summary: cut A1 (`installing`), A2 (static registry cycle edges); simplified A5 (retry hop budget), A9 (a fresh `bound`
|
||||
after a swap), A10 (one rule after a live failure), A11 (`parked_long` widened), A12 (one seal token); kept A3, A4, A6,
|
||||
A7, A8, A13, A14, A15.
|
||||
|
||||
## Portability boundaries in the kernel
|
||||
|
||||
From `research/chord-on-durable-objects.md`, adopted for the Chord v2 kernel because they cost little and help tests:
|
||||
|
|
@ -2183,7 +2963,7 @@ From `research/chord-on-durable-objects.md`, adopted for the Chord v2 kernel bec
|
|||
|
||||
- **Per host:** load (bundle; a load error rejects) → staging (run the facet setup and every live object's new factory
|
||||
with a recording `self`, declarations only, nothing activated; a throw rejects) → commit (atomic) → after.
|
||||
- **Rollback is all-or-nothing per host:** any load or staging error leaves the host entirely on its previous code.
|
||||
- ~~**Rollback is all-or-nothing per host:**~~ (**superseded** by "Per plugin at startup and reload", 2026-10-30) any load or staging error leaves the host entirely on its previous code.
|
||||
A host is completely at revision R or R−1, so status per host suffices.
|
||||
- **Pure objects** (**superseded in part** by R15 (old code touching a document migrated past its version throws)) (no `self.open`, old or new) are swapped in place: new methods and derives; documents, references
|
||||
and jobs stay; routines restart serialized; new references become live; then the object's gate opens. Running calls
|
||||
|
|
@ -2231,7 +3011,7 @@ From `research/chord-on-durable-objects.md`, adopted for the Chord v2 kernel bec
|
|||
beyond a use (a pinned Durable phase holding an old table is fine). Natives reach other plugins only this way: no
|
||||
`opaque` members, no natives in method results. What a native's old value does after its owner restarts is the
|
||||
owner's `close` (a model runtime keeps working until collected; a closed SSH connection fails its in-flight `exec`).
|
||||
- **HTTP is an optional Chord component** (`@earendil-works/chord/next/http`): a route gets a plain request summary
|
||||
- **HTTP is an optional Chord component** (**superseded in part** by "Slice 10's points": `html` instead of `page`, request bodies through `json`/`body`, authentication one app hook, plugin sockets instead of a channel component until a second user) (`@earendil-works/chord/next/http`): a route gets a plain request summary
|
||||
(method, path params, query, principal, selected headers; parsed and capped JSON bodies; uploads handed over in
|
||||
chunks) and returns a response description (`json`, `page`, `bytes` with `size`, `mime` and `read(offset, length)`,
|
||||
`redirect`, `error`). The component does ranges (206/416), HEAD, download headers, streaming, abort on disconnect and
|
||||
|
|
@ -2326,6 +3106,16 @@ Everything else from earlier lists is decided (see the entries above and `packag
|
|||
- **Future goal: deploy Chord apps to Cloudflare Durable Objects and similar platforms.** Hot reload there is not
|
||||
needed; composition through plugins is (plugin set fixed at deploy). Research in `research/chord-on-durable-objects.md`.
|
||||
- Deferred until a concrete case: jobs as objects and where a flow lives (when login is ported); project plugins.
|
||||
- **Chord from code mode scripts** (owner, 2026-10-30: "something we want"; p3): the agent's code mode scripts get a
|
||||
Chord call scope (`host.root`) to call objects and change things: `chord.get("notes.store", {})` by name, every
|
||||
argument checked by the provider's closed schema. Needs, to design with p3: a principal for the agent (acting as who
|
||||
runs the session, so views apply their authority; never the owner's powers in a guest's session), a by-name `get`
|
||||
and a list of served contracts with their members and schemas (the agent's declarations), values crossing the
|
||||
sandbox boundary as plain data, the scope sealed when the script ends.
|
||||
- **Every object may contribute** (owner, 2026-10-29: "note it down as something to consider for the future; can be
|
||||
changed later without a need for migrations"): lift R21 so objects with params and `remote` objects contribute too;
|
||||
the entry lives while the object is live, its identity is the object's (type, params, principal key); `contribute`
|
||||
on every `self`. Not decided; R21 stands.
|
||||
- p3 domain points (decided per domain when p3 is designed): `SessionFiles {session}` (proposed), session delete,
|
||||
connection pools, config files with a single writer, file formats as versioned interfaces, the terminal host
|
||||
process as a revision artifact.
|
||||
|
|
|
|||
1671
packages/chord/docs/next/dependencies/book.md
Normal file
1671
packages/chord/docs/next/dependencies/book.md
Normal file
File diff suppressed because it is too large
Load diff
2641
packages/chord/docs/next/dependencies/index.html
Normal file
2641
packages/chord/docs/next/dependencies/index.html
Normal file
File diff suppressed because it is too large
Load diff
330
packages/chord/docs/next/dependencies/model.md
Normal file
330
packages/chord/docs/next/dependencies/model.md
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
# Dependency resolution in Chord v2: one model (decided, 2026-10-29)
|
||||
|
||||
Status: decided (owner, 2026-10-29); **built for slice 7a** (audit A1, 2026-10-30: `installing` is cut, staging being synchronous; A5: retry dedupe by a visited set and a hop budget; A11: `parked_long` for any long park) (§1-4 and §6-11 without what installs and reloads add:
|
||||
§1's `installing`, §4's install-end and reload wakes, §3.9's reload handover and §5's removal, versions and rollout
|
||||
are slice 7b's; folded into `design.md` §3, §5, §6, §7, §17, §21's start barrier, §22; `slice-7a.md`). The draft
|
||||
history below is kept as it was. Sources: the owner's direction
|
||||
("park until resolved"; "no difference between local and remote resolution: a link going is like a plugin
|
||||
unloading"), the reviews (`/tmp/dep-mechanism-*.md`, `/tmp/dep-graph-*.md`, `/tmp/barrier-*.md`,
|
||||
`/tmp/model/review{1,2}-*.md`), the code at pi `358f7d46a`. **Owner** marks an open decision.
|
||||
|
||||
Draft 7 (round 6): (A) also retries in lockstep without finding the cycle (Fable); the probe's recovery after a
|
||||
suppressed search becomes a choice between (B1) a re-search backoff for probes only (recommended) and (B2) a full
|
||||
search lifecycle without timers, since `dead` notices alone need recovery state that eviction may not drop (Astra, Sol).
|
||||
Causes are compared by value on wakes (Opus).
|
||||
Round 7: a returned search is accepted while its initiator is parked, whatever its round (Astra: re-searches must not
|
||||
invalidate searches under way); detection is eventual, not within one backoff period; the local walk stays (Opus).
|
||||
|
||||
Draft 6 (round 5): cycles across hosts become an owner choice between (A) the owner's slice 4b decision (simultaneous
|
||||
starts across hosts time out, no probe message), narrowed to one deadline, and (B) the probe, completed with edge
|
||||
tokens, no revisits, and `dead` notices back to the initiator (Astra: a check that dies silently can lose the only
|
||||
search that survived dedupe). (A) turned out to either poll chains across hosts again or miss a ring of `waiting`
|
||||
attempts, so (B) is recommended, pending its review.
|
||||
|
||||
Draft 5 (round 4, Sol's phantom cycle): the second round is replaced by a check along the recorded trail of
|
||||
`(attempt, edge generation)` hops; a reported ring existed at one moment; probes grow with their path.
|
||||
|
||||
Draft 4 after round 4 (probe only): round numbers per attempt, forwarded only when newer than the stored one; probes
|
||||
honoured on parent and route links, dropped on child links; completeness stated under an eventually stable graph; the
|
||||
repair caveat broadened to any concurrent edge removal; a new edge generation resets confirmation; a bounded display
|
||||
trail.
|
||||
|
||||
Draft 4 changes (round 3): the probe starts a fresh round on every new or changed wait edge (a ring closes at its last
|
||||
edge, whose source finds it), keeps one seen entry per initiator, accepts only its current round, and states its
|
||||
cost honestly; every host drops probes from untrusted links; local forwarding is iterative; no-placement remote
|
||||
requests are re-placed when a host's first parent link connects; a cohort object that ends before its first outcome
|
||||
settles the barrier; the last-good refinement is marked for the owner.
|
||||
|
||||
Draft 3 changes (round 2): remote types without placement keep today's fallback (here if served, else the parent);
|
||||
hydration stays before parking; no copied `retryAt`; a dependency `ended` breaks its dependent; a reload's handover
|
||||
binds new requests before releasing old ones; retry passes only to `broken` and `transient` blockers, once per
|
||||
operation; `installing` re-evaluated at every install end (commit or rejection); a registry's last good value dropped
|
||||
when one of its contributors leaves; the probe with round ids, bounded seen sets, edge-generation triggers, attempt
|
||||
ids on every non-live answer and relays dropping probes from untrusted links; one cycle mechanism, local and remote.
|
||||
|
||||
## 0. The problem
|
||||
|
||||
The same situation, "what I need is not there", has five outcomes today, depending only on how it is declared:
|
||||
|
||||
| Declared as | Provider missing | Today |
|
||||
| --- | --- | --- |
|
||||
| `self.get(LocalType)` | no facet serves it | `self.get` throws, the factory throws: `broken` |
|
||||
| `self.get(RemoteType)` | the worker does not serve it | the hold is refused `broken`; the dependent `transient`, polled 1-60 s forever |
|
||||
| `self.registry(R)` | no provider | `transient`, woken at R's first value only |
|
||||
| `self.registry(R)`, reader newer | | `broken` |
|
||||
| a reload removes the type | | holders get `gone`: ended for good |
|
||||
|
||||
And the startup barrier deadlocks an acyclic graph across two hosts (Sol, `/tmp/dep-graph-sol-barrier.log`): S's eager
|
||||
A needs W's B, B needs S's C; W's hold for C waits for S's whole `load`, which waits for A. Only `readyTimeout` ends it.
|
||||
|
||||
## 1. Requests and one resolver
|
||||
|
||||
- **A request** is what a reference stands for: type (or registry), params, principal, the consumer's contract version.
|
||||
Its holder makes it and owns it: an attempt (`self.get`, `self.registry`, keyed pins), a call (`ctx.get`), outside
|
||||
code (`host.get`, `host.hold`, `host.registry`), a link's peer (`hold`). **It ends only when its holder ends it** (the
|
||||
attempt ends, the call settles, `release()`, a getter gives up, the peer's link or origin closes, revocation, the
|
||||
holding host closes), never because its provider is missing, went away or does not fit.
|
||||
- **The destination by address** (round 1): a `local` type goes here; a `remote` type goes to the address its
|
||||
`hostAddress` (computed from the consumer's own contract) gives: here if that is this host's address, whether served
|
||||
here or not, else over the route for that kind or the parent (design §3). Every `remote` type declares
|
||||
its `hostAddress` (owner, 2026-10-29: the optional `placement` of slice 3a is removed and the field renamed, and with it the fallback "here if
|
||||
served, otherwise the parent" and its re-placement at the first parent link). A hold arriving for an address that is not this host's is relayed or
|
||||
refused (`invalid/host_address`) as today; a relay never captures a hold because it serves the same name.
|
||||
- **Then the selected provider is checked** (served, version or accepted version and fingerprint, `authorize`, the
|
||||
provider's own host-address check). Version checks never look at a same-name registration elsewhere.
|
||||
- **The publication gate:** a request made on this host, or arriving on its links, before the host's first
|
||||
publication (§6) is not placed yet; it shows `loading` and is placed by the publication. The gate lasts only for the
|
||||
synchronous setups, never for activation, so it cannot deadlock.
|
||||
- **Outcomes of the check:**
|
||||
|
||||
| The check finds | Request |
|
||||
| --- | --- |
|
||||
| a fitting provider | bound (to the object here, or to the hold over a path) |
|
||||
| not published yet | unbound `unpublished`: `loading` |
|
||||
| nobody serves the name at its address | unbound `not_served`: `failed { waiting }` |
|
||||
| no fitting version, a fingerprint that differs at the same version, a host address the provider computes elsewhere (code skew) | unbound `version`: `failed { waiting }` |
|
||||
| the registry is served, but its first `compose` (or the reader's version mapping) threw | unbound `compose`: `failed { waiting }` |
|
||||
| the provider host is installing a revision and the hold does not fit its current code | unbound `installing` at the provider: `loading` |
|
||||
| the path has no link (a drop, a route resolving) | on the path: `loading` |
|
||||
| `authorize` refused, invalid params | `failed { broken, forbidden \| invalid }`, as today, never indexed |
|
||||
|
||||
The unbound reasons that wait for a name (`unpublished`, `not_served`, `version`, `compose`) are indexed under it on
|
||||
the host that decided them: the consumer's host for a local request; the provider's host for a hold that arrived
|
||||
over a link (it already keeps such holds as refused: `remote/hold.ts`). `installing` is the provider host's own,
|
||||
re-evaluated at its install's end; `no_link` lives on the path, where the next link holds it again. Indexes drop an
|
||||
entry when its request ends; GC-tested.
|
||||
|
||||
## 2. States
|
||||
|
||||
`ref.state` keeps its four kinds; `FailureCause` gains one kind, `waiting`:
|
||||
|
||||
```ts
|
||||
type RefState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "live" }
|
||||
| { kind: "failed"; cause: FailureCause; retryAt: number | undefined }
|
||||
| { kind: "ended"; error: ChordError };
|
||||
|
||||
type FailureCause =
|
||||
| { kind: "transient"; error: ChordError } // the object's own effect failed (an open, a follow): backoff
|
||||
| { kind: "broken"; error: ChordError } // the object's own code, a cycle, or authority: retry() or a reload
|
||||
| { kind: "waiting"; error: ChordError }; // not the object's fault: no provider, no fitting version, a
|
||||
// dependency failed; retryAt undefined, woken by an event
|
||||
```
|
||||
|
||||
| The request is | `ref.state` | Calls |
|
||||
| --- | --- | --- |
|
||||
| bound, its object activating or parked with no failed blocker | `loading` | park, bounded |
|
||||
| unbound `unpublished` or `installing`, or its path has no link | `loading` | park, bounded |
|
||||
| unbound `not_served`, `version` or `compose` | `failed { waiting }` | reject at once |
|
||||
| bound, its object failed | `failed` with the object's cause, as today | reject at once |
|
||||
| bound, its object live | `live` | run |
|
||||
| ended by its holder, revoked, or its host closed | `ended` | reject |
|
||||
|
||||
**Owner:** `waiting` as a new cause kind (Opus, Fable; recommended: the cause says "not your fault, an event will wake
|
||||
you" and a UI can say what is missing) or no new kind: `transient` with no `retryAt` (Astra; today's wire already
|
||||
carries a `transient` cause without `retryIn`, `remote-object.ts`), or `loading` with a diagnostic (Sol; parks every
|
||||
call on a missing plugin for its full `readyTimeout`).
|
||||
|
||||
## 3. Dependents: parked until their dependencies resolve
|
||||
|
||||
A **dependency** is what an attempt declares and needs before it goes live: `self.get` (local or remote),
|
||||
`self.registry`, keyed pins (`at(key)` on another object's keyed member). `self.follow` is the attempt's own step (its
|
||||
attach runs Durable's code), not a dependency; a follower declares `retryOn` on the registry it reads the harness
|
||||
through, or recovers by its own backoff.
|
||||
|
||||
1. **Order, as today up to the check:** the factory runs; persisted singletons hydrate (the object's documents, kept
|
||||
across attempts); the requests are made. The attempt is **parked** while any dependency is not `live` (hydrated):
|
||||
|
||||
| Its dependencies | The dependent shows |
|
||||
| --- | --- |
|
||||
| any `loading`, none failed | `loading` |
|
||||
| any `failed` | `failed { waiting, "waiting for X: <X's cause>" }`, `retryAt` undefined; the message keeps the nearest blocker and the root cause within the 500-character rule |
|
||||
| one failed with `invalid/cycle` (a dependent off the ring) | `broken` with the same error: it would wait on a ring that never heals by itself |
|
||||
| one failed with `forbidden` (an `authorize` refused it: authority, §3.6) | `broken` with the same error: only `retry()` or a reload helps (slice 7a fix round 1) |
|
||||
| one `ended` (revoked, or its host closed) | `broken` with that error: the request cannot come back; `retry()` or a reload makes new requests (a guard: a dependency ends only with its holder's principal or host, so the dependent ends too; Fable) |
|
||||
|
||||
2. **The dependency check is the activation boundary:** once every dependency is `live`, the attempt runs its own
|
||||
steps (natives in order, follow attaches, member checks, derives), bounded by its own `readyTimeout` deadline, and
|
||||
goes live. A dependency that leaves `live` after the check is treated as after live (R18): the attempt goes on. This is
|
||||
R18's rule for live objects applied from the check on, not a rule of its own (owner, 2026-10-29: a dependency can
|
||||
go away at any point of an object's life; the object stays live, its calls through that reference meet its state). So a parked attempt holds no
|
||||
natives, and an attempt that opened natives is never parked.
|
||||
3. **No timer while parked:** no dependency deadline, no backoff. The attempt's deadline is paused while parked (as
|
||||
eager migrations pause it) and resumes with the budget left.
|
||||
4. **Wakes, one rule:** the dependent re-evaluates on every state change of any of its dependencies (live, loading,
|
||||
failed, a new cause compared by value, ended; Opus: otherwise a ring of waiting attempts passes its causes round and
|
||||
round), updating what it shows; it leaves parking when all are live. The factory never re-runs
|
||||
while parked. Wakes are coalesced per object and run after the batch that caused them; a change during the
|
||||
attempt's own steps or its teardown is remembered. (As built in 7a, with the parent's note 5: a change while the
|
||||
parked attempt checks or loads its pinned keys is remembered and checked again; after the dependency check, during
|
||||
the own steps, a change is ignored, §3.2's R18 rule.)
|
||||
5. **Kept while parked:** its documents (the object's), its declarations and requests, its seal token; no natives.
|
||||
Ended with the object (its last holder; a host-lifetime object when its plugin is removed or the host closes).
|
||||
6. **`broken` only for the object's own code** (a throwing factory, a derive throwing at activation, hydration,
|
||||
`migrate`, a document fingerprint mismatch, an `open` throwing `broken(...)`), **cycles** (§7) and **authority**
|
||||
(`forbidden`, an ended dependency).
|
||||
7. **Retry passes on:** `ref.retry()` on a `failed { waiting }` object retries its `broken` and `transient` blockers,
|
||||
hop by hop (the existing `retry` message over links), once per retry operation (an id dedupes diamonds); `waiting`
|
||||
blockers pass it on; nothing else is touched. Retry of a `broken` or `transient` object starts a new attempt, as
|
||||
today.
|
||||
8. **`retryOn`** wakes failed objects as decided in slice 8 (a value call retries a `transient` or `broken` object);
|
||||
while the object is parked `waiting`, a value call does nothing: its dependencies decide.
|
||||
9. **Own-code reload of a parked object:** its plugin's reload replaces the parked attempt with the new factory's,
|
||||
as one handover: the new requests are made and bound first, then the old ones released (round 2, Astra N4: an
|
||||
unchanged dependency held only by this object must not end in between; today's `#activate` binds before
|
||||
`#releasePrevious`).
|
||||
10. **After live, R18 unchanged:** a dependency leaving `live` leaves its dependent live.
|
||||
|
||||
## 4. Wakes: two events
|
||||
|
||||
| Event | Re-evaluates |
|
||||
| --- | --- |
|
||||
| **a target changes state**: a dependency's object goes live, loading, fails, gets a new cause or ends; a hold's `bound`, `loading`, `failed` arrive, also after a re-hold on a new link | the requests bound to it, and their dependents (§3.4) |
|
||||
| **a name becomes usable, or an install ends, on a host**: a publication (`load`'s setups, a reload's commit, an install's commit) serves a type, a registry, a version; a registry composes or maps a value at a version; an install ends without a commit (rejected) | the unbound requests indexed under those names; the provider's `installing` holds; a refused hold re-runs its start and sends `loading`, then `bound` or `failed` |
|
||||
|
||||
No new message kind for wakes: `loading`, `bound`, `failed` and `retry` exist. The wire's `failed` carries the new cause
|
||||
kind `waiting`.
|
||||
|
||||
## 5. Removal, versions, rollout
|
||||
|
||||
- **A reload that removes a type:** its objects end as today (natives close, reason `end`; callers rejected at once;
|
||||
handlers drain). Their requests are not ended: each becomes unbound `not_served` and binds again if a later commit
|
||||
serves the name.
|
||||
- **Locally:** a `Binding` whose target ended by removal becomes unbound and accepts a second `bind`: member sources
|
||||
re-bind by name (one frame from the last value, as on a restart), keyed pins re-pin, parked calls flush or
|
||||
reject, presence re-lists. The one new piece of machinery.
|
||||
- **Over a link:** a `failed` on a bound hold unbinds it (Fable: one message, one rule; the consumer applies it as
|
||||
`#startOver` plus `failed`); the provider detaches the hold's sources and drops its unsent pending ops and queued
|
||||
`bound`; the ack slots of `bound`/`state` messages already sent stay counted until their `ack` or the link's loss;
|
||||
calls in flight on it are rejected and free their slots once.
|
||||
- **A reload that stops accepting a version** unbinds only the requests on that version (`version`); the object stays
|
||||
(its own plugin's reload still restarts it if it has natives, §21).
|
||||
- **A type moved to another host kind:** the request carries the address its own consumer code computed; the old host
|
||||
answers `not_served`; the consumer's own reload makes a new request. No redirect.
|
||||
- **A consumer whose code moves to a new version** releases its old request and makes a new one (today).
|
||||
- **Rollout, without revision fields:** each host knows only its own install. A provider host that installs keeps
|
||||
holds that do not fit its current code `installing` (`loading`) and re-evaluates them when the install ends, by its
|
||||
commit or by its rejection. A provider host that is queued but not installing yet answers a mismatch `failed
|
||||
{ waiting, version }` until its install commits and wakes it: a visible blip of at most one install, accepted (Opus).
|
||||
An install never waits for activations (a reverse-order rollout would deadlock); a host whose own install was
|
||||
rejected fails its own requests at once with the reason (design §21). The loader keeps its own duties (the desired
|
||||
code set, digests, serialized installs).
|
||||
- **Old calls across a reload** follow §21 unchanged: a pure swap lets them finish on old handles; a native restart
|
||||
drains them first; a removal rejects their callers at once and drains the handlers. Calls are never resent.
|
||||
|
||||
## 6. Startup: publication, then first outcomes
|
||||
|
||||
1. **Publication:** `load` runs every facet's setup, then publishes all at once in one batch (types served,
|
||||
registries served and composed once, contributions in their tables), then wakes (§4) after that batch. Nothing
|
||||
activates inside it.
|
||||
2. **No gate beyond it:** holds and `host.hold` wait only for the publication, never for the barrier. This ends the
|
||||
startup deadlock: W's hold for C binds as soon as S's setups published; B goes live, then A.
|
||||
3. **The barrier** (**Owner:** (a) or (b); recommended (a), three reviewers of four):
|
||||
- **(a):** `load` resolves when every host-lifetime object of this `load` has a first outcome:
|
||||
`firstOutcome(o) = o.live || o.ownFailure || (o.parked && o.blockers.every((b) => !b.local || firstOutcome(b)))`,
|
||||
where `local` means executing on this host (an object here, a keyed pin still loading here), not the contract's
|
||||
kind. So `CodeMode → Harness` waits for the harness's outcome; a wait on another host or on a failed dependency
|
||||
ends the first pass. One `firstOutcome` signal per object, separate from the readiness dependents wait for (Sol),
|
||||
counted once, never reopened by retries. An object of the cohort that ends before its first outcome (its plugin
|
||||
removed, the host closing) settles its entry as ended; `host.close` settles the waiting `load` (Astra R3).
|
||||
- **What (a) promises:** every host-lifetime object on this host had its first try, and the contributions of those
|
||||
that went live are in their tables. **Not:** every tool that will arrive (a parked or slow contributor, tools found
|
||||
in `onLive`, MCP; Sol's probe `/tmp/barrier-sol-first-outcome.log`). What a first turn needs is p3's explicit
|
||||
admission check.
|
||||
- **(b):** `load` resolves after the publication; Chord adds `host.startup`, a watchable of the host-lifetime
|
||||
objects' first outcomes with their causes; the app decides (Fable). The same tracker, awaited by the app.
|
||||
- p2 today resumes its harness without waiting for all host objects (`apps/p2/src/hosts/worker.ts`), because its
|
||||
worker objects that need server types are reachable only after `control.ready()`.
|
||||
|
||||
## 7. Cycles (decided 2026-10-29: no probe)
|
||||
|
||||
On one host, cycles are found as today: at `self.get`, following the waiting edges of activating and parked attempts
|
||||
(exact). Across hosts, a hold carries the activation chain it was made in, which finds every ring whose objects start
|
||||
one after another. The remaining gap, a ring across hosts whose objects start at the same moment, is not detected:
|
||||
without a dependency deadline it stays parked. It is made visible: an attempt parked longer than its `readyTimeout` on
|
||||
a remote attempt that is itself only `loading` is reported once per parking episode as `HostError` `parked_long
|
||||
{ object, waitingFor, since }`; nothing fails or retries. A cycle is a plugin bug; the guide gives plugin authors and
|
||||
agents four rules: (1) a factory declares only what the object needs, never a type that needs it back; (2)
|
||||
back-references and occasional lookups go into methods and routines (`ctx.get`); (3) data flows up through state and
|
||||
registries, not mutual `self.get`; (4) host-lifetime objects on the server never declare worker types in their factory.
|
||||
|
||||
Rejected (owner, 2026-10-29): the probe (edge-chasing searches with a check along the recorded trail; seven review
|
||||
rounds, `/tmp/model/review*-*.md`; complete only with a re-search timer or a full search lifecycle, the most intricate
|
||||
code in Chord, for a plugin bug) and the slice 4b timeout narrowed (it polls chains across hosts again, misses a ring of
|
||||
`waiting` attempts, and retries in lockstep without finding the ring).
|
||||
|
||||
## 8. Getters, holds, calls: one rule
|
||||
|
||||
- **`host.hold`** returns a reference at once; only invalid params throw (also before `load`). A missing local type
|
||||
gives `failed { waiting }` after the publication, `loading` before it.
|
||||
- **`host.get`, `ctx.get` and `host.registry`** (owner, 2026-10-29, decision 2): resolve when live; wait while `loading`,
|
||||
`failed { transient }` or `failed { waiting }`, up to their `readyTimeout` (default 60 s, the caller's choice), then
|
||||
reject with the last cause; reject at once only on `failed { broken }`. On
|
||||
success, rejection or timeout their temporary wait goes; `host.registry` returns the `RegistryValue` with its
|
||||
ordinary watch ownership (no `release()`). A UI holds and shows `ref.state`; code that needs the object now gets it, with the
|
||||
patience it passes.
|
||||
- **Calls** on a `loading` reference park, each bounded by the reference's `readyTimeout` from when it was made; on a
|
||||
`failed` one they reject at once with the cause; sent calls are never resent.
|
||||
|
||||
## 9. Registries
|
||||
|
||||
- The same resolver on one host: a reader's request for R at its version is bound while R is served and has a usable
|
||||
value at that version (composed; mapped by `accepts[old].value` for an older reader), otherwise unbound
|
||||
`not_served`, `version` or `compose`, or `loading` before the publication. Woken at every transition to usable
|
||||
(`#valued`'s first-value latch goes). S2's `broken` and S3's backoff go.
|
||||
- **Version skew within one host:** a reload of the registry's plugin alone leaves other plugins' contributors and
|
||||
readers on their bundled versions; `accepts[old].contribution` and `.value` keep them working, unsupported
|
||||
contributions are left out and reported, unsupported readers are `waiting` (`version`).
|
||||
- A registry waits for a value, never for a particular contribution (an empty table composes). After live, a later
|
||||
`compose` failure changes nothing for the reader: `.value` keeps the last good composition, **but only while every
|
||||
contribution it was built from is still in the table** (round 2, Astra N2): when one of them leaves (its
|
||||
contributor ended or was reloaded), the last good value is dropped and `.value` throws until `compose` succeeds, so
|
||||
it never hands out a native whose contributor ended. `compose` represents a missing contributor as a value.
|
||||
- Registries stay host-local (contributions are in-process values).
|
||||
|
||||
## 10. Keep-alive, presence, revocation, jobs
|
||||
|
||||
- A request over a link counts once in `host.activity.references` from its arrival to its end, bound or unbound;
|
||||
presence (`host.references`) lists only bound references.
|
||||
- Revocation ends the requests of a revoked principal, bound and unbound, at every hop; nothing revives them.
|
||||
- `gone` stays only for revocation (a provider host closing closes its links and sends nothing; its consumers see a
|
||||
drop, `loading`, and hold again on the next link).
|
||||
- Persisted job runs of a parked host-lifetime object are loaded with its documents; `run` and `finish` start only
|
||||
once it is live; the run count keeps the object as today.
|
||||
|
||||
## 11. Timeouts after the model
|
||||
|
||||
| Timeout or backoff | After | Why |
|
||||
| --- | --- | --- |
|
||||
| `readyTimeout` of an attempt | only its own steps; paused while parked | a slow dependency is bounded where it runs |
|
||||
| `readyTimeout` of `host.get`, `ctx.get`, `host.registry` | kept | a caller gets an answer |
|
||||
| `readyTimeout` of a parked call | kept | an action must not run long after it was asked for |
|
||||
| identity backoff 1-60 s | only for the object's own `transient` failures | nothing else says the disk or network is back |
|
||||
| dependency backoff, polling of refused holds | removed | recovery is an event |
|
||||
| dependency deadline across links | removed | the simultaneous-cycle gap is a plugin bug, reported by `parked_long` |
|
||||
| `parked_long` report | new: once per parking episode after `readyTimeout` on a remote attempt that is only `loading` | a diagnostic; nothing fails or retries |
|
||||
| route backoff, heartbeat, `closeTimeout`, routine and `finish` backoffs, `linger` | kept | not dependency waits |
|
||||
| rollout install timeout (not built) | a watchdog on the install only | never a deadline for dependencies |
|
||||
|
||||
## 12. What changes in the design
|
||||
|
||||
R18 (dependency failures `waiting`, no backoff, the deadline paused while parked, the dependency check as boundary,
|
||||
wakes on every change, an ended dependency breaks), S2 and S3 (`waiting`, woken at every usable value), slice 8's
|
||||
last-good rule (dropped when a contribution leaves), §3's destination table (by address; the publication gate), §6 (the
|
||||
simultaneous-cycle gap: no probe, `parked_long`), §7 (`host.hold`, getters, calls), §21 (no `gone` on
|
||||
removal; a dropped version unbinds only its requests; installs never await activations; rollout without revision
|
||||
fields; the reload handover), the start barrier (R17, W6), `FailureCause` (+ `waiting`), `parked_long`. Unchanged: what an object's own code reload does (§21), R18 after live,
|
||||
`retryOn` outside parking, `self.follow`'s R4, the call window and flow control, revocation, no client providers.
|
||||
|
||||
## 13. Decisions (owner, 2026-10-29)
|
||||
|
||||
1. The barrier: (a), `load` resolves at the first outcomes of this host's host-lifetime objects (§6.3).
|
||||
2. Getters wait through `waiting` until their `readyTimeout`; calls on `failed` still reject (§8).
|
||||
3. `waiting` is a new `FailureCause` kind (§2).
|
||||
4. A pure swap whose new factory declares a dependency that is not there parks the object (its gate closes; holders
|
||||
see `loading` or `failed { waiting }`; calls park or reject); no overlap of old and new code (§21).
|
||||
5. The last good registry value is kept only while every contribution it was built from is still in the table (§9).
|
||||
6. Cycles across hosts: no probe; `parked_long` and four authoring rules (§7).
|
||||
|
||||
1, 3, 4 and 5 are the recommendations, taken when the owner said "just build this" (2, 6 decided in discussion).
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
|
|
@ -1,111 +0,0 @@
|
|||
---
|
||||
title: Chord v2: a guide to building apps that reload
|
||||
cover: Chord v2 / A Guide
|
||||
kicker: A guide · Chord v2 target design · 2026-10-20
|
||||
subtitle: How to build an app like p2 from nothing on Chord v2 and pi-durable, one object at a time, so that new plugin code takes over while the agent keeps running
|
||||
stats:
|
||||
- 11 | layers in the rebuild, L1 to L11
|
||||
- ~1,300 | lines built so far (L1 contracts)
|
||||
- 6–7k | target size of Chord v2, without tests
|
||||
- 0.004 ms | Delta's apply per small change
|
||||
note: |
|
||||
This guide teaches the **designed** API of Chord v2 (`packages/chord/src/next`, export `@earendil-works/chord/next`)
|
||||
from `packages/chord/docs/next/design.md` and the newer decision log `packages/chord/docs/next/decisions.md`, which wins
|
||||
where the two differ. Most of it is **not built yet**: a first kernel of about 10k lines is frozen as a reference and
|
||||
Chord v2 is being rebuilt layer by layer; only the L1 contract layer exists (`src/next/contracts/`, commit
|
||||
`7d2bb8ea8`). Code in this guide is therefore the target, not something you can run today. Every chapter says what is
|
||||
built. API names are working names. Notes marked *review* are points that are undecided, or where the sources
|
||||
disagree; they name the files. The reader is assumed to know pi-durable; Chord is explained from the start.
|
||||
colophon: Chord v2 · a guide · first edition · 2026-10-20 · based on design.md (target design v2), redesign-decisions.md (review rounds 2 and 3), chord-next-walkthrough.md, layer-1.md, contracts/README.md, the p3 functionality map, questions.md and the pi-durable README
|
||||
---
|
||||
|
||||
# Before we begin {.preface}
|
||||
|
||||
It is late in the evening. Mario is on his phone, connected to his laptop at home through the Radius relay. In session
|
||||
S1 the agent is twenty minutes into a refactoring; right now it is running a test suite through `bash`. A colleague,
|
||||
Ann, follows the session read-only from her own browser. Mario notices that the notes panel sorts notes the wrong way
|
||||
round. He fixes one line in the notes plugin on the laptop and presses **Reload**.
|
||||
|
||||
What should happen is easy to say. The notes panel on both screens shows the new order. The test run keeps running. The
|
||||
agent's turn continues as if nothing happened. Ann's view does not flicker into an error. Nothing that was written is
|
||||
lost, and no process restarts.
|
||||
|
||||
The obvious ways to get there fail. **Restarting the processes** loses the turn in flight, the open terminal and every
|
||||
connection; pi-durable would resume the turn, but the test run and the connections are gone. **Hot-swapping modules**
|
||||
in place, as front-end tooling does, leaves the old code alive in closures, timers, event listeners and sockets that
|
||||
nobody knows about: the old sort function keeps running inside a callback someone stored, a file watcher opened by the
|
||||
old code is never closed, and two versions of the plugin now write the same data without any order.
|
||||
|
||||
Chord v2 is the runtime that makes the first outcome normal. It does so by being strict about one thing: **every piece
|
||||
of state, every open OS handle and every piece of running work has exactly one owner, an object, and objects only
|
||||
declare what they need.** Chord itself activates, connects, reloads and ends them. Because Chord knows every timer,
|
||||
watcher, transaction and reference an object has, it can swap the object's code while its data stays, restart only
|
||||
what touches the operating system, and route calls between browser tabs, the server and per-session worker processes
|
||||
without the plugin author writing any of that.
|
||||
|
||||
This guide teaches Chord v2 by building an app called **Studio**: a small version of p2 (Pi Mobile), with a server,
|
||||
one worker process per session, browser tabs as clients, and an agent from pi-durable on each worker. The first
|
||||
chapters grow one plugin, `@studio/notes`, until it uses every concept; the last three chapters assemble the rest of a
|
||||
p2-like app from the same parts.
|
||||
|
||||
:::review | Review: what exists today
|
||||
Nothing in this guide runs yet except the contract layer. `packages/chord/src/next/contracts/` (L1 part 1: `remote`,
|
||||
`local`, `method`, `evolve`, `checkAccepts`, the schema helpers) is built and reviewed. The rest of L1 (inputs,
|
||||
argument checks, principals, errors) is a draft spec (`packages/chord/docs/next/layer-1.md`). The frozen kernel in
|
||||
`src/next/*.ts` implements an older version of the design (host values, a development freeze, JSONL storage,
|
||||
descriptions on the wire); do not read it as the target. Build on the design, not on the frozen code.
|
||||
:::
|
||||
|
||||
## Ten things to know
|
||||
|
||||
:::findings
|
||||
1. **An app is a tree of hosts.** One server, any number of workers it starts on demand, any number of clients (tabs,
|
||||
TUIs) that connect to it. Clients reach workers only through the server, which relays.
|
||||
2. **Objects are the unit of everything.** An object type is a contract (name, input, placement, members, version); an
|
||||
object is one instance per input and caller. The first `get` creates it, later gets share it, the last release ends
|
||||
it.
|
||||
3. **Factories declare, Chord activates.** A factory is synchronous: it declares documents, references, derived
|
||||
values, OS handles, routines and jobs through `self`, and returns the members. Chord opens, binds and starts them,
|
||||
and undoes them in reverse.
|
||||
4. **Links stamp who is calling.** A caller's data never says who it is; the link it arrived on does. Each caller gets
|
||||
its own object and the variant of members it may use.
|
||||
5. **Shared data lives once, behind views.** A `local` shared store holds the data; per-caller `remote` views expose
|
||||
what each caller may see. Only stable identities persist documents.
|
||||
6. **State changes only in transactions.** Documents are JSON values written in `self.tx`; commits are atomic for
|
||||
every viewer and travel as small operations, never as whole values.
|
||||
7. **Failures look like disconnects.** A reference survives link drops, restarts and failures: calls park or fail,
|
||||
values stay readable, and Chord retries by itself. There are no request ids.
|
||||
8. **Everything long-lived has a declared form.** Standing behaviour is a routine, work that outlives a call is a job,
|
||||
input from a person is a flow, behaviour from other plugins comes through a registry. Nothing else may hold a timer
|
||||
or a callback.
|
||||
9. **Reload swaps pure objects in place and restarts native ones.** Objects without OS handles get the new code with
|
||||
their data and running work intact; objects with OS handles close and reopen them. A broken change leaves the whole
|
||||
host on its old code.
|
||||
10. **pi-durable runs inside a core object.** The agent harness is a host-lifetime object on each worker; models,
|
||||
environments and tools reach it through registries, and tools reach Chord through `chord(fn, context)`.
|
||||
:::
|
||||
|
||||
## Words used throughout
|
||||
|
||||
A **host** is one participant of the app: a process, a browser tab or a Durable Object, identified by a logical
|
||||
identity and an **incarnation** that changes on every start. A **link** connects two hosts. The **server** is the one
|
||||
addressable root host; a **worker** is a host the server starts for one session; a **client** is a tab or TUI, not
|
||||
addressable. A **plugin** is a directory with a `package.json`; its code for one kind of host is a **facet**. **Base
|
||||
code** is the app's own code (Chord calls its facet the core facet); it restarts the hosts when it changes, while
|
||||
plugins reload. An **object type** (or **contract**) describes objects; an **object** is one instance; its **input** is
|
||||
its key among objects of that type, and its **placement** computes from the input on which host it lives. A **factory** builds an
|
||||
object; **`self`** is what a factory declares through. A **principal** is who calls, stamped by the link; a
|
||||
**variant** is the set of members a principal gets. A **reference** is what `get` returns. A **readable** is a value
|
||||
plus its ordered changes. A **document** is a named JSON value an object owns, written in a **transaction**. A
|
||||
**derive** is a value computed from readables. A **native** is an OS-level value (a socket, a watcher, a process)
|
||||
opened with `self.open`; an object with natives is a **native object**, one without is a **pure object**. A
|
||||
**routine** is standing behaviour, a **job** is owned work that outlives its call, a **flow** is input into running
|
||||
work. A **registry** is an owner's extension point, filled with **contributions**. **`ctx`** is what every call
|
||||
receives: cancellation, caller, call-scoped references. A **reload** swaps a plugin's code inside running hosts; a
|
||||
**restart** ends a process; a **revision** names the complete code set the hosts run.
|
||||
|
||||
:::note | Reading the code in this guide
|
||||
Imports are shown once per file and then omitted. `obj`, `str`, `arr` and friends are Chord's schema helpers (they
|
||||
return ordinary TypeBox schemas). Helper functions that belong to the app (`authenticate`, `watchDir`, `uuidv7`) are
|
||||
named and not shown. Where the design gives no name, the guide says it uses a working name.
|
||||
:::
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
|
||||
# The Shape of an App {#shape lede="Which processes a Chord app has, how they find each other, and the start code that connects them."}
|
||||
|
||||
Before any plugin exists, Studio needs processes and connections between them. In Chord each participant is a host,
|
||||
and the app's own start code creates every host, connects them and decides how workers start. Chord deliberately does
|
||||
none of that itself: a laptop app forks child processes, a test runs everything in one process, a Cloudflare
|
||||
deployment routes through the edge. This chapter writes Studio's start code: `server.ts`, `worker.ts`, the browser
|
||||
entry, and a test setup.
|
||||
|
||||
## Three roles
|
||||
|
||||
A Node app on Chord has one shape:
|
||||
|
||||
:::flow wide | The three roles. Clients connect to the one server; the server starts workers and relays between them.
|
||||
row "Clients: not addressable" muted
|
||||
box tab1 "browser tab" "owner, local"
|
||||
box tab2 "browser tab" "guest Ann"
|
||||
box tui "TUI" "owner"
|
||||
row "Server: exactly one" accent
|
||||
gap
|
||||
box server "server" "`Server()`" accent
|
||||
gap
|
||||
row "Workers: one per session, on demand" blue
|
||||
box w1 "worker" "`Session({ id: \"S1\" })`" blue
|
||||
box w2 "worker" "`Session({ id: \"S2\" })`" blue
|
||||
box w3 "worker" "`Session({ id: \"S3\" })`" dashed
|
||||
arrow tab1 -> server "WebSocket"
|
||||
arrow tab2 -> server "WebSocket"
|
||||
arrow tui -> server "WebSocket"
|
||||
arrow server -> w1 "IPC"
|
||||
arrow server -> w2 "IPC"
|
||||
note "A worker for S3 does not exist until something asks for an object that lives there."
|
||||
:::
|
||||
|
||||
| Role | Addressable | Who it is comes from | Lifecycle |
|
||||
| --- | --- | --- | --- |
|
||||
| client | no | the authentication of its link | connects by itself |
|
||||
| server | yes, exactly one | its own declaration | runs |
|
||||
| worker | yes, many, by a typed address | stamped by the server, its parent | started on demand by the app |
|
||||
|
||||
The **server** holds what all sessions share (the session list, access, settings) and starts workers. A **worker**
|
||||
runs one session: its agent, its files, its terminal connections. **Clients** are browser tabs and TUIs. Nobody can
|
||||
send a request *to* a client, so a client can only serve objects that nobody outside it needs; chapter IV names them
|
||||
`local` types.
|
||||
|
||||
Things that look like hosts but are not: a code mode sandbox (a worker thread inside a worker, owned by an object), a
|
||||
remote environment's daemon (it speaks its own protocol, owned by an object), the TUI's sandbox behind its launcher.
|
||||
A detached process such as a terminal host could become another kind of child of the server.
|
||||
|
||||
## Host kinds and addresses
|
||||
|
||||
Every addressable host has a typed address. The app declares its **host kinds** once, in base code:
|
||||
|
||||
```ts
|
||||
// studio/src/hosts.ts
|
||||
import { hostKind, obj, str } from "@earendil-works/chord/next";
|
||||
|
||||
export const Server = hostKind("server");
|
||||
export const Session = hostKind("session", obj({ id: str() }));
|
||||
|
||||
Server(); // the address of the server
|
||||
Session({ id: "S1" }); // the address of the worker of session S1
|
||||
```
|
||||
|
||||
There are no host-name strings anywhere in app code. Chord serializes addresses internally; your code only calls the
|
||||
kinds. An object type names its host with a placement function from its input to an address (chapter II), so the
|
||||
type checker knows that `Session({ id: session })` needs a `session` field.
|
||||
|
||||
## Links
|
||||
|
||||
A **link** is an ordered, two-way channel of messages between two hosts; it only delivers. Chord ships three: a memory
|
||||
pair for tests, IPC for a Node child process forked with `serialization: "advanced"`, and WebSocket. All three carry
|
||||
the same binary message format and the same flow control (chapter XVII). Recovery policy belongs to the link adapter:
|
||||
IPC needs no heartbeat, a WebSocket through a relay needs one, because TCP does not notice that a laptop went to sleep.
|
||||
|
||||
Each link has a **direction**. A host has at most one link to its parent (`connect`) and any number of links from
|
||||
below (`accept` for clients, `route` for workers). Requests arriving on a link act as the principal the link was given;
|
||||
chapter III explains why that matters.
|
||||
|
||||
## The server
|
||||
|
||||
```ts
|
||||
// studio/src/server.ts
|
||||
import { createHost, ipcLink, webSocketLink } from "@earendil-works/chord/next";
|
||||
import { fork } from "node:child_process";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { Server, Session } from "./hosts.ts";
|
||||
import { coreServerFacet } from "./core/server.ts";
|
||||
import type { P } from "./principal.ts";
|
||||
|
||||
const server = createHost<P>({
|
||||
address: Server(),
|
||||
principal: { kind: "server" }, // who this host's own code acts as
|
||||
storage: await openServerStorage(dataDir), // persisted documents (chapter V); memory if omitted
|
||||
onError: (e) => { if (e.kind === "storage_failed") process.exit(75); else log(e); },
|
||||
});
|
||||
|
||||
await server.load([
|
||||
{ name: "@studio/core", facet: coreServerFacet(config) }, // base code, under a fixed name
|
||||
...(await loadPlugins("server")), // [{ name, facet }] from the plugin loader
|
||||
]);
|
||||
|
||||
const wss = new WebSocketServer({ server: http, path: "/chord", maxPayload: 64 * 1024 * 1024 });
|
||||
wss.on("connection", (ws, req) => {
|
||||
const principal = authenticate(req); // app code: cookie, loopback, device token
|
||||
if (!principal) return ws.close(4401);
|
||||
server.accept(webSocketLink(ws), { principal });
|
||||
});
|
||||
|
||||
server.route(Session, {
|
||||
authorize: (p, { id }) =>
|
||||
p.kind === "owner" || p.kind === "server" || (p.kind === "guest" && p.session === id),
|
||||
resolve: async ({ id }, { signal }) => {
|
||||
const principal: P = { kind: "worker", session: id };
|
||||
const child = fork("worker.ts", [], { serialization: "advanced", signal });
|
||||
child.send({ id, principal, init: await initFor(id, signal) }); // the app's own start message
|
||||
return { link: ipcLink(child), principal }; // Chord stamps `principal` on it
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Each call has one job:
|
||||
|
||||
| Call | Meaning |
|
||||
| --- | --- |
|
||||
| `createHost<P>({ address?, principal?, ...options })` | with an address: an addressable host whose own code acts as `principal`; without: a client. `P` is the app's principal type |
|
||||
| `load([{ name, facet }])` | the facets this host runs, each with its plugin's `package.json` name; duplicates and invalid names are refused. Base code passes its core facet the same way |
|
||||
| `accept(link, { principal })` | a link from a client; every request on it acts as `principal` |
|
||||
| `route(Kind, { authorize, resolve })` | how to reach hosts of a kind. `authorize(principal, address)` runs before anything starts; `resolve(address, { signal })` returns `{ link, principal }` |
|
||||
| `connect(link, { principal })` | the link to the parent, at most one, replaced after a reconnect; `principal` says who the parent is |
|
||||
| `root(fn, { principal?, signal? })` | a call scope for code outside objects (a CLI command): gives `fn` a `ctx` (chapter VI) |
|
||||
|
||||
Chord has no `listen`. Entry points differ too much: a loopback WebSocket server, a tunnel, the Radius relay
|
||||
delivering channels, a Durable Object edge, a memory link in a test. Each entry point authenticates and calls
|
||||
`accept`. Radius, for example, is one more line:
|
||||
|
||||
```ts
|
||||
radius.onChannel((ws) =>
|
||||
server.accept(webSocketLink(ws), { principal: { kind: "owner", access: "relay" } }));
|
||||
```
|
||||
|
||||
## The worker
|
||||
|
||||
```ts
|
||||
// studio/src/worker.ts
|
||||
const { id, principal, init } = await startMessage(process); // app code: the first IPC message
|
||||
const worker = createHost<P>({
|
||||
address: Session({ id }),
|
||||
principal, // { kind: "worker", session: id }
|
||||
storage: await openSessionStorage(init.dataDir, id), // one storage per session
|
||||
});
|
||||
await worker.load([
|
||||
{ name: "@studio/core", facet: coreWorkerFacet(init) }, // serves the start data (chapter IX)
|
||||
...(await loadPlugins("worker")),
|
||||
]);
|
||||
worker.connect(ipcLink(process), { principal: { kind: "server" } });
|
||||
```
|
||||
|
||||
## A browser tab
|
||||
|
||||
```ts
|
||||
// studio/web/main.tsx
|
||||
const client = createHost<P>(); // no address: a client
|
||||
await client.load(await loadPlugins("web"));
|
||||
client.connect(webSocketLink(new WebSocket("/chord")), { principal: { kind: "server" } });
|
||||
const notes = await client.get(Notes, { session: "S1" }); // tab → server → worker S1
|
||||
```
|
||||
|
||||
## The first `get`, step by step
|
||||
|
||||
The tab asks for the notes of S1. No worker for S1 runs yet.
|
||||
|
||||
:::sequence animate | The first get for an object on a worker that is not running. The server authorizes, the app starts the worker, Chord stamps the link.
|
||||
actor tab "browser tab" "owner"
|
||||
actor server "server" accent
|
||||
actor app "route(Session)" "app code" muted
|
||||
actor worker "worker S1" blue
|
||||
tab -> tab: placement: Session({ id: "S1" })
|
||||
tab -> server: hold Notes { session: "S1" }
|
||||
server -> server: not mine: route(Session)
|
||||
server -> app: authorize(owner, { id: "S1" })
|
||||
app --> server: true
|
||||
server => app: resolve({ id: "S1" })
|
||||
app -> worker: fork + start message
|
||||
worker -> worker: createHost, load, connect
|
||||
app --> server: { link, principal: worker S1 }
|
||||
server => worker: hold (stamped: owner)
|
||||
worker -> worker: placement check, run factory
|
||||
worker --> server: bound: snapshot
|
||||
server --> tab: bound: snapshot
|
||||
:::
|
||||
|
||||
`resolve` runs once per address while its link lives. When the link drops (the worker crashed or the supervisor
|
||||
stopped it) and references to that worker still exist, Chord calls `resolve` again by itself, with a per-address
|
||||
backoff, so a tab that only watches recovers without a click. A worker start that already produced a link finishes
|
||||
even if the request that triggered it went away.
|
||||
|
||||
**Incarnations** make restarts visible. Chord mints a fresh id (a uuidv7) on every host start and sends it in the link
|
||||
handshake; the other side sees a restart as a new incarnation of the same identity, and references to it re-hold
|
||||
(chapter VI).
|
||||
|
||||
## Tests: everything in one process
|
||||
|
||||
The same code runs in a test with memory links. `resolve` builds the worker host in memory and returns one end of the
|
||||
pair:
|
||||
|
||||
```ts
|
||||
server.route(Session, {
|
||||
authorize: () => true,
|
||||
resolve: async ({ id }) => {
|
||||
const [up, down] = memoryLinkPair();
|
||||
const principal: P = { kind: "worker", session: id };
|
||||
const worker = createHost<P>({ address: Session({ id }), principal });
|
||||
await worker.load([{ name: "@studio/core", facet: coreWorkerFacet({ id, dataDir }) }, notesWorker]);
|
||||
worker.connect(up, { principal: { kind: "server" } });
|
||||
return { link: down, principal };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Memory links deliver the same encoded messages asynchronously, so a test runs the production protocol path.
|
||||
|
||||
## What stays app code
|
||||
|
||||
Starting processes, the start message, authentication, supervising and stopping idle workers are app code. Chord
|
||||
provides one readable for the last one: `host.activity`, with `{ references, calls, jobs, lingering }` (viewers, calls
|
||||
in flight, unsettled jobs, objects kept warm after their last reference). Studio's core reports it to the server
|
||||
together with the agent's running tasks, and the server's supervisor stops a worker when everything has been zero for
|
||||
five minutes, as p2 does. Chord counts; it has no idle policy.
|
||||
|
||||
## Host options and timeouts
|
||||
|
||||
`createHost` takes the options introduced later in this guide: `storage` (V), `principalKey` and `revokedBy` (III,
|
||||
VII), `telemetry` (XVII) and `onError`. `onError` receives background errors and `{ kind: "storage_failed" }`; every
|
||||
app must handle the latter, because Chord never ends the process. Studio exits with its own code so its supervisor
|
||||
restarts it.
|
||||
|
||||
Timeouts are host defaults with per-thing overrides; the full table is in the appendix. The two you meet first:
|
||||
`readyTimeout` (60 s: how long an activation and an awaited `get` wait) and the park deadline (60 s: how long a call
|
||||
waits through a link drop or a restart before failing `unavailable`).
|
||||
|
||||
:::note | Built so far
|
||||
Nothing in this chapter is built in Chord v2 yet: hosts, links and routes are layers L6 and L7. The frozen kernel has
|
||||
working versions with the same shape (`createHost`, `connect`, `accept`, `route`, `load`), which the rebuild
|
||||
simplifies.
|
||||
:::
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
|
||||
# A First Object {#first-object lede="A plugin, a contract, a factory, and a browser tab that calls it on another process."}
|
||||
|
||||
Studio's first feature is session notes: a list of short notes per session, shown in a panel, that anyone allowed can
|
||||
add to. Several places need "the notes of S1": the owner's tab, a guest's tab, later a download route and an agent
|
||||
tool. If each built its own copy, the copies would drift apart and nobody would know when to clean them up. In Chord
|
||||
there is **one object** for "the notes of S1", everyone asks for it by its type and input, and Chord routes them all to it.
|
||||
|
||||
## The plugin's files
|
||||
|
||||
A plugin is a directory with a `package.json`. Its `name` (npm style, globally unique and stable) is the plugin's
|
||||
identity: Chord finds its stored documents, reports its errors and orders its contributions by it, never by a
|
||||
directory name or load order.
|
||||
|
||||
| File | Runs on | Holds |
|
||||
| --- | --- | --- |
|
||||
| `package.json` | read by the loader | `"name": "@studio/notes"` |
|
||||
| `contract.ts` | every host that imports it | object types: names, shapes, method signatures; no running code |
|
||||
| `worker.ts` | every session worker | the worker facet |
|
||||
| `server.ts` | the server | the server facet (later: the download route) |
|
||||
| `web.tsx` | browser tabs | the web facet: the panel |
|
||||
|
||||
## The contract
|
||||
|
||||
An object type is a contract: a name, an **input** (the object's key among objects of this type), a **placement**
|
||||
(on which host it lives, computed from the input), the **members** (state you can read and methods you can call) and a **version**.
|
||||
|
||||
```ts
|
||||
// notes/contract.ts
|
||||
import { arr, int, method, obj, remote, str, type Static } from "@earendil-works/chord/next";
|
||||
import { Session } from "@studio/core/hosts";
|
||||
|
||||
export const Note = obj({ id: str(), text: str({ max: 2000 }), by: str(), at: int() });
|
||||
export type Note = Static<typeof Note>;
|
||||
|
||||
export const Notes = remote("notes", {
|
||||
version: 1,
|
||||
input: obj({ session: str() }), // the key: { session: "S1" }
|
||||
placement: ({ session }) => Session({ id: session }), // input → the host's address
|
||||
variants: {
|
||||
editor: {
|
||||
state: { notes: obj({ items: arr(Note) }) },
|
||||
methods: {
|
||||
add: method(obj({ text: str({ min: 1, max: 2000 }) }), str()), // returns the new id
|
||||
remove: method(obj({ id: str() })), // no result
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`remote` means objects of this type can be reached from any host, across links (chapter IV introduces `local`). The
|
||||
schema helpers (`obj`, `str`, `int`, `arr`, `map`, `opt`, `lit`, `oneOf`, `union`, `bool`, `num`, `nul`, `bytes`,
|
||||
`unknown`) return ordinary TypeBox schemas; short options such as `{ min, max }` bound lengths or values. Schemas must
|
||||
describe data: no callbacks, no class instances, no custom codecs.
|
||||
|
||||
`method()` has four forms: `method()` (no arguments, no result), `method(args)`, `method(args, result)` and
|
||||
`method(undefined, result)`.
|
||||
|
||||
A contract contains no code that runs, so every host can import it. When you declare it, Chord materializes its
|
||||
description and computes a **fingerprint**, a hash of its input, variants and members. Two hosts compare contracts by
|
||||
version and fingerprint (chapter XVI). Placement is not part of the fingerprint.
|
||||
|
||||
## Placement
|
||||
|
||||
The input says *which* object; `placement` says *where* it lives. The caller runs the placement function on the input and sends the resulting address with its request; the server
|
||||
routes by it. The provider runs the function too and refuses an object that is not its own with `invalid/placement`.
|
||||
So a worker for S2 never builds "Notes for S1", even if a caller has an outdated rule. Because placement is a typed
|
||||
function, a `Session` placement on a type whose input has no `session` field does not compile.
|
||||
|
||||
Inputs are **normalized** before anything else: defaults are applied, unknown fields removed, and a canonical key
|
||||
computed. `{ session: "S1" }` and `{ session: "S1", extra: 1 }` are the same object.
|
||||
|
||||
## The facet and the factory
|
||||
|
||||
A **facet** is a plugin's setup for one kind of host. It runs once when the plugin loads on that host and tells the
|
||||
host which object types it serves:
|
||||
|
||||
```ts
|
||||
// notes/worker.ts
|
||||
import { defineState } from "@earendil-works/chord/next";
|
||||
import { defineFacet } from "@studio/core"; // Studio's defineFacet: principals typed as Studio's P (chapter III)
|
||||
import { Note, Notes } from "./contract.ts";
|
||||
|
||||
const NoteList = defineState("notes.list", { // a document (chapter V); in memory for now
|
||||
schema: obj({ items: arr(Note) }),
|
||||
version: 1,
|
||||
initial: () => ({ items: [] }),
|
||||
checkpointWhen: () => true,
|
||||
});
|
||||
|
||||
export default defineFacet((host, self) => {
|
||||
host.serve(Notes, ({ session }, principal, self) => {
|
||||
const list = self.state(NoteList); // THE note list of this object
|
||||
return {
|
||||
variant: "editor",
|
||||
notes: list, // a document handle is a readable: expose it as state
|
||||
add: ({ text }, ctx) =>
|
||||
self.tx(async (tx) => {
|
||||
const id = uuidv7();
|
||||
(await tx.draft(list)).items.push({ id, text, by: "someone", at: Date.now() });
|
||||
return id;
|
||||
}, ctx),
|
||||
remove: ({ id }, ctx) =>
|
||||
list.change((d) => { d.items = d.items.filter((n) => n.id !== id); }, ctx),
|
||||
};
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Read `host.serve(Notes, factory)` as "this host builds `Notes` objects like this". The factory receives the
|
||||
normalized input, the caller's principal (chapter III) and `self`, through which it declares what the object needs.
|
||||
It returns the members of one variant, tagged with `variant`.
|
||||
|
||||
**Factories declare; Chord activates.** The factory is synchronous and has no side effects. It does not await, open
|
||||
files or start timers. `self.state(NoteList)` does not load anything: it declares that the object has this document
|
||||
and returns a **handle** that stays valid for the object's whole life. Chord then performs the activation: it loads the
|
||||
document, binds references, opens OS handles, starts routines, and only then lets calls in. The rule exists for
|
||||
reload: a reload runs the factory again, and anything the factory did itself would happen twice.
|
||||
|
||||
## Calling it
|
||||
|
||||
```tsx
|
||||
// notes/web.tsx
|
||||
export function NotesPanel({ session }: { session: string }) {
|
||||
const notes = useRef(Notes, { session }); // app-side hook over host.get / release
|
||||
const items = useReadable(notes?.notes)?.items ?? [];
|
||||
return (
|
||||
<section>
|
||||
{items.map((n) => <p key={n.id}>{n.text}</p>)}
|
||||
<AddBox onAdd={(text) => notes?.add({ text })} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Underneath the hooks is the reference API:
|
||||
|
||||
```ts
|
||||
const notes = await client.get(Notes, { session: "S1" }); // live and hydrated once awaited
|
||||
notes.notes.value.items; // the current list
|
||||
notes.notes.subscribe((v) => render(v.items)); // every committed change
|
||||
const id = await notes.add({ text: "check the flaky test" });
|
||||
notes.release(); // the object may end now
|
||||
```
|
||||
|
||||
An awaited reference is **live and hydrated**: every state member has a value. State members are **readables**:
|
||||
`.value` is the current value, `subscribe` is called after every commit. The first `get` for `{ session: "S1" }` runs
|
||||
the factory; every later `get` for the same input and caller shares that object. The object ends when the last
|
||||
reference is released (a type may keep it a little longer with `linger`).
|
||||
|
||||
:::sequence animate | A call to add. Arguments are checked once at the provider; the commit reaches the viewer as one small change.
|
||||
actor tab "browser tab"
|
||||
actor server "server" muted
|
||||
actor worker "worker S1" blue
|
||||
actor obj "Notes { S1 }" accent
|
||||
tab -> server: call add { text }
|
||||
server -> worker: relay (header only)
|
||||
worker -> obj: strip + validate args
|
||||
obj => obj: self.tx: push note, commit
|
||||
obj --> worker: result: id
|
||||
worker --> server: state: ops of the commit
|
||||
worker --> server: result
|
||||
server --> tab: state, then result
|
||||
:::
|
||||
|
||||
## Two rules for members
|
||||
|
||||
**Methods return data, never objects.** A method that wants to point you to another object returns its address
|
||||
(`{ session: "S2" }`), and you `get` it yourself. That keeps every reference owned by someone who releases it.
|
||||
|
||||
**No callbacks as arguments.** A callback carries code, and everything it closes over, from one object into another.
|
||||
Suppose an inbox object passed `onFile` to another object, which stored it. After a reload of the inbox's plugin, the
|
||||
other object still calls the *old* `onFile`; after the inbox ends, it calls into an object whose file watcher is
|
||||
already closed. Chord forbids this by construction: members are data (JSON plus bytes). Behaviour from elsewhere goes
|
||||
through registries (chapter XIII), multi-step work through jobs (XI), input from a person through flows (XII).
|
||||
|
||||
## What is wrong with this version
|
||||
|
||||
This first version works for one person. It has two problems, which the next two chapters fix. Everyone gets the
|
||||
`editor` variant, including a read-only guest. And, less obvious, the owner and a guest do not see the same notes: as
|
||||
chapter III shows, a remote object exists per caller, so each caller's `Notes` object has its own `NoteList`.
|
||||
|
||||
:::note | Built so far
|
||||
`remote`, `method`, the schema helpers, `Static` and fingerprints are built (`src/next/contracts/`). Serving, `get`,
|
||||
references and documents are layers L3 to L5.
|
||||
:::
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
|
||||
# Who Is Calling {#principals lede="Principals: how Chord knows who calls, why a caller cannot lie about it, and how the answer picks the object and its members."}
|
||||
|
||||
The owner may add and remove notes. Ann, a read-only guest, may only read them and should not even see who wrote
|
||||
what. The object must therefore know who is calling, and the caller must not be able to claim to be someone else. In
|
||||
Chord the answer is a **principal**, a small JSON value the app defines and the links stamp.
|
||||
|
||||
## The app's principal type
|
||||
|
||||
Chord defines no principal kinds; the app does. Every variant has a `kind`. Studio uses p2's set:
|
||||
|
||||
```ts
|
||||
// studio/src/principal.ts
|
||||
export type P =
|
||||
| { kind: "owner"; access: "local" | "relay" | "device"; device?: string }
|
||||
| { kind: "guest"; session: string; mode: "read" | "write"; grant: string; revision: number }
|
||||
| { kind: "server" }
|
||||
| { kind: "worker"; session: string };
|
||||
```
|
||||
|
||||
| `kind` | Who |
|
||||
| --- | --- |
|
||||
| `owner` | the person running the app, on loopback, through the relay, or on a paired device |
|
||||
| `guest` | someone invited into one session, with a mode and the grant (and its revision) that let them in |
|
||||
| `server` | the server's own code calling a worker |
|
||||
| `worker` | a session worker calling the server |
|
||||
|
||||
## Links stamp principals
|
||||
|
||||
A principal never comes from a request's data. The server authenticates a client once, when its WebSocket connects,
|
||||
and stamps the result on the whole link (`server.accept(link, { principal })`). The server tells Chord who each worker
|
||||
is when it resolves it (`return { link, principal }`). When the server relays a client's request to a worker, Chord
|
||||
writes the client's principal into the forwarded request; a principal field coming from a client is always
|
||||
overwritten, and only a trusted parent link may carry one.
|
||||
|
||||
Where principals come from is app code. Studio's sharing feature creates a share link per session and mode; a guest
|
||||
who opens it gets a guest record (grant `g1`) and a cookie; `authenticate(req)` turns the cookie into
|
||||
`{ kind: "guest", session: "S1", mode: "read", grant: "g1", revision: 1 }` on every new connection. Chord never sees
|
||||
cookies or grants, only the finished principal.
|
||||
|
||||
## The caller is part of the identity
|
||||
|
||||
A `remote` object exists once per **(type, input, principal key)**. The **principal key** is a string computed from
|
||||
the principal; by default its canonical JSON (sorted keys). The app may pass its own `principalKey` to `createHost`,
|
||||
and must pass the same function on every host, or identities split silently.
|
||||
|
||||
```ts
|
||||
const principalKey = (p: P) =>
|
||||
p.kind === "owner" ? `owner:${p.access}:${p.device ?? ""}`
|
||||
: p.kind === "guest" ? `guest:${p.grant}:r${p.revision}`
|
||||
: p.kind === "worker" ? `worker:${p.session}`
|
||||
: p.kind;
|
||||
```
|
||||
|
||||
The design wants the key to include how the client is connected and the grant's revision, so that two access paths
|
||||
never share objects and a changed grant never reuses the old grant's object:
|
||||
|
||||
| Caller | Principal key | `Notes { S1 }` object |
|
||||
| --- | --- | --- |
|
||||
| owner, tab A, local | `owner:local:` | 1 |
|
||||
| owner, tab B, local | `owner:local:` | 1 (shared with tab A) |
|
||||
| owner through the relay | `owner:relay:` | 2 |
|
||||
| guest g1, revision 1 | `guest:g1:r1` | 3 |
|
||||
| guest g1 after a change to write mode, revision 2 | `guest:g1:r2` | 4 (new) |
|
||||
|
||||
## Variants
|
||||
|
||||
The contract lists named sets of members; the factory receives the principal and returns the variant that principal
|
||||
may use:
|
||||
|
||||
```ts
|
||||
// notes/contract.ts (version 1, final form for now)
|
||||
variants: {
|
||||
editor: {
|
||||
state: { notes: obj({ items: arr(Note) }) },
|
||||
methods: { add: method(obj({ text: str({ min: 1, max: 2000 }) }), str()), remove: method(obj({ id: str() })) },
|
||||
},
|
||||
reader: {
|
||||
state: { notes: obj({ items: arr(PublicNote) }) }, // no `by`: authors are hidden
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
```ts
|
||||
// notes/worker.ts
|
||||
host.serve(Notes, ({ session }, principal, self) => {
|
||||
if (canWrite(principal, session)) return { variant: "editor", notes: …, add: …, remove: … };
|
||||
return { variant: "reader", notes: … };
|
||||
});
|
||||
```
|
||||
|
||||
A reader has no `add` at all. Chord refuses a call to a member that is not in the caller's variant, so `add` needs no
|
||||
check of its own. Three rules keep variants compatible: a member name has the same kind (state or method) in every
|
||||
variant that has it; a method has the same arguments and result in every variant; only state schemas may differ per
|
||||
variant (the reader's notes have no authors). `remote(...)` enforces them at the type level, and at runtime for
|
||||
contracts that arrive as data.
|
||||
|
||||
## How a facet knows `P`
|
||||
|
||||
`P` is Studio's type, not Chord's, and every host is created with it (`createHost<P>`). A plugin's facets are written
|
||||
and compiled separately, then handed to `host.load` as values, so the type checker cannot infer `P` from the host into
|
||||
the factory's `principal`. Studio's base code therefore exports its own `defineFacet`, already bound to `P`, and every
|
||||
facet of every plugin imports that one instead of Chord's:
|
||||
|
||||
```ts
|
||||
// studio/src/facet.ts (base code, exported as @studio/core)
|
||||
import { bindFacet } from "@earendil-works/chord/next";
|
||||
import type { P } from "./principal.ts";
|
||||
|
||||
export const defineFacet = bindFacet<P>(); // a facet's principals are Studio's P
|
||||
export type { P, Revocation } from "./principal.ts";
|
||||
```
|
||||
|
||||
```ts
|
||||
// notes/worker.ts (server.ts, web.tsx and tui.ts import the same defineFacet)
|
||||
import { defineFacet } from "@studio/core";
|
||||
|
||||
export default defineFacet((host, self) => {
|
||||
host.serve(Notes, ({ session }, principal, self) => { // principal: P
|
||||
if (principal.kind === "guest" && principal.mode === "read") return { variant: "reader", notes: … };
|
||||
…
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
The import is cheap: `@studio/core` is a host package plugins already import at runtime (the contract imports `Session`
|
||||
from it), `P` is a type-only import, and `bindFacet` is a one-line function. The host checks the pairing: `load`
|
||||
accepts only facets typed for its own `P`, so a facet compiled against another app's principal type, or one built with
|
||||
Chord's untyped `defineFacet`, does not compile into Studio's start code. Client facets (`web.tsx`, `tui.ts`) use the
|
||||
same `defineFacet`; they rarely see a principal, because clients serve only `local` types.
|
||||
|
||||
None of this is checked at runtime, and none of it needs to be: the principal a factory receives is the one the link
|
||||
stamped.
|
||||
|
||||
:::review | Review: binding facets to the app's principal type (open)
|
||||
The design shows `defineFacet((host, self) => …)` without saying how `P` reaches the factory. The frozen kernel takes
|
||||
it as an explicit type parameter, `defineFacet<P>(…)`, defaulting to `{ kind: string }`
|
||||
(`packages/chord/src/next/facet.ts`), and the playground writes `defineFacet<AppPrincipal>(…)`. The pre-bound
|
||||
`defineFacet` above (`bindFacet` is a working name) is the owner's preferred direction, recorded as an open point in
|
||||
`packages/chord/docs/next/layer-1.md` §4.
|
||||
:::
|
||||
|
||||
## Narrowing on the caller's side
|
||||
|
||||
Owner and guest run the same `web.tsx`, and which variant they get is known only at runtime. The types force every
|
||||
caller to narrow before using variant members:
|
||||
|
||||
```ts
|
||||
const notes = await client.get(Notes, { session: "S1" });
|
||||
notes.add({ text: "hi" }); // type error: `add` exists only in "editor"
|
||||
if (inVariant(notes, "editor")) await notes.add({ text: "hi" });
|
||||
expectVariant(notes, "editor"); // or assert (throws)
|
||||
function Editor({ notes }: { notes: RefIn<typeof Notes, "editor"> }) { … }
|
||||
```
|
||||
|
||||
The protection is not the type checker; it is Chord refusing the call at the provider. A client that ignores the
|
||||
types gets the same error.
|
||||
|
||||
## Admission before a worker starts
|
||||
|
||||
Principals also decide whether a caller may reach a host at all. `route(Session, { authorize })` runs before Chord
|
||||
resolves an address, so a guest of S1 asking for anything in S2 is refused at the server, and no worker starts for
|
||||
them. Finer authority lives in the per-principal view's methods; there is no asynchronous "may this caller activate
|
||||
this object" guard. An object whose address carries authority (say, a login flow by id) may exist; what a caller can
|
||||
see and do is decided by the variant its factory picks.
|
||||
|
||||
## Inside methods
|
||||
|
||||
`ctx.principal` is the caller of the current call. In Studio's notes the editor's `add` uses it to record the
|
||||
author:
|
||||
|
||||
```ts
|
||||
add: ({ text }, ctx) => store.add({ text, by: authorOf(ctx.principal) }, ctx),
|
||||
```
|
||||
|
||||
Where principals act, in summary:
|
||||
|
||||
| Purpose | Where |
|
||||
| --- | --- |
|
||||
| identity: which object a caller gets | principal key |
|
||||
| variant: which members | the factory |
|
||||
| admission: whether a host is reachable | `route.authorize` |
|
||||
| attribution and fine checks | `ctx.principal` in methods |
|
||||
| revocation | `revokedBy` (chapter VII) |
|
||||
|
||||
## The bug from chapter II
|
||||
|
||||
Now the bug is visible. The owner's `Notes { S1 }` and Ann's `Notes { S1 }` are two objects (rows 1 and 3 above), and
|
||||
each declared its own `NoteList`. The owner's notes are not in Ann's list. Per-caller objects must not hold the data;
|
||||
they must be views over something that exists once. That is the next chapter.
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
|
||||
# One Store, Many Views {#shared-store lede="Local types, and the pattern every Chord plugin uses: shared data once in a store, per-caller views in front of it."}
|
||||
|
||||
Every caller has its own `Notes` object, which is what makes per-caller variants possible. But data must exist once.
|
||||
The fix is the most common pattern in Chord: a **shared store**, a `local` object that holds the data and does the
|
||||
heavy work once, behind thin per-principal **views**, the `remote` objects callers get.
|
||||
|
||||
## `local` types
|
||||
|
||||
```ts
|
||||
// notes/contract.ts
|
||||
export const NoteStore = local("notes.store", {
|
||||
version: 1,
|
||||
input: obj({ session: str() }),
|
||||
members: {
|
||||
notes: obj({ items: arr(Note) }), // state: a schema
|
||||
public: obj({ items: arr(PublicNote) }),
|
||||
add: method(obj({ text: str(), by: str() }), str()), // methods: method(...)
|
||||
remove: method(obj({ id: str() })),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
A `local` contract has flat `members` and no variants. The differences:
|
||||
|
||||
| | `remote` | `local` |
|
||||
| --- | --- | --- |
|
||||
| reachable from | any host, across links | its own host only |
|
||||
| identity | (type, input, principal key) | (type, input) |
|
||||
| principals, variants | yes | no |
|
||||
| factory | `(input, principal, self)` | `(input, self)` |
|
||||
| arguments | stripped to the provider's schema and validated, for every caller | see the review note below |
|
||||
| values on the same host | passed by reference, never copied | passed by reference, never copied |
|
||||
| `ctx.principal` | the caller | none |
|
||||
| may persist documents | no (chapter V) | yes |
|
||||
|
||||
A local object has no principal: it is trusted code on its own host, acting for the host. So attribution ("who wrote
|
||||
this note") is passed as data (`by`), by the view that knows who called. Clients serve only local types, because
|
||||
nobody can address a client.
|
||||
|
||||
## The pattern
|
||||
|
||||
```ts
|
||||
// notes/worker.ts
|
||||
export default defineFacet((host, self) => {
|
||||
host.serve(NoteStore, ({ session }, self) => {
|
||||
const list = self.state(NoteList);
|
||||
const pub = self.derive("public", [list], ([l]) => ({ items: l.items.map(stripAuthor) }));
|
||||
return {
|
||||
notes: list,
|
||||
public: pub, // computed once, for every reader
|
||||
add: ({ text, by }, ctx) =>
|
||||
self.tx(async (tx) => {
|
||||
const id = uuidv7();
|
||||
(await tx.draft(list)).items.push({ id, text, by, at: Date.now() });
|
||||
return id;
|
||||
}, ctx),
|
||||
remove: ({ id }, ctx) => list.change((d) => { d.items = d.items.filter((n) => n.id !== id); }, ctx),
|
||||
};
|
||||
});
|
||||
|
||||
host.serve(Notes, ({ session }, principal, self) => {
|
||||
const store = self.get(NoteStore, { session }); // a reference, declared: held for the view's life
|
||||
if (canWrite(principal, session)) return {
|
||||
variant: "editor",
|
||||
notes: store.notes, // the store's readable, exposed as-is
|
||||
add: ({ text }, ctx) => store.add({ text, by: authorOf(principal) }, ctx),
|
||||
remove: (args, ctx) => store.remove(args, ctx),
|
||||
};
|
||||
return { variant: "reader", notes: store.public };
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
:::flow wide | One store, three views. The redaction runs once in the store; both readers read the same value.
|
||||
row "Per-principal views (remote)" blue
|
||||
box ve "Notes { S1 }" "owner · editor" blue
|
||||
box vr1 "Notes { S1 }" "guest Ann · reader" blue
|
||||
box vr2 "Notes { S1 }" "guest Bob · reader" blue
|
||||
row "Shared store (local)" accent
|
||||
gap
|
||||
box store "NoteStore { S1 }" "notes, public (a derive)" accent
|
||||
gap
|
||||
arrow ve -> store "self.get"
|
||||
arrow vr1 -> store "self.get"
|
||||
arrow vr2 -> store "self.get"
|
||||
note "Authority is the view's: the reader variant has no add. The store does not check; it is trusted and acts for the host."
|
||||
:::
|
||||
|
||||
`self.get(NoteStore, { session })` in a factory declares a **reference** that the view holds for its whole life; Chord
|
||||
binds it during activation and waits until the store is live (chapter VI). The view exposes the store's readables
|
||||
directly. Owner, Ann and Bob now give three `Notes` objects, one `NoteStore`, one note list and one redacted list.
|
||||
|
||||
Any heavy or shared part belongs in a store like this: a pi-durable watch, a file watcher, a sandbox, a search index.
|
||||
Base code uses the same pattern with the same primitives; nothing about it is special to core.
|
||||
|
||||
## Derived values, briefly
|
||||
|
||||
`self.derive(name, sources, fn)` computes a value from readables and recomputes it when a source changes; viewers get
|
||||
only the difference. The reader's list above is one. Chapter VIII covers derives in depth, including the one footgun
|
||||
that matters: `stripAuthor` must return the *same* object for an unchanged note, or every commit costs the size of the
|
||||
whole list. The tool for that is `mapShared`.
|
||||
|
||||
## Arguments are checked at the provider
|
||||
|
||||
Trusted code is not type-checked code: plugins are bundled with esbuild without type checks, and agents write
|
||||
JavaScript plugins. So the provider checks arguments itself (decision R7): every method's arguments are stripped to
|
||||
the **provider's own** schema (unknown fields removed) and validated with it, whoever the caller is, and never with a
|
||||
schema a peer sent. Schemas are compiled lazily on first use and cached per schema object. Bytes are checked as leaves
|
||||
(`instanceof Uint8Array` plus an optional length bound), never walked. Stripping copies only when a field was removed;
|
||||
the caller's value is never mutated (proposal Q12, in the L1 draft). Results and state are not validated: they come
|
||||
from the provider.
|
||||
|
||||
On the same host nothing is copied: arguments and results are passed by reference. Values are **immutable by
|
||||
convention** (Delta's rule, as in Durable): nobody mutates a value it received, and Chord neither copies nor freezes
|
||||
values to enforce it. There is no development mode that would freeze them; one behaviour everywhere (R9).
|
||||
|
||||
:::review | Review: are local arguments validated? (Q15, open)
|
||||
Design §5's table says `local` arguments are "neither validated nor copied"; §3 and R7 say arguments are validated
|
||||
"for every caller". Review round 3 raised this as Q15 (apply R7 to `local` too, or keep `local` as the one exception),
|
||||
and `layer-1.md` lists it as open. Until it is decided, write local methods so that they do not depend on either
|
||||
answer: trust the shape, but never trust content (lengths, ids) that came from a remote caller without the view
|
||||
checking it. `chord-next-walkthrough.md` concept 6 still describes the older rule ("validated only when the caller is
|
||||
not a host principal"), which R7 replaced.
|
||||
:::
|
||||
|
||||
## Choosing between the two
|
||||
|
||||
Use a `remote` type for what callers address: views, things with per-caller authority. Use a `local` type for what
|
||||
exists once per input on one host: stores, caches, owners of OS handles, and the core objects a host provides
|
||||
(chapter IX). If a type is reached across a link, it is remote; if a type persists documents, it is local (or a
|
||||
host-lifetime object, chapter V).
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
|
||||
# Writing It Down {#documents lede="Documents and transactions: how an object stores data, changes it atomically, survives restarts and migrates it."}
|
||||
|
||||
Notes must be stored, changed safely while others read them, sent to viewers as small changes, and survive a worker
|
||||
restart. An object's data lives in **documents**: named JSON values the object owns, changed only in
|
||||
**transactions**. The design is a port of pi-durable's store, so most rules will look familiar; this chapter states
|
||||
them for Chord.
|
||||
|
||||
## Defining a document
|
||||
|
||||
```ts
|
||||
// notes/worker.ts
|
||||
const NoteList = defineState("notes.list", {
|
||||
schema: obj({ items: arr(Note) }),
|
||||
version: 1,
|
||||
initial: () => ({ items: [] }),
|
||||
persist: true, // stored; default: memory only
|
||||
checkpointWhen: (_value, _ops, info) => info.deltasSinceBase > 64,
|
||||
});
|
||||
|
||||
const Draft = defineState("notes.draft", { // one unfinished note per author
|
||||
schema: obj({ text: str() }),
|
||||
version: 1,
|
||||
initial: () => ({ text: "" }),
|
||||
keyed: true, // many per object, by key
|
||||
persist: true,
|
||||
checkpointWhen: () => true, // small: a full base per write
|
||||
});
|
||||
```
|
||||
|
||||
`checkpointWhen` is required on every definition, with no default (R14, as in Durable since pi `5c1736f82`): it says
|
||||
when a commit stores a complete base instead of a delta, which bounds how much is replayed on load. Small documents
|
||||
checkpoint on every write; growing ones after a number of deltas.
|
||||
|
||||
A document's root is always a JSON object (lists live in a field). Documents hold JSON only; bytes are a value and wire
|
||||
type, not a document type.
|
||||
|
||||
## Singleton and keyed
|
||||
|
||||
```ts
|
||||
host.serve(NoteStore, ({ session }, self) => {
|
||||
const list = self.state(NoteList); // THE note list of this object
|
||||
const drafts = self.state(Draft); // all drafts of this object
|
||||
…
|
||||
});
|
||||
```
|
||||
|
||||
| | singleton (no `keyed`) | keyed |
|
||||
| --- | --- | --- |
|
||||
| how many | one per object | many per object, by a short key |
|
||||
| loaded | at activation, kept in memory | on demand |
|
||||
| read | `list.value` (synchronous) | `await drafts.get(key)`: the value or `undefined` |
|
||||
| list | (it is one value) | `for await (const key of drafts.keys())` pages keys |
|
||||
| expose as state | yes: the handle is a readable | no: expose a method or a singleton index |
|
||||
|
||||
There is no live key list. A collection shown live is a singleton (an object holding a list or a map); a large
|
||||
collection is a method returning pages.
|
||||
|
||||
## Transactions
|
||||
|
||||
All writes go through `self.tx(fn, ctx)`. Inside, `await tx.draft(handle, key?, { tags? })` returns a draft of the
|
||||
current document, or a draft over `initial()` if it is absent. Drafts record operations (assignments, `push`,
|
||||
`Object.assign`); asking for the same document twice in one transaction returns the same draft. `tx.retire(handle,
|
||||
key?)` removes a document. There is no `tx.get`: reading is drafting, and a draft is an overlay over the current
|
||||
value, so nothing is copied. A draft you do not change commits nothing, so reading never stores an absent document.
|
||||
|
||||
```ts
|
||||
saveDraft: ({ author, text }, ctx) =>
|
||||
self.tx(async (tx) => { (await tx.draft(drafts, author)).text = text; }, ctx),
|
||||
```
|
||||
|
||||
Shorthands for one document: `handle.change(fn, ctx)` (keyed: `handle.change(key, fn, ctx)`) (loads the document, then runs the synchronous `fn` on
|
||||
its draft) and `handle.retire(ctx)` (keyed: `handle.retire(key, ctx)`).
|
||||
|
||||
### Four patterns
|
||||
|
||||
Two decisions (R12 and R13, both as in Durable) shape how transactions are written. **Drafts are revoked when the
|
||||
transaction settles**, so a transaction returns at most plain values, never something taken from a draft. And
|
||||
**there is no existence check inside a transaction.**
|
||||
|
||||
```ts
|
||||
// 1. Return data: write in the transaction, read after the commit.
|
||||
await self.tx(async (tx) => { (await tx.draft(list)).items.push(note); }, ctx);
|
||||
return list.value.items.length; // the committed revision, or a newer one
|
||||
|
||||
// 2. Remove if present: retire of an absent document is a no-op.
|
||||
await drafts.retire(author, ctx);
|
||||
|
||||
// 3. Create with tags if missing: the draft creates it, only if it changes.
|
||||
await self.tx(async (tx) => {
|
||||
(await tx.draft(drafts, author, { tags: [SessionTag(session)] })).text = text;
|
||||
}, ctx);
|
||||
|
||||
// 4. Branch on existence: read first, outside the transaction.
|
||||
if ((await drafts.get(author)) === undefined) { … }
|
||||
```
|
||||
|
||||
### Durable's rule
|
||||
|
||||
Inside the callback, only `await tx.*`. No other objects, no network, no models, no processes, no second `self.tx`, no
|
||||
reads past `tx`. Breaking it is not an error but a hang: the object's line is held while the callback waits. Fetch
|
||||
first; decide and write inside.
|
||||
|
||||
```ts
|
||||
// Wrong: a fetch inside the transaction holds the object's line.
|
||||
await self.tx(async (tx) => { (await tx.draft(list)).items.push({ …, title: await fetchTitle(url) }); }, ctx);
|
||||
// Right: fetch, then write.
|
||||
const title = await fetchTitle(url, ctx.signal);
|
||||
await self.tx(async (tx) => { (await tx.draft(list)).items.push({ …, title }); }, ctx);
|
||||
```
|
||||
|
||||
## What a commit does
|
||||
|
||||
:::flow wide animate | The commit sequence, Durable's. Nothing is copied, frozen or revalidated; viewers get one batch per commit.
|
||||
row "Inside self.tx" accent
|
||||
box line "wait for the line" "per object"
|
||||
box run "run the callback" "drafts record ops" accent
|
||||
box prep "prepare" "Delta checks placed values"
|
||||
box write "storage batch" "deltas or a base" accent
|
||||
row "After the write" blue
|
||||
box adopt "adopt" "pointer swap"
|
||||
box local "notify locally" "once, with the ops" blue
|
||||
box wire "send" "one batch per link"
|
||||
box revoke "revoke drafts" "touching one throws"
|
||||
arrow line -> run
|
||||
arrow run -> prep
|
||||
arrow prep -> write
|
||||
arrow write -> adopt "stored"
|
||||
arrow adopt -> local
|
||||
arrow local -> wire
|
||||
arrow wire -> revoke
|
||||
note "An error or an abort before the storage write discards everything. Storage commits are serialized per host."
|
||||
:::
|
||||
|
||||
Lines are per object, never across objects; reads outside a transaction do not take the line. Subscribers on the same
|
||||
host get the new value and the commit's operations by reference. Remote viewers get the commit's operations, never the
|
||||
document again; chapter XVII shows how one commit touching several holds still arrives as one message.
|
||||
|
||||
## Tags and deleting by tag
|
||||
|
||||
Tags are typed tokens set when a document is created:
|
||||
|
||||
```ts
|
||||
export const SessionTag = defineTag<string>("studio.session"); // base code, shared by every plugin
|
||||
await self.tx(async (tx) => { (await tx.draft(drafts, author, { tags: [SessionTag(session)] })).text = text; }, ctx);
|
||||
// when the session is deleted
|
||||
await store.retireByTag(SessionTag, "S1"); // every document tagged S1
|
||||
```
|
||||
|
||||
The design names the operation `store.retireByTag(tag, value)` without saying who holds the store; session delete is
|
||||
one of the app decisions still open (#18, chapter XIX).
|
||||
|
||||
## Who may persist
|
||||
|
||||
`persist: true` is allowed only in `local` objects and `lifetime: "host"` objects: identities without a principal. A
|
||||
`remote` object's identity contains the principal key, which changes with the access path and the grant revision. Its
|
||||
stored documents would scatter across access paths and be orphaned after a reconnect. The rule is enforced at the type
|
||||
level (`self.state` in a remote factory accepts only non-persisted definitions) and at declaration ("persist needs a
|
||||
stable identity: put the documents into a local shared store"). This is the second reason for the shared store.
|
||||
|
||||
What survives a host teardown (a crash, a restart) is exactly what is persisted. In-memory documents live as long as
|
||||
their object: they survive reloads and native restarts of a live object, and go when it ends. Everything else
|
||||
(natives, derives, routines, references) is rebuilt on activation.
|
||||
|
||||
## Facet documents
|
||||
|
||||
A facet has its own identity (its plugin's name) and may hold documents too, for plugin-wide data such as
|
||||
preferences. Objects the facet serves may read and write their facet's documents, and one transaction may span the
|
||||
object's own documents and its facet's (lock order: own line, then the facet's). The facet never touches a served
|
||||
object's documents.
|
||||
|
||||
## Ownership across plugins
|
||||
|
||||
No plugin reads or writes another plugin's documents, Chord or Durable. Data crosses plugins only through state and
|
||||
methods. Data that core enforces lives in a format core owns and is handed to core through its API when the data is
|
||||
created (as with Durable's `createConversation({ init })`), never written into core's documents.
|
||||
|
||||
## Versions and migration
|
||||
|
||||
A change of shape or meaning raises `version` and adds a pure `migrate(old, from)`:
|
||||
|
||||
```ts
|
||||
const NoteList = defineState("notes.list", {
|
||||
schema: obj({ items: arr(NoteV2) }), // NoteV2 adds `pinned`
|
||||
version: 2,
|
||||
migrate: (old, from) => ({ items: old.items.map((n) => ({ ...n, pinned: false })) }),
|
||||
initial: () => ({ items: [] }),
|
||||
persist: true,
|
||||
checkpointWhen: (_v, _o, info) => info.deltasSinceBase > 64,
|
||||
});
|
||||
```
|
||||
|
||||
Documents migrate on access by the new definition: singletons during activation (hydration counts as access), keyed
|
||||
ones on their next access. The migrated base is persisted with the next commit; right after a bump, a read-only
|
||||
transaction writes it.
|
||||
|
||||
Chord does not walk values against schemas. Instead, each definition computes a **schema fingerprint** once
|
||||
(descriptions and titles ignored), and every stored record keeps the fingerprint it was written with. On access Chord
|
||||
compares them: the same version with a different fingerprint fails the object `broken` ("schema changed without a
|
||||
version bump"). So every structural change needs a bump, an additive one with a trivial `migrate`. The one exception
|
||||
to "no value checks" (R8): the result of `migrate` and of `initial()` is validated against the new schema once, when
|
||||
produced. A failed check, a throwing `migrate`, or old code meeting a newer document refuses the access, keeps the
|
||||
stored data and fails the owning object. A changed meaning without a schema change cannot be detected; that is on
|
||||
you.
|
||||
|
||||
## Storage
|
||||
|
||||
Behind the store is a `DocumentStorage`, passed as `createHost({ storage })`; memory by default. The server has one
|
||||
shared storage, each worker one for its session.
|
||||
|
||||
```ts
|
||||
interface DocumentStorage {
|
||||
commit(writes: readonly DocumentWrite[]): Promise<Seq>; // create/change/retire, atomic
|
||||
find(address: DocumentAddress): Promise<DocumentRecord | undefined>;
|
||||
load(id: string): Promise<StoredDocument | undefined>; // version, value, deltasSinceBase
|
||||
scan(query: { owner: string; definition: string }, limit: number, cursor?: string):
|
||||
Promise<{ keys: string[]; next?: string }>;
|
||||
byTag(tag: string, value: string, limit: number, cursor?: string): Promise<{ ids: string[]; next?: string }>;
|
||||
mintId(): Promise<string>;
|
||||
}
|
||||
```
|
||||
|
||||
Chord ships a memory storage and a SQLite storage (over `@earendil-works/chord/sqlite`, the driver layer that moved
|
||||
from Durable; Node and Durable Object drivers). Storages check only metadata (incarnation, version, sequence, address
|
||||
occupancy, fingerprint) and write the supplied delta or base; they never rebuild or validate content on a commit.
|
||||
Chord documents never commit together with Durable transcripts: Chord has its own storage, not an adapter over
|
||||
Durable's. Your own storage can prove itself with `registerDocumentStorageConformance(runner, name, withStorage)` from
|
||||
`@earendil-works/chord/testing`.
|
||||
|
||||
Keyed persisted documents that nobody reads, writes or watches for 10 minutes leave memory and reload on the next
|
||||
access. A storage error ends the store and closes the host; `onError` receives `{ kind: "storage_failed" }` and the
|
||||
app decides (chapter I).
|
||||
|
||||
## Kinds of state
|
||||
|
||||
| Kind | For | API |
|
||||
| --- | --- | --- |
|
||||
| document | data the object owns | `self.state`, `self.tx` |
|
||||
| derive | computed values | `self.derive` (chapter VIII) |
|
||||
| source readable | external data: Durable views, files, logs | value plus ordered changes; feeds derives |
|
||||
| paged method | large or queryable collections | a method returning pages (chat history) |
|
||||
| plugin database | full-text search, large blobs | a native opened by its owner (chapter IX) |
|
||||
|
||||
App state lives in Chord documents. Only transcript-bound data, which must fork and rewind with a conversation, lives
|
||||
in Durable.
|
||||
|
||||
:::review | Review: does an absent singleton read `undefined` or `initial()`? (Q11, open)
|
||||
Design §9 says "whether a document exists is read outside the transaction (`handle.value` / `handle.get` give
|
||||
`undefined` for an absent one)" and also "a singleton that does not exist yet reads `initial()` through its handle;
|
||||
retiring resets it to `initial()`". Review round 3 proposes: singletons always read `initial()`, only absent keyed
|
||||
documents read `undefined`, and singleton existence needs no API. This guide follows the proposal (`list.value` is
|
||||
never `undefined`); it is not decided.
|
||||
:::
|
||||
|
||||
:::note | Built so far
|
||||
Documents, transactions and storages are layer L3, a port of Durable's session store; not started. The frozen kernel
|
||||
has a working version with a JSONL storage, which the design rejected.
|
||||
:::
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
|
||||
# Holding On {#references lede="References, the object lifecycle and ctx: what a caller sees while objects start, fail, restart and move, and why nobody writes retry code."}
|
||||
|
||||
A tab shows the notes of S1 for an hour. During that hour the laptop sleeps, the worker is stopped for idleness and
|
||||
started again, the notes plugin reloads twice and the store's storage hiccups once. The panel should show the notes the
|
||||
whole time, say "reconnecting" when it cannot reach them, and recover by itself. In Chord this is what a
|
||||
**reference** does; plugin code contains no retry loops and no request ids.
|
||||
|
||||
## Three ways to hold
|
||||
|
||||
| Where | API | Released |
|
||||
| --- | --- | --- |
|
||||
| a factory | `self.get(Type, input)` | when the object ends |
|
||||
| a call | `await ctx.get(Type, input, { state? })` | when the call ends |
|
||||
| outside objects | `await host.get(Type, input, { signal, state?, parkTimeout? })` | `ref.release()` |
|
||||
|
||||
Use `self.get` for what an object needs its whole life (the view's store), `ctx.get` for what one call needs, and
|
||||
`host.get` only in UI code, tests and entry points. `{ state: false }` binds for methods only, without receiving state.
|
||||
A reference keeps its object alive while held.
|
||||
|
||||
## The object lifecycle
|
||||
|
||||
```text
|
||||
(get) → activating → live → ending → ended
|
||||
└→ failed (backoff) → activating …
|
||||
```
|
||||
|
||||
**Activating:** Chord binds the declared references and waits until they are live and hydrated, loads singleton
|
||||
documents, opens natives (one after another, in declaration order), computes derives and starts routines. The object
|
||||
goes live when all of that succeeded. Calls wait behind its **gate** until then.
|
||||
|
||||
**Ending:** routines stop, the object's calls and jobs are aborted as their rules say, natives close in reverse order,
|
||||
references are released. An object with `lifetime: "referenced"` (the default) ends when its last reference goes,
|
||||
after the type's `linger` if it has one. A `lifetime: "host"` object (only for types without a required input) lives
|
||||
as long as the host. A running, finishing or blocked job counts as a reference (chapter XI).
|
||||
|
||||
## `ref.state`
|
||||
|
||||
Every reference reports what is going on as a tagged value; listeners get each new one:
|
||||
|
||||
```ts
|
||||
type RefState =
|
||||
| { kind: "loading" } // first hold, reconnecting, restarting
|
||||
| { kind: "live" }
|
||||
| { kind: "failed"; cause: FailureCause; retryAt: number | undefined } // undefined: no automatic retry
|
||||
| { kind: "released" };
|
||||
```
|
||||
|
||||
| Situation | Calls | Values |
|
||||
| --- | --- | --- |
|
||||
| link drop | park until reconnected | readable, `loading` |
|
||||
| object loading or restarting | park until live | readable, `loading` |
|
||||
| rollout: provider ahead or installing | park until installed | readable |
|
||||
| object `failed` | reject at once with the cause | `failed`, with cause and `retryAt` |
|
||||
|
||||
Values stay readable through all of this: the panel keeps showing the last notes. Parked calls share one deadline (60
|
||||
s by default, `parkTimeout` per `get`), then fail `unavailable`. Otherwise a call ends only through its caller's
|
||||
signal; there is no per-call deadline. A call that was running on an instance that ends fails with an error and is
|
||||
not resent: Chord cannot know whether it took effect.
|
||||
|
||||
**Errors look like disconnects.** A consumer handles a failed call exactly as a dropped link: show the error, let the
|
||||
user retry. Work that must not run twice carries its own idempotency key as data (Durable's `requestId`, a job's
|
||||
`key`).
|
||||
|
||||
:::steps | The worker restarts while a tab watches the notes
|
||||
+++ Watching
|
||||
The tab holds `Notes { S1 }`. `ref.state` is `live`; `notes.notes.value` has 40 notes.
|
||||
+++ The worker goes away
|
||||
The supervisor stops worker S1 (or it crashes). The server's IPC link to it drops. The server tells the tab that its
|
||||
hold is gone; the tab's reference goes `loading`. The 40 notes stay readable.
|
||||
+++ A click meanwhile
|
||||
Ann's colleague clicks **Add**. The call **parks** on the reference; nothing is sent yet. The deadline is 60 s.
|
||||
+++ Chord resolves again
|
||||
Holds on S1 still exist, so Chord calls `route(Session).resolve({ id: "S1" })` again, with the per-address backoff.
|
||||
The app forks a new worker: a new **incarnation** of `Session({ id: "S1" })`.
|
||||
+++ Re-hold
|
||||
The server re-holds every hold on S1 over the new link. The new worker activates `NoteStore { S1 }` (its notes load
|
||||
from storage) and the views; it sends fresh snapshots. The tab's reference goes `live`.
|
||||
+++ The parked call runs
|
||||
The parked `add` is sent now and succeeds. The panel shows 41 notes. Nobody wrote recovery code.
|
||||
:::
|
||||
|
||||
## Failure and retry
|
||||
|
||||
Any broken part fails the **whole** object: a half-working object could break rules that rely on its documents
|
||||
changing together. Chord tags the cause by where it failed:
|
||||
|
||||
```ts
|
||||
type FailureCause =
|
||||
| { kind: "transient"; error: ChordError } // an open threw, a dependency was unreachable: automatic backoff
|
||||
| { kind: "broken"; error: ChordError }; // factory, derive, hydration or migrate threw: retried only by a trigger
|
||||
```
|
||||
|
||||
**Transient** causes get a backoff per address: 1 s, doubling to 60 s; concurrent gets share one attempt; success
|
||||
resets it. **Broken** causes would fail the same way again (a code bug, invalid data), so they are retried only by a
|
||||
**trigger**: a plugin reload, `ref.retry()` (a Retry button), or a readable the factory declared with
|
||||
`self.retryOn(...readables)`. Only an opener may declare its own failure broken, with `throw broken(error)`, for
|
||||
example for invalid configuration. A failed reference stays held; on success it goes `loading`, then `live`.
|
||||
|
||||
```ts
|
||||
host.serve(EnvConnection, ({ env }, self) => {
|
||||
const config = self.get(EnvConfig, {}); // a facet or registry value, outlives the attempt
|
||||
self.retryOn(config.envs); // a config change retries a broken open
|
||||
const ssh = self.open(async ({ signal }) => {
|
||||
const c = config.envs.value[env];
|
||||
if (!c) throw broken(new Error(`unknown environment ${env}`));
|
||||
return connect(c, signal);
|
||||
}, (s) => s.close());
|
||||
…
|
||||
});
|
||||
```
|
||||
|
||||
`retryOn` sources must live independently of the attempt (facet documents, registry values); a member of one of the
|
||||
object's own references is a type error, since it goes away with the attempt.
|
||||
|
||||
## Waiting for dependencies
|
||||
|
||||
An object whose references are not live yet stays `loading` and goes live the moment they are, bounded by
|
||||
`readyTimeout` (60 s). The decision for the failure case (R18) is what makes recovery automatic:
|
||||
|
||||
:::sequence animate | Dependency recovery. The view fails transient with the store's cause, keeps its reference, and is retried the moment the store goes live.
|
||||
actor tab "tab"
|
||||
actor view "Notes { S1 }" blue
|
||||
actor store "NoteStore { S1 }" accent
|
||||
tab -> view: hold
|
||||
view -> store: self.get (activation)
|
||||
store -> store: open fails: disk not mounted
|
||||
store --> view: failed (transient)
|
||||
view --> tab: failed: transient, cause inside
|
||||
note view..store: the view keeps its reference; its state is the retry trigger
|
||||
store -> store: backoff retry: open succeeds
|
||||
store --> view: live
|
||||
view -> view: retried at once (backoff cancelled)
|
||||
view --> tab: live
|
||||
:::
|
||||
|
||||
A dependency that fails, or does not become live in time, fails the dependent **transient** with the dependency's
|
||||
cause inside, never broken: the cause is the dependency's. The dependent keeps its references while failed, and Chord
|
||||
uses their state as a retry trigger. Factory authors write `self.get` and know none of this. After an object is live,
|
||||
a dependency leaving `live` does not change it: its calls that use the dependency get the dependency's error, or park
|
||||
while it loads.
|
||||
|
||||
**Cycles are rejected** (Q5). If A's activation gets B and B's gets A, neither would ever lose its last reference.
|
||||
Every object instance has a uuidv7 instance id; on one host Chord follows the waiting edges of activating objects, and
|
||||
across hosts a hold made during an activation carries the chain of instance ids. A cycle fails the activation
|
||||
`broken`, naming it ("A → B → A"), and keeps no references.
|
||||
|
||||
## No optional references
|
||||
|
||||
`get` has no `optional` flag. When a dependency's plugin may be missing, turn the dependency around: the dependent
|
||||
owns a registry and the other plugin contributes to it. Radius uses forwards from the ports plugin, but ports is
|
||||
optional, so radius owns `radius.forwards` and ports contributes its forwards; without ports the table is empty and
|
||||
radius works (chapter XIII).
|
||||
|
||||
## `ctx`
|
||||
|
||||
Every call gets a `ctx` as its last argument:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `signal` | fires when the caller cancelled or the object ended; pass it to everything you wait on |
|
||||
| `principal` | the caller (remote types only) |
|
||||
| `incarnation` | the calling host's incarnation, for calls that arrived over a link (chapter X) |
|
||||
| `get` | references released when the call ends |
|
||||
| `telemetry`, `trace(name, attributes, fn)` | the current span; a child span with a child `ctx` (chapter XVII) |
|
||||
| `jobId`, `progress` | in a job's `run` (chapter XI) |
|
||||
|
||||
```ts
|
||||
exportText: async ({}, ctx) => {
|
||||
const catalog = await ctx.get(Catalog, {}); // released when the call ends
|
||||
const title = catalog.sessions.value[session]?.title ?? session;
|
||||
const res = await fetch(renderUrl, { method: "POST", body: render(title, list.value), signal: ctx.signal });
|
||||
return await res.text();
|
||||
},
|
||||
```
|
||||
|
||||
There are no code versions and no request ids in `ctx`. Durable's `Context` is the same module (`chord/context`):
|
||||
`ctx` extends it, so passing `ctx` into pi-durable needs no conversion. Code outside objects gets a `ctx` from
|
||||
`host.root(fn, { principal?, signal? })`; inside Durable tools it uses `chord(fn, context)` (chapter XIV).
|
||||
|
||||
:::note | Built so far
|
||||
References, `ctx` and the lifecycle are layers L4 and L5. `ChordError` and `FailureCause` are specified in the L1
|
||||
draft (`layer-1.md`), with codes `invalid`, `contract_mismatch`, `forbidden`, `gone`, `not_ready`, `released`,
|
||||
`too_large`, `unavailable` and `aborted`, and a finer `reason` such as `invalid/args`.
|
||||
:::
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
|
||||
# Taking Access Away {#revocation lede="Revocation: how removing a guest ends their objects on every host at once, and why reconnecting does not bring access back."}
|
||||
|
||||
The owner removes Ann from S1. Her tab still has a WebSocket open and a `Notes { S1 }` view on worker S1. Both must end
|
||||
now, on the server and on the worker, and her tab's automatic reconnect must not restore anything. Chord provides the
|
||||
mechanism; the app decides who is blocked and remembers it.
|
||||
|
||||
## Labels, not predicates
|
||||
|
||||
The app declares, once, which **revocation labels** each principal carries. Revoking means revoking one label;
|
||||
everything carrying it is hit.
|
||||
|
||||
```ts
|
||||
// studio/src/principal.ts
|
||||
export type Revocation = { grant: string } | { device: string } | { session: string };
|
||||
|
||||
export const revokedBy = (p: P): Revocation[] =>
|
||||
p.kind === "guest" ? [{ grant: p.grant }, { session: p.session }]
|
||||
: p.kind === "owner" && p.access === "device" && p.device ? [{ device: p.device }]
|
||||
: [];
|
||||
|
||||
const server = createHost<P, Revocation>({ address: Server(), principal: { kind: "server" }, revokedBy });
|
||||
// every worker is created with the same revokedBy
|
||||
```
|
||||
|
||||
| Principal | Labels |
|
||||
| --- | --- |
|
||||
| guest Ann (grant g1) in S1 | `{ grant: "g1" }`, `{ session: "S1" }` |
|
||||
| guest Bob (grant g2) in S1 | `{ grant: "g2" }`, `{ session: "S1" }` |
|
||||
| owner on the paired phone | `{ device: "phone" }` |
|
||||
| owner, local | none |
|
||||
|
||||
| Call | Hits |
|
||||
| --- | --- |
|
||||
| `server.revoke({ grant: "g1" })`: remove Ann | Ann |
|
||||
| `server.revoke({ session: "S1" })`: archive S1 | Ann and Bob |
|
||||
| `server.revoke({ device: "phone" })`: sign out the phone | the phone |
|
||||
|
||||
A label is plain data because revocation is an event that must travel from the server to the workers; a predicate
|
||||
function could not be sent.
|
||||
|
||||
## What `revoke` does
|
||||
|
||||
`host.revoke(r)` ends every object and closes every link whose principal `p` has `r` in `revokedBy(p)` (compared by
|
||||
canonical JSON; never the host's own principal), and passes `r` to its children, which apply their own `revokedBy`.
|
||||
|
||||
:::sequence animate | Removing guest Ann. The label travels down; her objects end on every host; her reconnect is refused by the app's own record.
|
||||
actor owner "owner tab"
|
||||
actor server "server" accent
|
||||
actor worker "worker S1" blue
|
||||
actor ann "Ann's tab" "guest g1" muted
|
||||
owner -> server: Sharing.remove({ grant: "g1" })
|
||||
server -> server: app: mark g1 blocked (document)
|
||||
server => server: revoke({ grant: "g1" })
|
||||
server -> ann: close link
|
||||
server => worker: revoke { grant: "g1" }
|
||||
worker -> worker: end Notes { S1 } as g1
|
||||
ann -> server: reconnect
|
||||
server -> server: authenticate: g1 blocked
|
||||
server --> ann: refused (4401)
|
||||
:::
|
||||
|
||||
On worker S1, for `revoke({ grant: "g1" })`:
|
||||
|
||||
| Object | Principal | Hit |
|
||||
| --- | --- | --- |
|
||||
| `Notes { S1 }` | owner, local | no |
|
||||
| `Notes { S1 }` | guest g1 | **yes**: ends |
|
||||
| `Notes { S1 }` | guest g2 | no |
|
||||
| `NoteStore { S1 }` | none (local) | no |
|
||||
|
||||
Ending Ann's view releases its reference to the store, which keeps serving the others.
|
||||
|
||||
## Chord persists nothing here
|
||||
|
||||
`revoke` cuts live access only. Lasting denial lives in the app's data (the sharing plugin's guest records), which
|
||||
`authenticate` checks on every new connection. After a server restart nothing live exists to revoke; every client
|
||||
authenticates again and blocked grants are refused there.
|
||||
|
||||
**No re-stamping.** A changed grant (Ann moves from read to write) is a revocation of the old grant. Her link closes,
|
||||
her tab reconnects and authenticates with the new grant revision, which gives a new principal key, and her references
|
||||
re-hold by themselves, now as an editor. Reconnecting never restores revoked authority.
|
||||
|
||||
**Revocation beats jobs** (R11). Normally a running job keeps its object alive after the last reference is released
|
||||
(a closed tab does not abort an import). Revoking a principal ends its objects at once regardless; their jobs get the
|
||||
abort signal and the normal close timeout.
|
||||
|
||||
## Work in shared stores
|
||||
|
||||
Revocation ends objects whose principal carries the label. A shared store has no principal, so work it runs *on
|
||||
behalf of* someone (a question waiting for Ann's answer, a shell command a guest started) is not ended by `revoke`.
|
||||
Such a store keeps the grant as data with the work and reconciles from the grant owner's live state with a routine
|
||||
(chapter X):
|
||||
|
||||
```ts
|
||||
// in a shared store that runs work for guests
|
||||
const sharing = self.get(Grants, {}); // the sharing plugin's live grants
|
||||
self.routines.on(sharing.live, async (live, ctx) => {
|
||||
for (const run of shell.running.value)
|
||||
if (run.args.grant && !live[run.args.grant]) shell.abort(run.id); // idempotent
|
||||
});
|
||||
```
|
||||
|
||||
There is no revocation hook: a hook can be missed (a crash between revoke and hook), a routine over current state
|
||||
cannot. The window between the revocation and the routine's run is accepted (R10); a step started in it still runs.
|
||||
|
||||
## Who does what
|
||||
|
||||
| Chord | The app |
|
||||
| --- | --- |
|
||||
| stamps principals on links | what a grant is; share links, cookies, `authenticate` |
|
||||
| keeps one object per principal key | defines `Revocation` and `revokedBy` |
|
||||
| on `revoke(r)`: ends matching objects and links, passes `r` down | decides when to revoke; stores who is blocked |
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
|
||||
# Computed Values {#derives lede="Derives, the one footgun that makes them expensive, mapShared, and presence: who is looking."}
|
||||
|
||||
The reader's notes without authors, a note count, the number of people viewing: values computed from other values.
|
||||
Storing copies would go stale; recomputing them on every read would be wasteful and could not be watched. A
|
||||
**derive** is computed once per change and sent to viewers as a difference.
|
||||
|
||||
## `self.derive`
|
||||
|
||||
```ts
|
||||
const count = self.derive("count", [list], ([l]) => l.items.length);
|
||||
const summary = self.derive("summary", [list, store.pinned], ([l, pinned], previous) => ({
|
||||
total: l.items.length,
|
||||
pinned: pinned.length,
|
||||
}));
|
||||
```
|
||||
|
||||
`self.derive(name, sources, (values, previous) => value)`. Sources can be documents, readables, other derives and
|
||||
state members of references. The value is recomputed when a source changes and diffed into operations for the wire.
|
||||
Derived values are adopted before ordinary observers run and share their source commit's wire batch, so a viewer
|
||||
never sees a note list and a count from different commits.
|
||||
|
||||
`previous` is the derive's last value, kept across factory re-runs (reloads) by name. It is a cache, empty after a
|
||||
host teardown, so a derive must be correct without it. A derive that throws fails its whole owner `broken`.
|
||||
|
||||
## How the difference is found
|
||||
|
||||
Chord compares the new value with the previous one using Delta's `diffRevisions`. Parts that are the **same objects**
|
||||
as last time cost one `===` each. Parts that are new objects must be compared by content.
|
||||
|
||||
**The footgun.** A derive that builds new objects for unchanged data makes every commit cost the size of the whole
|
||||
value, per view. The reader list from chapter IV, written naively:
|
||||
|
||||
```ts
|
||||
self.derive("public", [list], ([l]) => ({ items: l.items.map((n) => ({ id: n.id, text: n.text, at: n.at })) }));
|
||||
// one note added to 2,000: 2,001 new objects, every one compared by content
|
||||
```
|
||||
|
||||
**`mapShared(f)`** keeps one result per source element: `f` runs only for new or changed elements, unchanged ones
|
||||
return their previous result object.
|
||||
|
||||
```ts
|
||||
import { mapShared } from "@earendil-works/chord/delta";
|
||||
|
||||
const stripAuthor = mapShared((n: Note): PublicNote => ({ id: n.id, text: n.text, at: n.at }));
|
||||
self.derive("public", [list], ([l]) => ({ items: l.items.map(stripAuthor) }));
|
||||
// one note added: one new object, 2,000 identical references; the diff emits one insert
|
||||
```
|
||||
|
||||
:::review | Review: mapShared is planned, not available
|
||||
Design §12 introduces `mapShared` as coming "from `@earendil-works/chord/delta`", but nothing in `packages/chord/src`
|
||||
defines it yet, and Delta does not yet treat bytes as atomic leaves in `diffRevisions`. Design §28 lists both as the
|
||||
step "Delta in pi", before the layered rebuild. Until then, keep a `Map` from source element to result yourself, which
|
||||
is what `mapShared` will do.
|
||||
:::
|
||||
|
||||
## Cookbook
|
||||
|
||||
| Case | How |
|
||||
| --- | --- |
|
||||
| select a field, look up by key, a small aggregate | compute it; the value is small |
|
||||
| filter or map a list | `mapShared` over the elements |
|
||||
| redaction | preferably separate documents per visibility; otherwise `mapShared`, computed once in the shared store, never per view |
|
||||
| join two sources | `mapShared` plus the looked-up source's revision |
|
||||
| a window over a large list | take a section of the source elements without rebuilding them; cache per-element measurements by identity; keep the window's start stable so changes arrive as appends |
|
||||
| a large view combined from several sources | re-path the sources' operations, as Durable's `ConversationView` does; application code never interprets ops |
|
||||
|
||||
**Container width** (R24): structural sharing is O(change) along a path, not across a container's width. Changing one
|
||||
item of a flat array copies the array's slots, and `mapShared` visits every element once (one identity comparison
|
||||
each). Large collections are keyed documents or bounded windows. A benchmark of long arrays is to set the documented
|
||||
limit.
|
||||
|
||||
## Readables that are not documents
|
||||
|
||||
Three small readables round out the toolkit:
|
||||
|
||||
- `constant(value)`: never changes. Start data uses it (chapter IX).
|
||||
- `self.writable(initial)`: a host-local readable of any in-process value (functions included), compared by identity,
|
||||
with `set`. It never goes on the wire; it exists for registry contributions that change (chapter XIII).
|
||||
- Source readables from elsewhere: a Durable view or document watch is already a Chord readable, because Durable and
|
||||
Chord v2 share the readable type (`chord/src/state.ts`), so it can feed a derive or be exposed as state directly.
|
||||
|
||||
:::review | Review: exposing a writable (Q14, open)
|
||||
A `writable` may hold functions; nothing yet stops a factory from returning one as a state member, which would reach
|
||||
the wire encoder. Review round 3 proposes that only members with a JSON schema in the contract are exposable, checked
|
||||
when the object is served. Undecided.
|
||||
:::
|
||||
|
||||
## Presence: who is looking
|
||||
|
||||
`host.references(Type)` is a readable of who holds references to objects of a type on this host: input, principal and
|
||||
client. Client holds carry an opaque origin id, so two tabs are two clients.
|
||||
|
||||
```ts
|
||||
host.serve(NoteStore, ({ session }, self) => {
|
||||
const viewers = self.derive("viewers", [host.references(Notes)], ([refs]) =>
|
||||
refs.filter((r) => r.input.session === session).length);
|
||||
return { …, viewers };
|
||||
});
|
||||
```
|
||||
|
||||
A link drop is a departure: accurate and simple. Smoothing (a phone switching networks makes the count flicker) is the
|
||||
UI's job, for example a debounced count. Counts per person are derived by the app from principals and origins. There
|
||||
is no consumer-side linger: a closed tab closes its link and keeps nothing. The provider's `linger` keeps a
|
||||
just-closed object warm, and that also keeps the worker alive through `host.activity`.
|
||||
|
||||
**"Leaving" is never derived from an unmount.** A UI reload unmounts and remounts components; a component unmounted by
|
||||
a reload has not left. Leaving is explicit navigation, or presence (a dropped reference). This replaces p2's presence,
|
||||
viewer and login-abandon leases with one mechanism.
|
||||
|
||||
:::note | Built so far
|
||||
Readables and derives are layer L2 (a readable is today's `replicatedState`, derives reuse today's derived cells over
|
||||
it). Not started in the rebuild.
|
||||
:::
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
|
||||
# Things the OS Gives You {#natives lede="Start data, host-lifetime objects and natives: how a plugin opens a file watcher or a socket so that it is closed exactly once, also across reloads."}
|
||||
|
||||
Studio's notes get an inbox: a text file dropped into the session's `inbox/` directory becomes a note. That needs a
|
||||
file watcher, a **native**: an OS-level value (a file handle, a socket, a process, a database connection) that cannot
|
||||
be JSON, cannot be copied, must be opened, and must be closed exactly once. It also needs the session's data
|
||||
directory, which the plugin does not know. This chapter covers both.
|
||||
|
||||
## Start data is an object
|
||||
|
||||
A worker knows things before any plugin loads: its session id, the data directory, init data from the server's start
|
||||
message. There is no special mechanism for them. Start data is an ordinary `local` object with `lifetime: "host"` and
|
||||
constant state, served by base code's core facet:
|
||||
|
||||
```ts
|
||||
// studio/src/core/contract.ts
|
||||
export const Init = obj({ id: str(), dataDir: str(), cwd: str(), env: opt(str()) });
|
||||
export const SessionInfo = local("studio.session-info", {
|
||||
version: 1,
|
||||
input: obj({}),
|
||||
members: { init: Init },
|
||||
});
|
||||
|
||||
// studio/src/core/worker.ts
|
||||
export const coreWorkerFacet = (init: Static<typeof Init>) => defineFacet((host) => {
|
||||
host.serve(SessionInfo, () => ({ init: constant(init) }), { lifetime: "host" });
|
||||
// … the Harness and other core objects (chapter XIV)
|
||||
});
|
||||
```
|
||||
|
||||
Any plugin on the worker declares a reference and reads it **after activation**: in an opener, a derive, a routine or
|
||||
a method, never synchronously in the factory.
|
||||
|
||||
```ts
|
||||
const info = self.get(SessionInfo, {});
|
||||
// later: info.init.value.id, info.init.value.dataDir
|
||||
```
|
||||
|
||||
Paths are derived by the plugin from generic start data (`join(dataDir, id, "notes-inbox")`), never provided per
|
||||
plugin. Platform bindings work the same way: on a Durable Object, a core object's `self.open` returns the binding
|
||||
(`state`, `env`) and its close does nothing.
|
||||
|
||||
## `self.open`
|
||||
|
||||
```ts
|
||||
const watcher = self.open(
|
||||
async ({ signal }) => watchDir(path, { signal }), // open: during activation
|
||||
(w, reason) => w.close(), // close: when the object ends or restarts
|
||||
{ closeTimeout: 10_000 }, // optional; host default 30 s
|
||||
);
|
||||
watcher.value; // the watcher; reading it before activation throws not_ready
|
||||
```
|
||||
|
||||
`self.open` returns a handle with `.value` (type `Native<T>`), not a proxy. The rules:
|
||||
|
||||
- **Natives live only in objects.** Not at module level, not in the facet. A module-level watcher would survive a
|
||||
reload with nobody to close it.
|
||||
- **Order:** natives open one after another in declaration order (a later opener may read an earlier handle's
|
||||
`.value`) and close in reverse. The object goes live after all opens succeeded.
|
||||
- **The value never changes during an attempt.** No replacement, no versions, no stale-handle logic. To change a live
|
||||
native, the object restarts.
|
||||
- **A failed open** makes the object `failed`: transient with backoff by default, broken if the opener says so
|
||||
(`throw broken(error)`). A partly failed activation closes the natives already open, with reason `end`; the next
|
||||
attempt opens all of them anew.
|
||||
- **`close` gets a reason:** `{ kind: "reload" }` (the plugin reloads and this native object restarts), `{ kind:
|
||||
"stop" }` (the host shuts down) or `{ kind: "end" }` (the last reference ended, or the type was removed). Each
|
||||
`close` is bounded by `closeTimeout`, then reported as a possible leak while the rest continues. A throwing `close`
|
||||
does not stop the others; `host.close()` collects the errors, background ends report to `onError`.
|
||||
- **Behaviour around a native goes into routines** (chapter X). Listeners on natives outside routines are forbidden.
|
||||
|
||||
An object with at least one `self.open` is a **native object**; one without is a **pure object**. The difference
|
||||
decides what a reload does (chapter XV): pure objects are swapped in place, native objects restart.
|
||||
|
||||
## The inbox
|
||||
|
||||
The inbox must run as long as the worker, whether anyone looks at the notes or not. So it is a `lifetime: "host"`
|
||||
object. The facet cannot `self.get` a type it serves, and the inbox has no meaningful input, so it is an object
|
||||
without input that reads its session from the start data:
|
||||
|
||||
```ts
|
||||
// notes/contract.ts
|
||||
export const NoteInbox = local("notes.inbox", { version: 1, input: obj({}), members: {} });
|
||||
|
||||
// notes/worker.ts
|
||||
host.serve(NoteInbox, (_input, self) => {
|
||||
const info = self.get(SessionInfo, {});
|
||||
const watcher = self.open(async ({ signal }) => {
|
||||
const { id, dataDir } = info.init.value; // read in the opener: after activation
|
||||
return watchDir(join(dataDir, id, "notes-inbox"), { signal });
|
||||
}, (w) => w.close());
|
||||
|
||||
self.routines.loop("import", async (ctx) => { // chapter X
|
||||
const store = await ctx.get(NoteStore, { session: info.init.value.id });
|
||||
for await (const file of watcher.value.files(ctx.signal)) {
|
||||
await store.add({ text: await readFile(file, "utf8"), by: "inbox" }, ctx);
|
||||
await rm(file);
|
||||
}
|
||||
});
|
||||
return {};
|
||||
}, { lifetime: "host" });
|
||||
```
|
||||
|
||||
Note `ctx.get(NoteStore, …)` in the routine rather than `self.get` in the factory. The factory runs before the
|
||||
session id is known (start data is read after activation), so a host-lifetime object without input gets
|
||||
session-scoped objects with `ctx.get` in a routine or method (R22). A `get` whose input is computed after activation is
|
||||
to be added only if a real case needs it.
|
||||
|
||||
## Natives with work in flight
|
||||
|
||||
A restart is sequential (R16): close the old native, then open the new one. When the old one still has work running,
|
||||
use one of two patterns:
|
||||
|
||||
- **Drain in the background.** `close` detaches the old resource and returns at once; it disposes of it when its work
|
||||
finishes, or after `closeTimeout` at most. Chord opens the new one immediately. Code mode's sandbox does this: a
|
||||
script running during a reload finishes on the old sandbox (for up to 30 s, Q7; a longer one is aborted and its turn
|
||||
fails), new scripts start on the new one.
|
||||
- **Swap through a registry.** The resource is a registry contribution (chapter XIII). Readers always look up the
|
||||
current one at use; a reader that must wait for one uses a cancellable "wait until present" with the park deadline.
|
||||
|
||||
Chord aborts only the object's own calls and jobs on close. Other work that uses the native (Durable tool calls on a
|
||||
sandbox) is the owner's business: `close` may drain instead of tearing down. What an old native does after its owner
|
||||
restarts is also the owner's `close`: a model runtime keeps working until collected, a closed SSH connection fails its
|
||||
in-flight `exec`.
|
||||
|
||||
## Natives that follow a relocation
|
||||
|
||||
A session can move to another directory. The inbox watcher must follow. Restarting the object on every move would be
|
||||
heavy-handed; instead the native is a stable manager opened once, and a routine on the session location switches its
|
||||
internal resource and bumps a generation, so late events from the old location are dropped:
|
||||
|
||||
```ts
|
||||
const watcher = self.open(async ({ signal }) => new SwitchableWatcher({ signal }), (w) => w.close());
|
||||
self.routines.on(location.cwd, (cwd) => watcher.value.switchTo(join(cwd, ".notes-inbox")));
|
||||
```
|
||||
|
||||
## Call-scoped natives
|
||||
|
||||
A native that lives exactly as long as one call (a temporary SSH session for a directory listing, a file handle for a
|
||||
download) is opened and closed inside the method with `try/finally`. There is no `ctx.open`:
|
||||
|
||||
```ts
|
||||
list: async ({ path }, ctx) => {
|
||||
const session = await ssh.open(config, ctx.signal);
|
||||
try { return await session.readDir(path, ctx.signal); }
|
||||
finally { session.close(); }
|
||||
},
|
||||
```
|
||||
|
||||
## Core vs. plugin
|
||||
|
||||
`SessionInfo` comes from base code, `NoteInbox` from a plugin; both use the same primitives. Where code lives decides
|
||||
which it is. **Base code** (the app's `src/`, imported statically by the start code under a fixed name such as
|
||||
`@studio/core`) is always there, not switchable, and a change restarts the hosts. **A plugin** (its own directory,
|
||||
found by listing, named by its `package.json`) is switchable and reloads. Core is what the app always needs and
|
||||
everything builds on: the harness, the session list, access, the session location. Optional features are plugins;
|
||||
code mode is a plugin.
|
||||
|
||||
:::note | Built so far
|
||||
Natives and host-lifetime objects are layer L4. `constant` is decided (R23), not built.
|
||||
:::
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
|
||||
# Standing Behaviour {#routines lede="Routines, reconciling instead of notifying, and how two owners keep a rule between them without a shared transaction."}
|
||||
|
||||
Notes older than 30 days should go every hour. Inbox files should be imported as they arrive. Notes of an archived
|
||||
session should become read-only. Each of these is behaviour that stands, not a call someone makes. Written as bare
|
||||
`setInterval`s and event listeners, it would keep running old code after a reload and touch state from nowhere. Chord
|
||||
has one declared form for it: the **routine**.
|
||||
|
||||
## Four kinds
|
||||
|
||||
```ts
|
||||
self.routines.every("cleanup", HOUR, (ctx) => dropOlderThan(30 * DAY, ctx)); // due time survives reloads
|
||||
self.routines.on(settings, async (value, ctx) => applyLimit(value, ctx)); // a readable or event source
|
||||
self.routines.loop("import", async (ctx) => { // runs until the object ends
|
||||
while (!ctx.signal.aborted) await importNext(ctx);
|
||||
});
|
||||
self.routines.custom("ticker", ({ signal, run }) => { // anything else
|
||||
const timer = setInterval(() => run(tick), 1000);
|
||||
return () => clearInterval(timer); // cleanup undoes the setup
|
||||
});
|
||||
```
|
||||
|
||||
Routines start when the object goes live, stop when it ends, and restart with the new code on every reload of their
|
||||
plugin. Each run gets its own `ctx`; errors are caught and reported, never uncaught. Routines are named (`on`
|
||||
optionally by its source) so Chord recognizes them across reloads; that is how `every` keeps its due time.
|
||||
|
||||
## Serialized per routine
|
||||
|
||||
The new instance of a routine starts only after the old handler in flight has settled; only that routine waits, for at
|
||||
most the routine handover timeout (10 s), after which it starts anyway and the stuck handler is reported.
|
||||
|
||||
The reason, as a trace: a supervisor routine reconciles pending worker restarts. The old handler is midway through
|
||||
`restart(W1)` when the plugin reloads. Without serialization the new routine starts at once, sees W1 still pending, and
|
||||
restarts it a second time.
|
||||
|
||||
## Reconcile, don't track
|
||||
|
||||
A routine **reconciles from the current value**: it makes the world match what it sees now. It writes no "in
|
||||
progress" flags, and it starts a job for long work (chapter XI). A handler that only cares about the latest value
|
||||
compares with the current value itself; there is no `overlap` option. Subscribing to the object's own documents is
|
||||
gapless: every commit is seen.
|
||||
|
||||
No bare timers or callbacks that touch state outside routines, jobs or calls. That is what lets Chord stop all of an
|
||||
object's behaviour when it ends or reloads.
|
||||
|
||||
## Rules between two owners
|
||||
|
||||
Every thing has exactly one owner, the smallest object it belongs to. Some rules span two: "notes of an archived
|
||||
session are read-only" involves the catalog (which knows archiving) and `NoteStore` (which holds notes). Two tempting
|
||||
designs are wrong:
|
||||
|
||||
- **A cross-owner transaction** (archive and lock the notes atomically) would make the catalog know every plugin and
|
||||
couple their storage.
|
||||
- **A notification** (`catalog` calls `notes.archive(session)`) can be lost: the worker is not running, the link drops,
|
||||
the process crashes between the archive and the call.
|
||||
|
||||
The design's rule: **the owner of the dependent data enforces the rule** by reconciling from the other owner's state
|
||||
with an idempotent routine.
|
||||
|
||||
```ts
|
||||
// NoteStore: an archived session's notes are read-only
|
||||
const flags = self.state(NoteFlags); // { archived: boolean }, NoteStore's own document
|
||||
const catalog = self.get(Catalog, {}); // the server's session list: another owner
|
||||
self.routines.on(catalog.sessions, async (sessions, ctx) => {
|
||||
const archived = sessions[session]?.archived ?? false;
|
||||
if (flags.value.archived !== archived) await flags.change((d) => { d.archived = archived; }, ctx);
|
||||
});
|
||||
// add and remove refuse while flags.value.archived
|
||||
```
|
||||
|
||||
After a crash the routine runs again with the current value. The catalog does not know the notes plugin exists. The
|
||||
same pattern closes a forward of an archived session in the ports plugin, aborts a job whose grant was revoked
|
||||
(chapter VII), and keeps any invariant between plugins.
|
||||
|
||||
## Reading without starting a worker
|
||||
|
||||
The session list in the sidebar shows each session's note count. Getting `NoteStore { S1 }` for every session would
|
||||
start every worker. The answer is plain publishing between two objects of one plugin, no Chord mechanism: the worker
|
||||
side reports a summary to a server-side object by a method call from a routine, and clients read the server object.
|
||||
|
||||
```ts
|
||||
// notes/contract.ts
|
||||
export const NoteCounts = remote("notes.counts", {
|
||||
version: 1, input: obj({}), placement: () => Server(),
|
||||
variants: {
|
||||
owner: { state: { counts: map(int()) } },
|
||||
worker: { methods: { report: method(obj({ session: str(), count: int() })) } },
|
||||
},
|
||||
});
|
||||
|
||||
// notes/server.ts
|
||||
const Counts = defineState("notes.counts", {
|
||||
schema: obj({ bySession: map(obj({ count: int(), incarnation: str() })) }),
|
||||
version: 1, initial: () => ({ bySession: {} }), persist: true, // survives a server restart
|
||||
checkpointWhen: (_v, _o, info) => info.deltasSinceBase > 64,
|
||||
});
|
||||
|
||||
host.serve(NoteCountStore, (_input, self) => { // local, lifetime "host"
|
||||
const counts = self.state(Counts);
|
||||
return {
|
||||
counts: self.derive("counts", [counts], ([c]) => mapValues(c.bySession, (e) => e.count)),
|
||||
report: ({ session, count, incarnation }, ctx) =>
|
||||
counts.change((d) => {
|
||||
const last = d.bySession[session];
|
||||
if (last && last.incarnation > incarnation) return; // uuidv7: an older worker, ignore
|
||||
d.bySession[session] = { count, incarnation };
|
||||
}, ctx),
|
||||
};
|
||||
}, { lifetime: "host" });
|
||||
|
||||
host.serve(NoteCounts, (_input, principal, self) => {
|
||||
const store = self.get(NoteCountStore, {});
|
||||
if (principal.kind === "worker") return {
|
||||
variant: "worker",
|
||||
report: ({ session, count }, ctx) => {
|
||||
if (session !== principal.session) throw forbidden(); // a worker reports only itself
|
||||
return store.report({ session, count, incarnation: ctx.incarnation }, ctx);
|
||||
},
|
||||
};
|
||||
return { variant: "owner", counts: store.counts };
|
||||
});
|
||||
|
||||
// notes/worker.ts, inside NoteStore
|
||||
const counts = self.get(NoteCounts, {}); // a reference to the server
|
||||
self.routines.on(count, (n, ctx) => inVariant(counts, "worker") && counts.report({ session, count: n }, ctx));
|
||||
```
|
||||
|
||||
The server object keeps the latest value per session (in a document if it must survive a server restart) and
|
||||
**ignores reports from an older worker incarnation** (incarnations are uuidv7, so they order by start time). A worker that was replaced may still deliver a late report; the
|
||||
fence is the caller's incarnation, which Chord stamps on every call from the link handshake (R20): `ctx.incarnation`,
|
||||
next to `ctx.principal`, not part of the principal or its key, never sent as data, so it cannot be forged. Session
|
||||
status, subagent lists, unread marks and artifact lists in p2 all follow this pattern. The store is local and has no
|
||||
caller incarnation of its own, so the view passes it on as data.
|
||||
|
||||
:::review | Review: `ctx.incarnation` across a relay (Q13, open)
|
||||
A worker's call to the server arrives directly, so `ctx.incarnation` is the worker's. But a call relayed through the
|
||||
server (a tab's call to a worker) carries the server's incarnation on the worker's side, because the worker's
|
||||
handshake is with the server. Review round 3 proposes that relays stamp the origin host's incarnation, as they stamp
|
||||
the principal. Undecided; the pattern above only needs the direct case.
|
||||
:::
|
||||
|
||||
:::note | Built so far
|
||||
Routines are layer L8. The design treats `every` as one of four forms over one runner; review round 3 proposed cutting
|
||||
to `on` and `loop` only (C4), not decided.
|
||||
:::
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
|
||||
# Work That Outlives the Call {#jobs lede="Jobs: owned work started by a method that keeps running after its caller leaves, with cancel, dedupe and a recorded outcome."}
|
||||
|
||||
Importing a 50 MB text file into notes takes a minute. The tab that started it may close; the import must go on, be
|
||||
cancellable, not run twice when the tab retries after a link blip, and its result must be recorded. A method cannot do
|
||||
this (it dies with its caller), a routine is standing behaviour rather than a request, and a Durable task is for work
|
||||
that must resume after a crash. Chord's form for it is the **job**, built in pi-durable's sandwich: **intent** (a
|
||||
transaction), **effect** (anything), **outcome** (a transaction in the current code).
|
||||
|
||||
## Defining and running a job
|
||||
|
||||
```ts
|
||||
// notes/worker.ts
|
||||
const ImportJob = defineJob("notes.import", {
|
||||
version: 1,
|
||||
args: obj({ path: str() }),
|
||||
progress: obj({ lines: int() }),
|
||||
result: obj({ added: int() }),
|
||||
concurrency: 1, // one import at a time per NoteStore
|
||||
});
|
||||
|
||||
const ImportResults = defineState("notes.import-result", {
|
||||
schema: obj({ outcome: union([obj({ added: int() }), obj({ error: str() })]) }),
|
||||
version: 1, initial: () => ({ outcome: { added: 0 } }), keyed: true,
|
||||
checkpointWhen: () => true,
|
||||
});
|
||||
|
||||
host.serve(NoteStore, ({ session }, self) => {
|
||||
const list = self.state(NoteList);
|
||||
const results = self.state(ImportResults);
|
||||
const imports = self.job(ImportJob, {
|
||||
run: async ({ path }, ctx) => { // 2. effect
|
||||
let lines = 0;
|
||||
for await (const line of readLines(path, ctx.signal)) {
|
||||
await addNote({ id: `${ctx.jobId}:${lines}`, text: line, by: "import" }, ctx); // keyed by job id
|
||||
ctx.progress({ lines: ++lines });
|
||||
}
|
||||
return { added: lines };
|
||||
},
|
||||
finish: async (outcome, tx) => { // 3. outcome, current code
|
||||
(await tx.draft(results, outcome.id)).outcome =
|
||||
outcome.kind === "done" ? outcome.result : { error: outcome.error };
|
||||
},
|
||||
});
|
||||
return {
|
||||
…,
|
||||
imports: imports.running, // live list of runs
|
||||
startImport: ({ path, requestId }, ctx) =>
|
||||
imports.start({ path }, ctx, { key: requestId }), // 1. intent (refined below)
|
||||
abortImport: ({ id }) => imports.abort(id),
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
`defineJob(name, { version, args, progress, result, concurrency?, persist?, accepts? })`; `progress` and the outcome
|
||||
are typed by the schemas.
|
||||
|
||||
:::sequence animate | The job sandwich. The intent commits before anything runs; the effect runs outside any transaction; the outcome commits in the current code.
|
||||
actor tab "tab"
|
||||
actor store "NoteStore { S1 }" accent
|
||||
actor run "run" "effect" blue
|
||||
tab -> store: startImport({ path, requestId })
|
||||
store => store: 1. intent: busy check, dedupe by key, commit
|
||||
store --> tab: { id }
|
||||
store -> run: start after the commit
|
||||
run -> run: read lines, add notes (keyed by jobId)
|
||||
run --> store: progress { lines } (throttled)
|
||||
note tab..store: the tab may close here: the job counts as a reference
|
||||
run --> store: settles: { kind: "done", result }
|
||||
store => store: 3. record outcome, finish(outcome, tx), ack
|
||||
:::
|
||||
|
||||
## The three steps
|
||||
|
||||
**1. Intent.** `job.start(args, ctx, { key? })` is shorthand for `tx.start(job, args, { key? })` inside a
|
||||
transaction, which may contain other writes. The busy check (`concurrency`) and deduplication (the same key returns the
|
||||
existing run) are atomic and in the same commit; `run` starts only after the commit. Run ids are uuidv7.
|
||||
|
||||
**2. Effect.** `run(args, ctx)` may do anything: processes, network, other objects. It writes no document itself; it
|
||||
returns the outcome. External writes carry a dedupe key derived from `ctx.jobId` (above: note ids), so an interrupted
|
||||
attempt cannot apply them twice. On a reload `run` keeps its old code.
|
||||
|
||||
**3. Outcome.** When `run` settles, Chord records the outcome in its own record, then calls the **current** code's
|
||||
`finish(outcome, tx)` and acknowledges the run in the same commit. The outcome is tagged:
|
||||
|
||||
```ts
|
||||
type Outcome<R> = { id: string } & ({ kind: "done"; result: R } | { kind: "failed"; error: "threw" | "aborted" | "lost" });
|
||||
```
|
||||
|
||||
A throwing `finish` changes nothing and is retried with a backoff (1 s to 60 s), and after the next reload. `finish`
|
||||
is optional and follows Durable's transaction rule.
|
||||
|
||||
## Dedupe ends with the run
|
||||
|
||||
Deduplication covers **unsettled runs only** (Q9). Once `finish` has committed, Chord drops the run's record and key
|
||||
and releases its waiters' references; it keeps nothing beyond a run's lifetime. Dedupe after completion is the
|
||||
owner's. Studio records which run a request started, in the same commit as the intent, and `finish` records the
|
||||
result by run id (`ImportResults` above):
|
||||
|
||||
```ts
|
||||
const Requests = defineState("notes.import-request", {
|
||||
schema: obj({ run: str() }), version: 1, initial: () => ({ run: "" }), keyed: true, persist: true,
|
||||
checkpointWhen: () => true,
|
||||
});
|
||||
|
||||
startImport: async ({ path, requestId }, ctx) => {
|
||||
const prior = await requests.get(requestId); // read outside the transaction (R13)
|
||||
if (prior) return { id: prior.run }; // started before, maybe finished long ago
|
||||
return self.tx(async (tx) => {
|
||||
const run = await tx.start(imports, { path }, { key: requestId }); // dedupes a concurrent retry
|
||||
(await tx.draft(requests, requestId, { tags: [SessionTag(session)] })).run = run.id;
|
||||
return { id: run.id };
|
||||
}, ctx);
|
||||
},
|
||||
```
|
||||
|
||||
Two concurrent retries both miss `prior`, but the key makes `tx.start` return the same unsettled run. The client
|
||||
watches `imports` for progress and reads the result by run id once it is gone from `running`.
|
||||
|
||||
Why Chord does not keep completed runs itself: either 100,000 keyed imports would stay in memory forever, or a
|
||||
retention limit would let a retried key rerun its effect. The owner knows which results matter.
|
||||
|
||||
## Watching, waiting, aborting
|
||||
|
||||
- **`running`** is a readable of `{ id, version, args, progress, phase }`. `phase` is `running`, `finishing` (outcome
|
||||
recorded, `finish` not yet committed) or `blocked` (with the reason). `concurrency` counts only active runs. The
|
||||
panel shows a progress bar from it.
|
||||
- **Progress** is in memory, throttled and sent as deltas. For output, use `OutputBuffer` and `Progress` from
|
||||
`@earendil-works/chord/output`: bounded windows, adaptive throttling, no write amplification.
|
||||
- **Waiting:** `start` returns a run with `outcome(ctx)`. The caller's abort ends only its wait, never the run.
|
||||
- **Abort is a request:** `job.abort(id)` fires the run's `ctx.signal`; the run decides what it returns (a result
|
||||
that already arrived stays a success). A run that ignores the signal keeps its slot until it ends. `finish` runs in
|
||||
Chord's own context.
|
||||
|
||||
## Lifetime, persistence, revocation
|
||||
|
||||
**Jobs keep their owner alive.** A running, finishing or blocked job counts as a reference to its object, which ends
|
||||
only once all its jobs are settled. Starting an import and closing the tab does not abort it. Unsettled runs also
|
||||
count as activity for the worker's keep-alive.
|
||||
|
||||
**Revocation takes precedence** (R11). Revoking a principal ends its objects at once, whatever jobs remain; the jobs
|
||||
get the abort signal and the normal close timeout. A job runs as its object's principal.
|
||||
|
||||
**Persistence:** jobs are in memory by default and gone with the process. `persist: true` (only for stable
|
||||
identities) keeps the record: a run live at a crash ends `lost` at the next activation and `finish` records it.
|
||||
Nothing is rerun automatically.
|
||||
|
||||
**Versions:** a run started under an older job version keeps its version; `finish` and `running` see it through pure
|
||||
`accepts` adapters, and stored outcomes are never rewritten. A version bump without an adapter does not refuse the
|
||||
reload: the old outcome is `blocked`, kept and reported until a later reload brings the adapter.
|
||||
|
||||
**Native objects:** a job in a native object ends `aborted` when the object restarts on a reload (chapter XV). In a
|
||||
pure object it keeps running on its old `run` code.
|
||||
|
||||
## What is not a job
|
||||
|
||||
| Work | Use |
|
||||
| --- | --- |
|
||||
| request and response that may die with its caller | a method |
|
||||
| input into running work (a pasted code, an approval) | a flow (chapter XII) |
|
||||
| work that must resume after a crash | a Durable task |
|
||||
| standing behaviour | a routine |
|
||||
|
||||
:::review | Review: jobs are the largest open simplification (#9, C3)
|
||||
Decision #9 (deferred until login is ported): a login or a long relocation is a job whose state consumers want to
|
||||
address and watch (`LoginFlow { id }`). Options: (a) jobs stay records inside their owner, watched through `running`;
|
||||
(b) a job is an object of its own, addressed by its id, living until done. Separately, review round 3 proposed cut C3:
|
||||
shrink jobs to a runner over documents (no job versions, `accepts`, `blocked`, `persist`) or move them out of the
|
||||
kernel. Not decided. `chord-next-walkthrough.md` concept 17 still shows the older outcome shape `{ ok: true, result }`;
|
||||
the design tags outcomes by `kind`.
|
||||
:::
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
|
||||
# Asking a Person {#flows lede="Flows and the Questions service: how running work waits for an answer from someone who may be on any tab, may reconnect, and may answer twice."}
|
||||
|
||||
A provider login asks for a pasted code, then an account choice. A dangerous tool call waits for the owner's
|
||||
approval. The agent asks everyone present a question. In each case running work waits for a person, who may be on any
|
||||
tab, may lose the connection halfway, may answer late or twice. Two patterns cover this: a **flow** kept as its
|
||||
owner's state, and the **Questions** service, a shared store for questions.
|
||||
|
||||
## A flow is state, not a call
|
||||
|
||||
The tempting design is a long-held server call, `step()`, that blocks until the next step. A link drop fails it, and
|
||||
the consumer cannot tell whether it missed a step. Chord's answer is that the owner keeps the flow as **state**, which
|
||||
re-holds by itself after any drop:
|
||||
|
||||
```ts
|
||||
// models/contract.ts (sketch from the design)
|
||||
start: method(obj({ provider: str() }), obj({ id: str() })) // uuidv7
|
||||
answer: method(obj({ id: str(), step: int(), value: str() }), Answered) // ok | "step no longer active"
|
||||
cancel: method(obj({ id: str() }))
|
||||
|
||||
// LoginFlow { id }: state
|
||||
type FlowState =
|
||||
| { kind: "running"; step?: { id: number; prompt: LoginPrompt }; notices: Notice[] }
|
||||
| { kind: "done" | "failed" | "cancelled"; notices: Notice[] };
|
||||
```
|
||||
|
||||
The UI watches the state and renders the current step. A late or second answer to a step that is gone is refused. A
|
||||
promise-based callback inside the work (pi-ai's `prompt` during a login) stays **inside** the owner: its job keeps the
|
||||
resolver, and nothing crosses an object boundary.
|
||||
|
||||
Imperative consumers (tests, scripts, a CLI, an agent tool) use a generic client helper over the same state, not a
|
||||
second protocol:
|
||||
|
||||
```ts
|
||||
async function* steps(flow: LoginFlowRef, ctx: Ctx) {
|
||||
let last = 0;
|
||||
for (;;) {
|
||||
const s = await until(flow.state, (v) => v.kind !== "running" || (v.step !== undefined && v.step.id > last), ctx);
|
||||
if (s.kind !== "running") return s.kind;
|
||||
last = s.step!.id;
|
||||
yield s.step!;
|
||||
}
|
||||
}
|
||||
|
||||
for await (const step of steps(await ctx.get(LoginFlow, { id }), ctx))
|
||||
await login.answer({ id, step: step.id, value: answerFor(step) }, ctx);
|
||||
```
|
||||
|
||||
`last` keeps steps from being lost or repeated across re-holds.
|
||||
|
||||
:::review | Review: where a flow lives (#11, deferred)
|
||||
Decision #11, deferred until login is ported: (a) a keyed document of its owner, or (b) an object `LoginFlow { id }`
|
||||
living until done; tied to #9 (jobs as objects). The `LoginFlow { id }` address above assumes (b). Also undecided is
|
||||
how "leaving" ends a login (p2 cancels when the starting owner leaves everywhere); with presence it is a dropped
|
||||
reference, never an unmount (chapter VIII).
|
||||
:::
|
||||
|
||||
## The Questions service
|
||||
|
||||
The Questions service (`apps/p2/docs/questions.md`) is a p2 design, not a Chord feature, built from Chord's
|
||||
primitives: a shared store with per-principal views. The asker gets `ask`; viewers get the open questions and
|
||||
`answer`.
|
||||
|
||||
```ts
|
||||
export type Question =
|
||||
| { kind: "text"; message: string; secret?: boolean; help?: string }
|
||||
| { kind: "select"; message: string; options: readonly { id: string; label: string }[] }
|
||||
| { kind: "confirm"; message: string; allow: string; deny: string };
|
||||
|
||||
type Policy = { mode: "first" } | { mode: "quorum"; count: number } | { mode: "all"; join?: "live" | "snapshot" };
|
||||
|
||||
ask(question, {
|
||||
audience, // a readable or a list of principals
|
||||
policy,
|
||||
key?, // stable per asking flow: a re-ask reattaches to the pending question
|
||||
deadline?, // then resolve with what is there; `missing` names who did not answer
|
||||
}, ctx): Promise<Result>;
|
||||
```
|
||||
|
||||
Its rules:
|
||||
|
||||
- **The store is the truth.** Pending questions and their resolvers live in the store; `open` is a readable over it,
|
||||
filtered per viewer. Nothing is a document, so a reload or migration cannot leave a ghost question.
|
||||
- **One settlement point.** Register the waiter and the abort listener before publishing the question; an answer, an
|
||||
abort or the deadline atomically takes the entry out before resolving. A late or second answer gets "no longer
|
||||
active". Answers are validated against the stored question (a select id must be one of the options). Never await an
|
||||
answer inside a transaction.
|
||||
- **Policies over a live audience.** `first` resolves with the first answer; `quorum(n)` after n answers; `all` when
|
||||
every member of the audience has answered. With `join: "live"` (the default), someone who joins becomes required and
|
||||
someone who leaves drops out.
|
||||
- **Authorization.** The asker is stamped by the host (a worker asks only in its own session's scope; secret questions
|
||||
only from server askers). Each question stores who may answer it; a guest's send access does not imply approval
|
||||
rights. Secret answers never appear in `open`, documents or logs.
|
||||
- **Link drops and crashes.** A rejected `ask` is not a denial. Askers retry with the same `key` until their own signal
|
||||
aborts; a pending `ask` counts as an active call for keep-alive.
|
||||
- **Where it lives.** On the host where the asking work runs: session questions on the session's worker, login
|
||||
questions on the server. UIs reach both through the usual relay.
|
||||
|
||||
The store in Chord v2 terms:
|
||||
|
||||
```ts
|
||||
host.serve(QuestionStore, ({ scope }, self) => {
|
||||
const pending = self.open(async () => new PendingQuestions(), (p) => p.rejectAll("store closed"));
|
||||
const open = self.derive("open", [pending.value.readable], ([all]) => all); // data only, no resolvers
|
||||
return { open, ask: (args, ctx) => pending.value.ask(args, ctx), answer: (args, ctx) => pending.value.answer(args, ctx) };
|
||||
});
|
||||
|
||||
host.serve(Questions, ({ scope }, principal, self) => {
|
||||
const store = self.get(QuestionStore, { scope });
|
||||
if (isHost(principal)) return { variant: "asker", ask: (args, ctx) => store.ask({ ...args, asker: principal }, ctx) };
|
||||
if (!mayView(principal, scope)) throw forbidden();
|
||||
return {
|
||||
variant: "viewer",
|
||||
open: self.derive("mine", [store.open], ([all]) => openFor(all, principal)),
|
||||
answer: ({ id, value }, ctx) => store.answer({ id, value, by: principal }, ctx),
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
The pending map with its resolvers is in-process state with promises, so it is a native of the store (opened and
|
||||
closed with it). Its `close` rejects every waiter; askers retry with their key against the new store after a reload.
|
||||
|
||||
:::review | Review: questions.md predates the v2 rules
|
||||
`apps/p2/docs/questions.md` writes the store as `self.resource(...)` with a `replace` hand-over, which the design
|
||||
rejected ("resources replaced in place"), and opens Chord call scopes in Durable hooks with `host.root`, which Q6
|
||||
replaced with `chord(fn, context)` (chapter XIV). Design §20 says the Questions store "must live in an object (it
|
||||
predates natives only in objects)". The sketch above is this guide's translation, with the derive per viewer; filtering
|
||||
per viewer in the store, once per variant, may be cheaper for large audiences. The `ask` signature passes typed
|
||||
objects as arguments, which is fine (data), but the `audience` readable form cannot cross a link: across links,
|
||||
audience must be data (principals) or computed by the store.
|
||||
:::
|
||||
|
||||
## Choosing
|
||||
|
||||
| Case | Use |
|
||||
| --- | --- |
|
||||
| a multi-step login in one owner | a flow: the owner's job keeps the resolver, state shows the steps |
|
||||
| a tool needs the owner's approval | Questions, `first`, audience the owner |
|
||||
| the agent asks everyone present | Questions, `all`, audience from presence |
|
||||
| deploy needs 2 of 3 approvals | Questions, `quorum(2)` |
|
||||
| a notice (auth URL, device code, progress) | not a question: the asking work's own progress state |
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
|
||||
# Extension Points {#registries lede="The facet, registries and the HTTP component: how plugins add behaviour to each other without callbacks, and how the owner stays in charge."}
|
||||
|
||||
The notes plugin wants a download route, `GET /s/:session/notes.txt`. A logs plugin wants to time every request.
|
||||
Several plugins add model providers, tools for the agent, commands for the TUI. Someone must combine these
|
||||
contributions, decide conflicts, and replace one plugin's part as a whole when it reloads. Between plugins exactly two
|
||||
things pass: object types and **registries**.
|
||||
|
||||
## The facet, completed
|
||||
|
||||
The facet has appeared since chapter II as the place that serves types. It is also itself a **pure object** with the
|
||||
host's lifetime:
|
||||
|
||||
```ts
|
||||
// notes/server.ts
|
||||
export default defineFacet((host, self) => {
|
||||
const prefs = self.state(NotesPrefs); // plugin-wide documents
|
||||
self.contribute(Http, { routes: { "GET /s/:session/notes.txt": exportNotes } });
|
||||
host.serve(NoteCountStore, …, { lifetime: "host" });
|
||||
host.serve(NoteCounts, …);
|
||||
});
|
||||
```
|
||||
|
||||
- It may hold documents, references, routines and contributions; **no natives** (those need an object with its own
|
||||
lifetime).
|
||||
- Its identity is the plugin's name, so its persisted documents survive restarts and reloads.
|
||||
- Served objects go live after the facet and may read and write its documents (chapter V).
|
||||
- `host.serve` and `self.contribute` are valid only while the setup runs. The facet never `self.get`s a type it
|
||||
serves.
|
||||
|
||||
## Registries
|
||||
|
||||
A registry is an extension point owned by someone (a Chord component, the app, a plugin). Each contributor
|
||||
contributes **at most one value** per registry; the table is an immutable, ordered list of `(contributor, value)`;
|
||||
the registry's `compose` turns it into the value consumers use.
|
||||
|
||||
```ts
|
||||
// owned by the HTTP component
|
||||
export const Http = defineRegistry<HttpContribution, Router>("chord.http", {
|
||||
version: 2,
|
||||
accepts: { 1: (old: HttpContributionV1): HttpContribution => upgrade(old) },
|
||||
compose: (table, { report }) => buildRouter(table, report), // pure; always delivers a value
|
||||
});
|
||||
|
||||
self.contribute(Http, { routes: { … }, wraps: [ … ] }); // a contributor
|
||||
const router = host.registry(Http).value; // a consumer: live
|
||||
```
|
||||
|
||||
**`compose` runs at runtime, whenever the table changes:** after a reload's commit, when a contributor goes live or
|
||||
ends, when a contribution changes. It is not a staging check: contributions can be dynamic (known only once a native
|
||||
is open), so no check before a reload could be complete. `compose` always delivers a value with its fallback built in
|
||||
(e.g. the first by plugin name wins) and reports conflicts through `report`, which become owner notices and telemetry
|
||||
events. Readers get a plain value and never handle conflicts. Only if `compose` throws is the registry in an error
|
||||
state, and readers get an error when they use it. Work that started earlier keeps the value it read.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Who contributes** (R21): only facets and `lifetime: "host"` objects; only their `self` has `contribute` (a type
|
||||
error elsewhere). Every object may read registries.
|
||||
- **A contribution is in the table exactly while its contributor is live:** a facet's while its plugin is loaded; an
|
||||
object's from `live` until it ends, fails or restarts.
|
||||
- **Order** is sorted by plugin name: deterministic on every host and across reloads, and deliberately meaningless.
|
||||
Meaningful order comes from the owner's composition (HTTP phases, below). Helpers: `keyed(table, entriesOf)`,
|
||||
`ordered(table, itemsOf, orderOf?)`.
|
||||
- **One authoritative declaration** (Q8): only the defining plugin's facet declares the registry to the host
|
||||
(`compose`, version, `accepts`). Other plugins' tokens only identify it by name and types. A reload of the defining
|
||||
plugin replaces the declaration atomically and the table recomposes. If the defining plugin is not loaded, the
|
||||
registry has no value (readers get an error like a failed reference) and contributions wait in the table.
|
||||
- **Versions** (Q8): a contribution carries the registry version its plugin was built against; older ones are adapted
|
||||
by the defining facet's pure `accepts`. One without a matching version or adapter is left out and reported naming
|
||||
plugin, registry and versions; its plugin keeps working. Contributions are in-process values (often functions) and
|
||||
are not schema-checked.
|
||||
- **Use sites guard contributions.** A contribution is untrusted code (third-party plugins are a goal). Every use site
|
||||
catches throws and rejections when calling an entry, reports naming the contributor, and continues with the others.
|
||||
- **The owner decides** conflicts, order and selection. Registries add behaviour; they never replace an owner's checks.
|
||||
|
||||
## Natives through registries
|
||||
|
||||
Registries are the only way a native reaches another plugin. An entry lives exactly as long as its contributor;
|
||||
consumers look up at each use (or follow the composed value) and keep nothing from an entry beyond a use. The models
|
||||
plugin's host-lifetime object contributes its runtime; the harness looks it up at every request. After a models reload
|
||||
the next request gets the new runtime, while a running stream finishes on the old one.
|
||||
|
||||
A handler that stores `const r = entry()` in a module variable and uses it for days has a bug: after the contributor
|
||||
restarts it holds a closed native.
|
||||
|
||||
## Contributions that change
|
||||
|
||||
Some contributions are known only at runtime: MCP tools appear when a server connects. A contribution is either fixed
|
||||
for its contributor's life (dynamic parts behind handles read at use) or a **readable**, which Chord subscribes to;
|
||||
on every change Chord replaces that entry and runs `compose` again. For in-process values, `self.writable(initial)` is
|
||||
a host-local readable of any value (no JSON, no wire, compared by identity) with `set`:
|
||||
|
||||
```ts
|
||||
const ext = self.writable(mcpExtension([])); // a Durable Extension, no tools yet
|
||||
self.contribute(HarnessExtensions, ext);
|
||||
self.routines.on(mcp.tools, (tools) => ext.set(mcpExtension(tools)));
|
||||
```
|
||||
|
||||
There is no remove call. An entry leaves when its contributor ends, or after a reload whose new code no longer
|
||||
contributes. A live contributor with nothing to offer sets its readable to the registry's empty value (an extension
|
||||
without tools, empty routes); the table keeps exactly one entry per live contributor.
|
||||
|
||||
## Optional dependencies
|
||||
|
||||
Registries also replace optional references (chapter VI). Radius forwards connections to ports the ports plugin
|
||||
manages, but ports is optional. So radius owns `radius.forwards` and ports contributes its forwards; without ports the
|
||||
table is empty and radius works.
|
||||
|
||||
## The HTTP component
|
||||
|
||||
HTTP is an optional Chord component (`@earendil-works/chord/next/http`) built on a registry. A route gets a plain
|
||||
request summary (method, path params, query, principal, provenance such as local or relayed, selected headers, parsed
|
||||
and capped JSON bodies, uploads in chunks) and a `ctx` whose `principal` is the request's, so `ctx.get(Notes, …)` gives
|
||||
exactly the view that person may see. The route returns a description; the component does the HTTP work once (ranges
|
||||
with 206 and 416, HEAD, download headers with a safe filename, `nosniff`, CSP and its nonce, caching, streaming, abort
|
||||
on disconnect):
|
||||
|
||||
```ts
|
||||
type HttpResponse =
|
||||
| { kind: "json"; value: JsonValue } | { kind: "page"; title: string; body: string }
|
||||
| { kind: "bytes"; name: string; mime: string; size: number; read(offset: number, length: number): Promise<Uint8Array> }
|
||||
| { kind: "redirect"; to: string } | { kind: "error"; status: number };
|
||||
|
||||
// notes/server.ts
|
||||
self.contribute(Http, {
|
||||
routes: {
|
||||
"GET /s/:session/notes.txt": async (req, ctx) => {
|
||||
const notes = await ctx.get(Notes, { session: req.params.session }); // the requester's view
|
||||
const text = new TextEncoder().encode(render(notes.notes.value));
|
||||
return { kind: "bytes", name: "notes.txt", mime: "text/plain", size: text.byteLength,
|
||||
read: async (offset, length) => text.subarray(offset, offset + length) };
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Routes compose with `keyed`: two contributors with one pattern is a reported conflict, and the first by plugin name
|
||||
wins. **Wraps** are middleware in phases the owner defines (`observe`, `headers`, `transform`, outermost first),
|
||||
within a phase in contributor order. No numeric priorities: plugins have no single registration order, so Fastify's
|
||||
lifecycle phases and encapsulation are the model, not Express.
|
||||
|
||||
```ts
|
||||
// logs/server.ts
|
||||
self.contribute(Http, {
|
||||
wraps: [{
|
||||
name: "access-log", phase: "observe", scope: "all", // default scope "own": only own routes
|
||||
wrap: async (req, next, ctx) => {
|
||||
const t = Date.now();
|
||||
const res = await next(req);
|
||||
log(req, res, Date.now() - t);
|
||||
return res;
|
||||
},
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
`route` narrows a wrap to one route; a `route` that does not exist is a reported conflict. Wraps may add a security
|
||||
boundary on top of an owner's checks, never instead of them. A request uses the router as it was when it started.
|
||||
Request resolvers (who sent a request: cookie, loopback, relay) are hooks over the summary.
|
||||
|
||||
## Channels and big bytes
|
||||
|
||||
**Channels** are WebSocket byte channels (`send`, `onData`, `close`) for terminals, separate from object links and
|
||||
with their own acknowledged windows, so a large state message never holds up keystrokes.
|
||||
|
||||
**Big transfers are chunked by the owner,** not by Chord: bytes are a schema type (`bytes()`), carried natively
|
||||
(never base64), and a 50 MB upload is a protocol of plain calls:
|
||||
|
||||
```ts
|
||||
beginUpload({ name, size }, ctx) → { upload }
|
||||
append({ upload, offset, bytes }, ctx) // about 1 MiB per chunk; the awaited call is the backpressure
|
||||
finishUpload({ upload }, ctx) → { id }
|
||||
read({ id, offset, length }, ctx) → bytes
|
||||
```
|
||||
|
||||
After a link drop the `append` in flight fails like any call; the writer asks the owner for the stored offset and
|
||||
resumes. Unfinished uploads expire through the owner's routine. Chord has no stream type: it would need its own rules
|
||||
for drops, cancellation, relays and restarts, which this offset protocol already survives.
|
||||
|
||||
:::note | Built so far
|
||||
Registries are layer L9. The HTTP component, channels and the chunked-transfer helper are deferred until after the
|
||||
kernel and a first real proof (the harness, one tool with an approval pause, a code mode reload); until then the
|
||||
playground serves its pages with plain app code. Their design stays the target.
|
||||
:::
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
|
||||
# The Agent {#agent lede="pi-durable inside Chord: the harness as a core object, models, environments and tools through registries, and how a tool reaches Chord objects and people."}
|
||||
|
||||
Each Studio worker runs a pi-durable harness for its session. The harness must run for the worker's whole life,
|
||||
whether anyone watches or not. Its models come from the models plugin, its execution environments from the envs
|
||||
plugin, its tools from every plugin, and all of those reload independently. This chapter assumes you know pi-durable
|
||||
and shows only how it plugs into Chord.
|
||||
|
||||
## Shared foundations
|
||||
|
||||
Durable and Chord v2 share modules: Durable imports Chord's `context`, `delta`, `json`, `output` and `sqlite`, and v2
|
||||
imports the same, so `Context`, `Draft` and `JsonValue` are one type each. A v2 readable is today's
|
||||
`replicatedState` (`chord/src/state.ts`), so Durable's `viewState()` and document watches can be exposed as Chord state
|
||||
members without conversion. Only plain data crosses between Durable and Chord objects.
|
||||
|
||||
## The harness is a core object
|
||||
|
||||
```ts
|
||||
// studio/src/core/harness.ts (base code)
|
||||
import { Harness as DurableHarness } from "@earendil-works/pi-durable";
|
||||
|
||||
export const ChordHostKey = createContextKey<Host<P>>("studio.chord-host");
|
||||
export const HarnessModels = defineRegistry<Models, Models>("studio.harness-models", { version: 1, compose: firstModels });
|
||||
export const HarnessEnvironments = defineRegistry<EnvFactory, EnvFactory>("studio.harness-envs", { version: 1, compose: firstEnv });
|
||||
export const HarnessExtensions = defineRegistry<Extension, RegistrySnapshotSource>("studio.harness-extensions", {
|
||||
version: 1,
|
||||
compose: (table, { report }) => extensionSnapshot(table, report), // one Durable Extension per plugin
|
||||
});
|
||||
const HarnessRuntime = defineRegistry<() => DurableHarness, () => DurableHarness>("studio.harness-runtime", {
|
||||
version: 1, compose: onlyEntry, // core-private: one contributor
|
||||
});
|
||||
|
||||
host.serve(Harness, (_input, self) => {
|
||||
const info = self.get(SessionInfo, {});
|
||||
const harness = self.open(async ({ signal }) => {
|
||||
const { id, dataDir } = info.init.value;
|
||||
const base = withContextValue(ChordHostKey, host, withAbortSignal(signal, BACKGROUND_CONTEXT));
|
||||
return DurableHarness.open(await openNodeSqliteStorage(join(dataDir, id, "durable.sqlite")), {
|
||||
models: () => host.registry(HarnessModels).value, // read at each use
|
||||
env: (target) => host.registry(HarnessEnvironments).value(target),
|
||||
settings: () => currentSettings(),
|
||||
registry: durableRegistry(host.registry(HarnessExtensions)), // a RegistryReader over the composed value
|
||||
}, base);
|
||||
}, (h) => h.close(BACKGROUND_CONTEXT));
|
||||
self.contribute(HarnessRuntime, () => harness.value); // for core's conversation objects
|
||||
return { … };
|
||||
}, { lifetime: "host" });
|
||||
```
|
||||
|
||||
- **Lifetime `"host"`:** it runs for the worker's whole life, also while nobody holds it, because Durable tasks
|
||||
continue.
|
||||
- **Base code:** it lives in the core facet, so a plugin reload never restarts it; only a base-code change (a host
|
||||
restart) does.
|
||||
- **Registries, looked up at each use:** Durable's `models` and `settings` accept functions (pi `7ec1f648e`), so each
|
||||
generation gets the current value. After a models reload the next request uses the new runtime; a running stream
|
||||
finishes on the old one.
|
||||
- **Durable's storage is its own.** Chord documents never commit together with Durable transcripts.
|
||||
|
||||
The design writes the call as `Durable.open(storage, { … })`; pi-durable's actual entry point is
|
||||
`Harness.open(storage, options, context)`, used here as `DurableHarness` to keep it apart from the core object
|
||||
`Harness`.
|
||||
|
||||
## Start order
|
||||
|
||||
At host start Chord loads every facet and waits until every facet and every `lifetime: "host"` object **other than the
|
||||
harness** is live or failed: the **start barrier**. Then the registries hold the contributions of every plugin that
|
||||
loaded, and the harness opens and resumes scheduling (R17). An object that must contribute at start, like the model
|
||||
runtime, is therefore `lifetime: "host"`. Nothing waits for specific contributions; what is missing shows where it is
|
||||
used:
|
||||
|
||||
| Missing | What happens |
|
||||
| --- | --- |
|
||||
| a tool | its call gets an error result the model sees |
|
||||
| a model | the generation reports "model unavailable" and waits, not fails, until a model appears |
|
||||
| a task definition | the task stays blocked (`missing_task`) until the plugin is installed or the task is aborted |
|
||||
| a plugin that failed | reported with its error; a facet in backoff contributes later and the registries recompose |
|
||||
|
||||
The same holds when a session's database is copied to an instance with other plugins: missing tools, sections and
|
||||
hooks are skipped, documents of missing plugins stay untouched, stored selections are never rewritten. The UI must
|
||||
show blocked tasks with their reason and offer to abort them; a turn waiting on one hangs until then.
|
||||
|
||||
:::review | Review: the harness exception to the barrier (C17, R17)
|
||||
R17 makes the harness start after the barrier, a special case in the barrier's rule. Review round 3 proposed cut C17:
|
||||
no harness-specific exception; the harness is an ordinary object the app's start code holds after `load` resolves.
|
||||
Not decided. `chord-next-walkthrough.md` concept 28 still says the harness's activation *is* the start barrier, which
|
||||
R17 replaced.
|
||||
:::
|
||||
|
||||
## Tools that call Chord
|
||||
|
||||
Durable tools, hooks and sections receive Durable's `ToolExecutionApi` (or `HookApi`) and a `Context`, never a Chord
|
||||
`ctx` (Q6). Core put the host into the harness's base context under `ChordHostKey`, and every phase context inherits
|
||||
it. A core helper opens a Chord call scope from any Durable context:
|
||||
|
||||
```ts
|
||||
// studio/src/core/chord.ts
|
||||
export function chord<T>(context: Context, fn: (ctx: Ctx) => Promise<T>): Promise<T> {
|
||||
const host = context.value(ChordHostKey);
|
||||
if (!host) throw new Error("not inside the Studio harness");
|
||||
return host.root(fn, { signal: context.abortSignal, telemetry: context.telemetry });
|
||||
}
|
||||
```
|
||||
|
||||
The scope runs as the host principal; attribution is data; references taken with `ctx.get` are released when `fn`
|
||||
settles. There is no wrapping of tools and no app-specific `defineTool`. The notes plugin gives the agent a tool:
|
||||
|
||||
```ts
|
||||
// notes/worker.ts
|
||||
const addNote = defineTool({
|
||||
name: "notes_add", // tools are named <plugin>_<name>
|
||||
description: "Add a note to this session's notes panel",
|
||||
parameters: Type.Object({ text: Type.String({ maxLength: 2000 }) }),
|
||||
execute: (args, api, context) => chord(async (ctx) => {
|
||||
const info = await ctx.get(SessionInfo, {});
|
||||
const store = await ctx.get(NoteStore, { session: info.init.value.id });
|
||||
const id = await store.add({ text: args.text, by: `agent:${api.conversationId}` }, ctx);
|
||||
return { output: [{ type: "text", text: `added note ${id}` }] };
|
||||
}, context),
|
||||
});
|
||||
|
||||
export default defineFacet((host, self) => {
|
||||
self.contribute(HarnessExtensions, defineExtension({ name: "@studio/notes", tools: [addNote] }));
|
||||
…
|
||||
});
|
||||
```
|
||||
|
||||
A reload of the notes plugin replaces its extension in the table; Durable pins a registry snapshot per task phase, so
|
||||
a tool call already running finishes on the old code and the next phase uses the new one.
|
||||
|
||||
## Tools that need a person
|
||||
|
||||
Clients are not addressable, so a tool cannot call the user's tab. A tool that needs the user goes through an owner's
|
||||
state: the Questions service (chapter XII). The model-facing `ask_user` tool and an approval hook both call
|
||||
`Questions.ask`; tabs render the open questions and answer them.
|
||||
|
||||
```ts
|
||||
hook(ToolTask, {
|
||||
beforeTool: async (call, api, context) => {
|
||||
if (!needsApproval(call)) return undefined;
|
||||
const decided = await api.memo<boolean>("approval", context); // a rerun does not ask again
|
||||
if (decided !== undefined) return decided ? undefined : { block: "Denied" };
|
||||
const allowed = await chord(async (ctx) => {
|
||||
const q = await ctx.get(Questions, { scope: { session } });
|
||||
for (;;) {
|
||||
try {
|
||||
const r = await q.ask(
|
||||
{ kind: "confirm", message: `Run ${call.name}?`, allow: "Allow", deny: "Deny" },
|
||||
{ audience: approvers, policy: { mode: "first" }, key: api.taskId },
|
||||
ctx,
|
||||
);
|
||||
return r.answer;
|
||||
} catch (error) {
|
||||
if (ctx.signal.aborted) throw error; // a link blip is not a denial: retry
|
||||
await backoff(ctx);
|
||||
}
|
||||
}
|
||||
}, context);
|
||||
await api.memo("approval", allowed, context);
|
||||
return allowed ? undefined : { block: "Denied by the user" };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Durable runs `beforeTool` in the tool task's call phase; after the execute checkpoint a crash retries execution
|
||||
without hooks, so the stored decision covers that retry. The approval must cover the final arguments: put the
|
||||
approval hook after hooks that rewrite arguments.
|
||||
|
||||
## Conversations for the UI
|
||||
|
||||
Chat panels need a conversation's live view, submit, steer, stop. The design leaves p3's conversation API to the p3
|
||||
design; one construction that follows the rules: a core `ConversationStore { conversation }` (local) opens the Durable
|
||||
watch as its own native, getting the Durable harness from the core-private `HarnessRuntime` registry contribution (a
|
||||
native may reach another object only through a registry entry), and exposes a bounded window as a derive. Per-principal
|
||||
`Conversation { session, conversation }` views in front of it check who may send (chapter XIX).
|
||||
|
||||
## Code mode
|
||||
|
||||
Code mode is a plugin. It owns its `pi-codemode` sandbox as a native; its tool entry looks up the **current** sandbox
|
||||
when `execute()` starts, so a call pinned by a phase before a reload (waiting in an approval hook, say) starts on the
|
||||
new sandbox. On `reload` and `stop` the old sandbox is sealed and drained in `close`: running scripts finish or hit
|
||||
their deadline, for at most 30 s (Q7). After a worker stop Durable replays as it does today.
|
||||
|
||||
## What lives where
|
||||
|
||||
| Data | Where |
|
||||
| --- | --- |
|
||||
| transcript-bound: entries, documents that fork and rewind with a conversation | Durable |
|
||||
| app state: session list, notes, drafts, grants, settings | Chord documents |
|
||||
| data core enforces on conversations (read-only one-shots, cwd followers) | a core per-conversation policy document, written when the conversation is created (`createConversation({ init })`) |
|
||||
|
||||
**Telemetry:** pass the host's adapter as `HarnessOptions.telemetry`; Durable stores trace carriers on submissions and
|
||||
tasks (chapter XVII).
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
|
||||
# Reload and Rollout {#reload lede="What happens when you press Reload: per host, per object, and across the server, the workers and every tab."}
|
||||
|
||||
Back to the preface. Mario changed the notes plugin's sort order and pressed Reload. Everything the earlier chapters
|
||||
asked of plugin authors (synchronous factories, natives only in objects, routines instead of timers, data in
|
||||
documents) exists so that this moment is safe. This chapter shows what Chord does with it.
|
||||
|
||||
## On one host
|
||||
|
||||
```text
|
||||
1. load new bundles; an import or syntax error rejects
|
||||
2. staging facet setups and every live object's new factory run with a recording self
|
||||
(declarations only, nothing activated); any throw rejects
|
||||
3. commit the host switches to the new code atomically
|
||||
4. after per object: pure objects swap, native objects restart
|
||||
```
|
||||
|
||||
**Rollback is all-or-nothing per host.** Any load or staging error leaves the whole host on its previous code, so a
|
||||
host is always completely at one revision and its status is one value. Runtime states (a registry conflict, a failed
|
||||
open, a migration) are not reasons to roll back; after the commit, errors stay with one object. There is no factory
|
||||
trial, no migrate dry run, and no consumer contract check in staging: an incompatible consumer fails at bind with
|
||||
`contract_mismatch` and a notice naming plugin and type.
|
||||
|
||||
:::steps | Reloading @studio/notes on worker S1, object by object
|
||||
+++ Load and stage
|
||||
The new bundles load. The facet setup and the factories of `Notes` (three views), `NoteStore { S1 }` and `NoteInbox`
|
||||
run with a recording `self`: they declare, nothing activates. Nothing throws, so the host commits to the new code.
|
||||
+++ NoteStore: pure, swapped in place
|
||||
`NoteStore` has no `self.open`. Its new methods and derives take over; `NoteList`, the drafts, its references and the
|
||||
running import job stay. Routines restart, serialized: the new `cleanup` waits for the old handler in flight. The gate
|
||||
opens.
|
||||
+++ Running calls finish on old code
|
||||
An `add` that was running when the swap happened finishes on the old code with its old handles; its transaction still
|
||||
commits. Only new calls get the new handles. Exception (R15): if the reload raised a document's version, old code that
|
||||
touches the migrated document throws, the document stays intact, and that call fails once.
|
||||
+++ The import job keeps its old run
|
||||
The job's `run` keeps the code it started with. When it settles, the **new** `finish` records the outcome.
|
||||
+++ The views: pure, swapped
|
||||
The three `Notes` views swap in place. Owner, Ann and Bob keep their references; the reader derive is recomputed with
|
||||
the new sort and the difference is sent. Nobody sees a reconnect.
|
||||
+++ NoteInbox: native, restarts
|
||||
`NoteInbox` has a watcher. Its running work ends `aborted`, the watcher closes with reason `reload`, the new factory
|
||||
runs, the watcher reopens. Its documents would stay. Whether a file dropped during the restart is seen is the
|
||||
watcher's business: Studio's `watchDir` scans the directory once when it opens.
|
||||
:::
|
||||
|
||||
**Why natives restart instead of being handed over:** a native has no line, no transactions and no schema. If old and
|
||||
new code shared it, they would interleave on it unchecked. In practice restarts are cheap: the Durable harness is base
|
||||
code, terminal PTYs live in a separate terminal host process, and model runtimes, sandboxes and a search index rebuild
|
||||
in milliseconds. Only reloading the plugin that owns SSH connections (running remote commands die) or the relay socket
|
||||
(relayed connections drop) hurts.
|
||||
|
||||
**Removing a type:** its objects end (routines stop, natives close, references end with an error); in-memory documents
|
||||
go with them; persisted ones stay until retired. **Consumers elsewhere** see a restarting object like any object that
|
||||
briefly goes away: `loading`, parked calls, then `live`.
|
||||
|
||||
**UI facets reload too.** A reload unmounts and remounts components. That is why "leaving" (abort a login, close an
|
||||
overlay) is never derived from an unmount (chapter VIII).
|
||||
|
||||
## Across hosts
|
||||
|
||||
A **revision** is (server incarnation, sequence) and names the complete desired code set: every facet with its
|
||||
digest, not a batch of changes. Every install reconciles everything missing, and a host reports a revision only when it
|
||||
runs that complete set. A revision also carries the code-set digest (a hash over the facet bundles' content hashes),
|
||||
so a server restart with unchanged code triggers no reinstall.
|
||||
|
||||
The order is **server, workers, clients**. Each host reports `installed`, `installed_failed` (with the objects that
|
||||
failed) or `rejected` (with the reason; it stays on its previous code). A server that rejects stops the rollout. A
|
||||
host that starts or reconnects installs the current revision before anything is routed to it. Installs are serialized
|
||||
per host and bounded (60 s).
|
||||
|
||||
Calls keep flowing during a rollout. When a call reaches a provider on another revision, the mismatch response
|
||||
carries the provider's revision and status:
|
||||
|
||||
| Provider | The call |
|
||||
| --- | --- |
|
||||
| ahead, or behind and installing | `updating`: values readable, calls park (with the park deadline) |
|
||||
| rejected | fails at once; retried on the next revision |
|
||||
| same revision | `contract_mismatch`: a real mismatch, final |
|
||||
|
||||
A host checks its own install status first: if its own install was rejected, a call fails at once with the reason
|
||||
instead of waiting on a provider. Calls parked on a binding that is released end with an error and are not resent.
|
||||
There are no cross-host transactions and no votes.
|
||||
|
||||
:::sequence animate | A tab calls into a worker that is still installing. The call parks and runs once the worker reports the revision.
|
||||
actor tab "tab" "revision 8"
|
||||
actor server "server" "revision 8" accent
|
||||
actor worker "worker S1" "installing 8" blue
|
||||
server => worker: install revision 8
|
||||
tab -> server: call Notes.add (new contract)
|
||||
server -> worker: relay
|
||||
worker --> server: updating (rev 7, installing)
|
||||
server --> tab: updating
|
||||
tab -> tab: park (deadline 60 s)
|
||||
worker --> server: installed 8
|
||||
server --> tab: provider at 8
|
||||
tab -> server: call Notes.add (resent from park)
|
||||
server -> worker: relay
|
||||
worker --> tab: result
|
||||
:::
|
||||
|
||||
## Start barrier
|
||||
|
||||
At host start and first load there is one more rule (chapter XIV): load every facet (the core facet with its start
|
||||
data among them), wait until every facet and `lifetime: "host"` object other than the harness is live or failed, then
|
||||
resume dependent work. Plugin reloads need no barrier.
|
||||
|
||||
## Loading and the dev loop
|
||||
|
||||
- **Roots:** plugins come from built-in, global (`<data>/plugins`), package (npm or git, installed with
|
||||
`--ignore-scripts`) and project roots, found by directory listing; no source parsing. The server selects which load.
|
||||
Dependencies between plugins resolve by contract name at runtime. Names are prefixed: objects `<plugin>.<name>`,
|
||||
tools `<plugin>_<name>`.
|
||||
- **Loader D:** one base bundle per surface (host packages and the app runtime) plus one script per facet, with host
|
||||
packages passed in (no module map). Old generations are reclaimed by the garbage collector; a stable filename per
|
||||
(facet, digest), and V8's compilation cache off on hosts that reload in place. The loader is an adapter: static
|
||||
imports (tests, Durable Objects) work the same, with the app passing the plugin name to `load`.
|
||||
- **Dev loop:** a source watcher marks plugins dirty and never applies anything. A reload control (the playground's web
|
||||
shell, a CLI, an agent tool such as p2's `reload_plugins`) reloads the changed plugins, or restarts all hosts for
|
||||
base code. Reloads are always explicit.
|
||||
|
||||
:::review | Review: project plugins (#14, deferred)
|
||||
Per-directory plugins with trust prompts and snapshots (`apps/p2/docs/plugin-loading.md`, still the reference for
|
||||
roots, trust and scopes) need their own pass over this design. Until then, treat the project root as designed but not
|
||||
settled.
|
||||
:::
|
||||
|
||||
:::note | Built so far
|
||||
Reload and rollout are layer L10, the last before telemetry. The frozen kernel has a reload path with host values and
|
||||
a development freeze, both removed from the design.
|
||||
:::
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
|
||||
# Changing Contracts {#contracts lede="How a contract gets a new version while old tabs, old workers and old plugins are still talking to it."}
|
||||
|
||||
Chapter XV ended with a tab that calls into a worker on another revision. That case is short-lived: the rollout
|
||||
finishes and both sides run the same code. Other cases last longer. A phone kept a tab open overnight with yesterday's
|
||||
web bundle. A plugin from another author was built against `Notes` version 1 and is not updated yet. In both, a
|
||||
consumer holds a contract that the provider no longer has. This chapter is about what the provider does then. It is
|
||||
also the one part of Chord v2 that is built: everything in the sections on `evolve`, `checkAccepts` and fingerprints
|
||||
runs today in `packages/chord/src/next/contracts/`.
|
||||
|
||||
## What has a version
|
||||
|
||||
Everything that is stored or crosses a boundary has a version. Each surface handles an old version its own way:
|
||||
|
||||
| Surface | Version | When an old one shows up | Chapter |
|
||||
| --- | --- | --- | --- |
|
||||
| object type (contract) | `version` plus fingerprint | `evolve` and `checkAccepts` on the provider; otherwise `contract_mismatch` | this one |
|
||||
| document | `version` plus schema fingerprint | pure `migrate` on access; a schema change without a bump fails the object `broken` | V |
|
||||
| job | `version` | `accepts` on outcomes and `running`; `blocked` without an adapter | XI |
|
||||
| registry | `version` | `accepts` adapts contributions; one without a match is left out and reported | XIII |
|
||||
| native | none | the object restarts on reload | IX |
|
||||
| Durable tasks and documents | Durable's `version` | Durable's `migrate` | XIV |
|
||||
| link protocol | protocol version | refused; hosts update together, a web page reloads | XVII |
|
||||
| UI components, Chord and app APIs | none yet | exposed as they are; an SDK will version them later | |
|
||||
|
||||
The rule behind every row is the same as for documents: **every change bumps the version**, additive ones included.
|
||||
Chord never guesses compatibility from the shape of two schemas.
|
||||
|
||||
## A history, not a file of versions
|
||||
|
||||
A contract history starts with a **base**: a full `remote()` or `local()` declaration with an explicit `version`. The
|
||||
`Notes` contract from chapters II and III is a base at version 1. Studio now wants pinned notes: notes get a `pinned`
|
||||
flag and editors get a `pin` method. Instead of editing the declaration, the plugin continues the history:
|
||||
|
||||
```ts
|
||||
// notes/contract.ts
|
||||
import { bool, evolve, nul } from "@earendil-works/chord/next";
|
||||
|
||||
export const NoteV2 = obj({ id: str(), text: str({ max: 2000 }), by: str(), at: int(), pinned: bool() });
|
||||
export const PublicNoteV2 = obj({ id: str(), text: str(), at: int(), pinned: bool() });
|
||||
|
||||
export const NotesV2 = evolve(Notes, (e) =>
|
||||
e.state("editor", "notes", obj({ items: arr(NoteV2) }))
|
||||
.state("reader", "notes", obj({ items: arr(PublicNoteV2) }))
|
||||
.addMethod("editor", "pin", obj({ id: str(), pinned: bool() }), nul()),
|
||||
);
|
||||
```
|
||||
|
||||
`evolve(prev, (e) => …)` appends one version to `prev`'s history: `NotesV2.version` is 2, and `NotesV2` still knows
|
||||
version 1 exactly as it was declared. Every version is **materialized and fingerprinted at declaration**. That is what
|
||||
makes old code safe: an old plugin bundle that contains its own copy of `Notes` version 1 computes the same
|
||||
fingerprint as the history inside `NotesV2`. There is no build step, no generated file and no fingerprint copied by
|
||||
hand.
|
||||
|
||||
The edits:
|
||||
|
||||
| Edit | Changes | Kind |
|
||||
| --- | --- | --- |
|
||||
| `e.state(variant, member, schema)` | one state member of one variant | remote |
|
||||
| `e.addState(variant, member, schema)` / `e.removeState(variant, member)` | adds or removes a state member | remote |
|
||||
| `e.state(member, schema)`, `e.addState(member, schema)`, `e.removeState(member)` | the same, flat | local |
|
||||
| `e.args(name, schema)` / `e.result(name, schema)` | one side of a method | both |
|
||||
| `e.method(name, args, result)` | both sides of a method | both |
|
||||
| `e.addMethod(variant, name, args, result)` | adds a method (or an existing one to another variant, same signature) | remote |
|
||||
| `e.addMethod(name, args, result)` | adds a method | local |
|
||||
| `e.removeMethod(name)` | removes a method from every variant | both |
|
||||
|
||||
Edits chain. The `method(...)` wrapper appears only in base declarations; edits take the schemas directly, so a
|
||||
method without a result is added with `nul()`. The rules of chapter III still hold across versions: a method has one
|
||||
signature in every variant, and a member's kind never changes, not even after it was removed and added again.
|
||||
|
||||
**What `evolve` cannot do:** change the input or the set of variants. Both change what an object *is* (its identity,
|
||||
who gets which members), and no adapter can turn one into the other. That needs a new history base, below.
|
||||
|
||||
## The provider says what it accepts
|
||||
|
||||
`NotesV2` says what changed. It does not say how version 1 consumers are served; the **provider** decides that, with
|
||||
mappings that live in its own code, never in the contract. The worker facet declares them with `checkAccepts`:
|
||||
|
||||
```ts
|
||||
// notes/worker.ts
|
||||
import { checkAccepts } from "@earendil-works/chord/next";
|
||||
|
||||
const toV1 = ({ pinned, ...note }: Static<typeof NoteV2>) => note;
|
||||
const toPublicV1 = ({ pinned, ...note }: Static<typeof PublicNoteV2>) => note;
|
||||
|
||||
const notesAccepts = checkAccepts(NotesV2, {
|
||||
1: {
|
||||
state: {
|
||||
editor: { notes: (current) => ({ items: current.items.map(toV1) }) },
|
||||
reader: { notes: (current) => ({ items: current.items.map(toPublicV1) }) },
|
||||
},
|
||||
methods: {}, // add and remove did not change; pin is new
|
||||
},
|
||||
});
|
||||
// notesAccepts.versions: [{ version: 1, fingerprint: Notes.fingerprint }]
|
||||
|
||||
host.serve(NotesV2, notesFactory, { accepts: notesAccepts }); // working name for the objects-layer option
|
||||
```
|
||||
|
||||
`checkAccepts` is **pure**: it checks the mappings without calling them and returns only the version metadata of the
|
||||
old versions it lists. A provider that lists nothing accepts only its current version. The objects layer later
|
||||
compares a consumer's version and fingerprint against this list.
|
||||
|
||||
The mappings point in the direction the data flows:
|
||||
|
||||
| Member in the old version | Mapping | Direction |
|
||||
| --- | --- | --- |
|
||||
| state, changed | `(currentMember) => oldMember` | provider's current value to what the old consumer expects |
|
||||
| state, removed | `(currentVariantState) => oldMember`, or `"refuse"` | receives the whole current state of that variant |
|
||||
| method arguments, changed | `args: (old) => current` | old caller's arguments to current ones |
|
||||
| method result, changed | `result: (current) => old` | current result back to the old shape |
|
||||
| method, changed or removed | `{ call: async (oldArgs, ctx) => oldResult }`, or `"refuse"` | replaces the method for old callers |
|
||||
| member added since | nothing | an old consumer never asks for it |
|
||||
|
||||
A changed state member cannot be refused: the old consumer still has that member and must get a value. A removed one
|
||||
can be computed from what the variant has now, or refused. `e.method(name, args, result)` replaces both sides and
|
||||
therefore needs both mappings, even if one side stayed the same (then an identity mapping).
|
||||
|
||||
**Required, at type level and again at runtime.** For every member that exists in the old version and changed or was
|
||||
removed since, the mapping is required by the type of `checkAccepts`. Because plugins are bundled without type
|
||||
checks, the same check runs at declaration: a missing mapping, a key for a member that needs none, an unknown version
|
||||
or a wrong shape throws, naming the member's path (for example `accepts[1].state.reader.notes`). An absent `state` or
|
||||
`methods` section counts as empty and may be left out only when nothing there needs a mapping.
|
||||
|
||||
**Conservative with no-op edits.** An edit that sets a schema to what it already was, or changes a member and then
|
||||
changes it back, still requires an identity mapping. TypeScript erases constraints such as `{ max: 2000 }` from the
|
||||
static type, so two schemas that look the same to the compiler may differ; Chord does not try to tell.
|
||||
|
||||
### Old versions map straight to the current one
|
||||
|
||||
Studio later replaces `remove` with `archive`, which keeps the note in a trash list:
|
||||
|
||||
```ts
|
||||
export const NotesV3 = evolve(NotesV2, (e) =>
|
||||
e.removeMethod("remove").addMethod("editor", "archive", obj({ id: str() }), nul()),
|
||||
);
|
||||
|
||||
const notesAccepts = checkAccepts(NotesV3, {
|
||||
1: {
|
||||
state: { editor: { notes: (c) => ({ items: c.items.map(toV1) }) }, reader: { notes: (c) => ({ items: c.items.map(toPublicV1) }) } },
|
||||
methods: { remove: { call: async ({ id }, ctx) => { await archiveNote(id, ctx); return null; } } },
|
||||
},
|
||||
2: { methods: { remove: { call: async ({ id }, ctx) => { await archiveNote(id, ctx); return null; } } } },
|
||||
});
|
||||
```
|
||||
|
||||
Each entry maps **one old version directly to the current one**, not through the versions in between. Version 1
|
||||
needs the state mappings (its notes have no `pinned`) and the `remove` replacement; version 2 needs only the
|
||||
`remove` replacement, and leaves `state` out because its state is unchanged. A member that an old version never had
|
||||
needs nothing for that version, even if it was added, changed and removed later. Dropping support for version 1 is
|
||||
deleting its entry: version 1 consumers then get `contract_mismatch`.
|
||||
|
||||
## Binding an old consumer
|
||||
|
||||
A hold carries only the consumer's contract **version and fingerprint**, never a description of the contract (R6).
|
||||
The provider decides with its own schemas:
|
||||
|
||||
:::flow wide animate | What a provider does with a hold. The decision uses only the provider's own history; a peer's schema is never an input.
|
||||
row "The hold arrives"
|
||||
box hold "hold" "version 1, fingerprint F1" blue
|
||||
box cur "equal to current?" "version and fingerprint"
|
||||
box acc "accepted old version?" "F1 from the provider's own history"
|
||||
row "Outcome" accent
|
||||
box live "bind" "current members"
|
||||
box adapted "bind adapted" "through the mappings" accent
|
||||
box mismatch "`contract_mismatch`" "or updating, during a rollout" muted
|
||||
arrow hold -> cur
|
||||
arrow cur -> live "yes"
|
||||
arrow cur -> acc "no"
|
||||
arrow acc -> adapted "yes"
|
||||
arrow acc -> mismatch "no"
|
||||
note "A mismatch fails only that consumer's binding. The provider object and its other bindings are not affected."
|
||||
:::
|
||||
|
||||
Once a version 1 tab is bound, it is served as if version 1 were current:
|
||||
|
||||
:::steps | A version 1 tab on a version 3 worker
|
||||
+++ Bind
|
||||
The tab holds `Notes { S1 }` with version 1 and fingerprint F1. The worker's history computes F1 for version 1, and
|
||||
`notesAccepts` lists version 1, so the hold binds adapted. The tab gets the `editor` variant as version 1 defined it:
|
||||
`notes`, `add`, `remove`. It never sees `pin` or `archive`.
|
||||
+++ State through the mapping
|
||||
Chord serves the adapted state like a derive of the provider's state: it applies the mapping to the current value
|
||||
and keeps the result per binding. The snapshot the tab receives has no `pinned` fields.
|
||||
+++ A commit
|
||||
The owner pins a note from a current tab. The worker sends the current tab the draft's ops. For the version 1 tab,
|
||||
Chord applies the mapping to the new value and sends the difference to its last adapted value (`diffRevisions`). The
|
||||
mapping produced nothing new for a pin, so the version 1 tab receives no change at all. A large list is never resent
|
||||
whole on every commit.
|
||||
+++ A call
|
||||
The tab calls `remove({ id })`. Its arguments are checked against the provider's own version 1 schema, then the
|
||||
`call` replacement runs `archiveNote` on version 3 code. Its `null` result is what version 1 expects. Had the method
|
||||
only changed, `args` would have mapped the arguments forward and `result` the result back.
|
||||
+++ The worker moves on
|
||||
A later revision drops the version 1 entry. When the tab re-holds (or the worker restarts and the tab binds again),
|
||||
the hold gets `contract_mismatch`, and Studio's panel shows "reload to update" instead of failing silently.
|
||||
:::
|
||||
|
||||
## A new history base
|
||||
|
||||
Some changes cannot be adapted. Studio decides that notes belong to a project, not a session: the input changes from
|
||||
`{ session }` to `{ project }`. That is a new base, written in full, with an explicit version that continues the
|
||||
published numbering:
|
||||
|
||||
```ts
|
||||
export const NotesV4 = remote("notes", {
|
||||
version: 4, // explicit: the published history used 1 to 3
|
||||
input: obj({ project: str() }),
|
||||
placement: () => Server,
|
||||
variants: {
|
||||
editor: { state: { notes: obj({ items: arr(NoteV2) }) }, methods: { add: method(…), pin: method(…), archive: method(…) } },
|
||||
reader: { state: { notes: obj({ items: arr(PublicNoteV2) }) } },
|
||||
},
|
||||
});
|
||||
// checkAccepts(NotesV4).versions is empty
|
||||
```
|
||||
|
||||
A new base has no history before its version and **cannot accept older versions**, not even for an additive change.
|
||||
Every consumer built against versions 1 to 3 gets `contract_mismatch` and must update. Later `evolve` calls continue
|
||||
from version 4. The same applies to a change of the variant set. Do not spread an older declaration into a new one or
|
||||
copy its fingerprint; the contract objects are branded and `evolve` and `checkAccepts` reject copies.
|
||||
|
||||
## Local contracts
|
||||
|
||||
`local` contracts (chapter IV) evolve the same way, with a flat members map and no variants:
|
||||
|
||||
```ts
|
||||
export const SessionInfoV2 = evolve(SessionInfo, (e) => e.state("init", InitV2));
|
||||
checkAccepts(SessionInfoV2, { 1: { state: { init: (current) => toInitV1(current) }, methods: {} } });
|
||||
```
|
||||
|
||||
Same-host consumers of a `local` type are usually reloaded together with their provider, but not always: another
|
||||
plugin on the same host may be built against an older version. The rules are identical; only the variant argument is
|
||||
missing.
|
||||
|
||||
## Implementing a contract
|
||||
|
||||
`Implementation<typeof NotesV2>` is the type of everything a provider must supply for the current version: one state
|
||||
getter per member and variant, and one asynchronous method per method, each receiving `(args, ctx)`:
|
||||
|
||||
```ts
|
||||
const impl: Implementation<typeof NotesV2> = {
|
||||
state: { editor: { notes: () => list.value }, reader: { notes: () => publicList.value } },
|
||||
methods: { add: async ({ text }, ctx) => addNote(text, ctx), remove: async ({ id }) => removeNote(id), pin: async (a) => pin(a) },
|
||||
};
|
||||
```
|
||||
|
||||
Keep declaration types **inferred** (no annotation on `NotesV2`): the inferred type carries the complete history, and
|
||||
`checkAccepts` needs it to know what changed.
|
||||
|
||||
:::review | Review: L1's implementation shape and the factory's return value
|
||||
`Implementation<C>` in `src/next/contracts/types.ts` is `{ state: { variant: { member: getter } }, methods }`. The
|
||||
design's factories (`design.md` §6, used throughout this guide) return `{ variant, member: value, method: fn }`.
|
||||
The objects layer (L4) has to connect the two, and also define how `serve` receives the checked accepts (this guide
|
||||
uses the working name `{ accepts }` as a third argument) and what `"refuse"` does at runtime. The natural reading,
|
||||
used in the stepper above, is that a refused method call and a hold that needs a refused state member end with
|
||||
`contract_mismatch`; no source says so.
|
||||
:::
|
||||
|
||||
## The fingerprint
|
||||
|
||||
The fingerprint is a 64-bit hash (`hash64`, portable across runtimes) of a **normal form** of the contract's
|
||||
schemas. The normal form decides what counts as a change:
|
||||
|
||||
| Ignored | Significant |
|
||||
| --- | --- |
|
||||
| order of object keys, `required` and `enum` (sorted) | order of union and intersection branches (`anyOf`, `oneOf`, `allOf`) and of tuples |
|
||||
| annotations on schema nodes: `title`, `description`, `examples`, `$comment`, `deprecated` | defaults, formats, patterns, bounds, `$ref` names |
|
||||
| placement | input, variants, member kinds and every member schema |
|
||||
|
||||
Union order is significant because it is meaning: TypeBox picks defaults and decodes by the first matching branch.
|
||||
Annotations are dropped only on schema nodes; an object property that happens to be named `title` is data and counts.
|
||||
|
||||
Only data schemas are allowed. One walker copies, validates and normalizes each schema against a **whitelist of
|
||||
TypeBox keywords**: primitives, arrays and tuples, objects and records, unions and intersections, `$defs` and `$ref`
|
||||
(use `$ref` for recursive types), constraints and annotations. Conditional and dependent schemas, `not`,
|
||||
`definitions`, `contentSchema`, `unevaluated*`, `contains`, `propertyNames`, unknown keywords, executable or custom
|
||||
TypeBox features, arbitrary `Unsafe` and refinements, accessors, symbols and cyclic objects are rejected at
|
||||
declaration. Bytes are the one special leaf: `bytes({ min, max })` checks a `Uint8Array` and has the data marker
|
||||
`{ type: "bytes", minLength?, maxLength? }`, so it hashes like any schema; TypeBox's checking still works on it.
|
||||
|
||||
Schemas are deep-copied into a private, frozen history; your own schema objects are copied, not frozen.
|
||||
`NORMAL_FORM_VERSION` is part of every digest, so a change of the normal form is a link protocol change.
|
||||
|
||||
**Not a security boundary.** A fingerprint says "we were compiled against the same contract"; it proves nothing. A
|
||||
hostile client can send any fingerprint it likes. Providers therefore always validate arguments with their own
|
||||
schemas (chapter IV), whatever the fingerprint says.
|
||||
|
||||
## Rules of thumb
|
||||
|
||||
- Treat a published contract like a published API: every change is `evolve`, every `evolve` needs a decision about
|
||||
old consumers.
|
||||
- Keep mappings small and pure. A state mapping runs for every commit and every adapted binding; make it a map over
|
||||
the value, not a query.
|
||||
- Accept the versions your users still run (one or two back during a normal rollout), and delete entries when the
|
||||
metrics show no old bindings.
|
||||
- Change the input or the variants only with a new base, and only when updating every consumer is acceptable.
|
||||
- Contract, document and job versions are separate and each is decided on its own. Pinned notes needed `NotesV2`
|
||||
for the wire and `NoteList` version 2 with a `migrate` that adds `pinned: false` (chapter V), but no new
|
||||
`ImportJob` version, because its outcome did not change.
|
||||
|
||||
:::note | Built so far
|
||||
This chapter's contract layer is built: `packages/chord/src/next/contracts/` (commit `7d2bb8ea8`, about 1,300 lines
|
||||
with `remote`, `local`, `method`, `evolve`, `checkAccepts`, `Implementation`, the schema helpers, `fingerprintOf`,
|
||||
`schemaNormalForm` and `NORMAL_FORM_VERSION`), with nine test files covering evolution, fingerprints, the hash,
|
||||
local contracts, long chains, schemas, types and review findings. It stands alone: it hosts nothing, calls no
|
||||
mapping and does not import the frozen runtime. Binding by version and fingerprint, adapted state and calls belong to
|
||||
the objects and protocol layers, not built yet.
|
||||
:::
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
|
||||
# On the Wire {#wire lede="What travels over a link, what a relay does with it, how big things are kept in check, what each hop costs, and how to see it in traces."}
|
||||
|
||||
Most plugin code never thinks about the wire: it declares state, commits transactions and calls methods, and Chord
|
||||
moves the bytes. You need this chapter when something is slow, when a value is large, when a link misbehaves, or when
|
||||
you want to read a trace. It also explains why the earlier chapters insisted on small operations, shared structure and
|
||||
no callbacks: each of those rules removes a cost on some hop.
|
||||
|
||||
## What a state member sends
|
||||
|
||||
Every exposed document, derive or readable is a **state member**. Over a hold it produces one of these:
|
||||
|
||||
| Update | When |
|
||||
| --- | --- |
|
||||
| `snap` | on hold and on re-hold: the whole value, once |
|
||||
| `ops` | per commit: the draft's recorded ops, no diff |
|
||||
| `ops` from a diff | per change of a derive (and of an adapted binding, chapter XVI) |
|
||||
| throttled `ops` | job progress |
|
||||
| `reset` | a migration commit replaced the value (adapted for older consumers) |
|
||||
| `error` | the member failed |
|
||||
|
||||
**Commits are atomic for consumers.** On the same host, subscribers are notified once, after every document of the
|
||||
commit is adopted. Remotely, one `state` message per hold carries a batch of member updates that is applied before
|
||||
listeners run, and a commit that touches several holds on one link travels as **one** `state` message on that link.
|
||||
A consumer never sees half a commit.
|
||||
|
||||
## Messages
|
||||
|
||||
A message is a **header plus opaque parts** (R1). There is one part per hold the message concerns; the header says
|
||||
which hold each part belongs to and how large it is.
|
||||
|
||||
| Direction | Kind | Parts |
|
||||
| --- | --- | --- |
|
||||
| consumer to provider | `hold`, `release`, `call`, `cancel` | `call` has one (the arguments) |
|
||||
| provider to consumer | `bound` | one: the snapshot |
|
||||
| | `state` | one per hold the commit touched on this link |
|
||||
| | `result` | one |
|
||||
| | `gone` | none |
|
||||
|
||||
There are no request ids in application code. Hold ids and call ids exist only inside the protocol, per link.
|
||||
|
||||
**One binary format on every link (Q4):** `u32 header length | header JSON | part bytes`, as one `Uint8Array`. A
|
||||
WebSocket sends it as a binary frame, IPC passes it with `serialization: "advanced"`, a memory link delivers it
|
||||
asynchronously. Encoding, framing, header checks, id resolution, flow control and size limits are one shared protocol
|
||||
layer; link adapters only move bytes. Tests on memory links therefore run the production path.
|
||||
|
||||
A part is encoded **once, at its origin**, with that hold's own Delta encoder (paths are interned per hold, end to
|
||||
end between provider and consumer). A JSON part above a threshold (about 16 KiB) is compressed once at its origin,
|
||||
whatever the link; byte segments stay raw; relays forward compressed parts as they are; the header names the codec.
|
||||
|
||||
**The header is a TypeBox schema (R4)**, a union tagged by `kind`; the protocol types derive from it. Headers from
|
||||
untrusted links (client WebSockets) are checked with the schema compiled once; trusted links (IPC between server and
|
||||
workers) are not checked. **Unknown ids are ignored on both sides (Q1).** They are normal races: a `result` crossing a
|
||||
`cancel`, a `call` crossing a `gone`, a state part crossing a `release`. The side that ended a hold or call settles
|
||||
its own pending work: a consumer that receives `gone(h)` rejects its pending calls on `h`, and the order per
|
||||
direction guarantees nothing else for `h` follows. Only a header that fails the schema is a protocol error, and it
|
||||
closes that link.
|
||||
|
||||
The link protocol has a version. Hosts with different protocol versions refuse each other; a web page reloads to get
|
||||
the server's protocol.
|
||||
|
||||
## Through a relay
|
||||
|
||||
A tab never talks to a worker directly (chapter I). The server **relays**, and a relay reads and rewrites only
|
||||
headers: it maps hold and call ids between its two links, stamps the principal on `hold` and `call` (a client's
|
||||
principal field is always overwritten; only a trusted parent link may carry one), and groups parts per destination
|
||||
link, so each link gets only its own holds' parts. It never parses a payload.
|
||||
|
||||
:::sequence animate | The owner adds a note while Ann watches. The commit leaves the worker as one `state` message with two parts; the server splits it by destination without decoding either part.
|
||||
actor owner "owner's tab" "hold h1" blue
|
||||
actor ann "Ann's tab" "hold h2" muted
|
||||
actor server "server" "relay" accent
|
||||
actor worker "worker S1" "NoteStore, Notes"
|
||||
owner -> server: call c7 Notes.add (1 part)
|
||||
server -> server: map ids, stamp owner
|
||||
server => worker: call c3 (part forwarded as is)
|
||||
worker -> worker: method runs, self.tx commits
|
||||
worker => server: state: parts for h1', h2'
|
||||
server -> server: group parts per link
|
||||
server --> owner: state (h1 part)
|
||||
server --> ann: state (h2 part)
|
||||
worker --> server: result c3
|
||||
server --> owner: result c7
|
||||
:::
|
||||
|
||||
The `state` message reaches the owner's tab before the `result`, so when `await notes.add(…)` resolves, the new note
|
||||
is already in `notes.value`. Ann's view is a different variant (reader) and gets its own part from its own derive; the
|
||||
relay neither knows nor cares.
|
||||
|
||||
## Flow control
|
||||
|
||||
Each link has **one byte window**, counted from each message's `byteLength` (parts are already encoded bytes; nothing
|
||||
is measured or stringified to count it). The window covers every message, first snapshots included.
|
||||
|
||||
- **Snapshots are always admitted (R19)**, even when larger than the window; they use it up. Whether a hold
|
||||
**overflows** is decided on the changes queued after the snapshot.
|
||||
- **Under backpressure** a commit's ops are collected per hold and encoded into one part when credits return. The
|
||||
consumer still gets the commit atomically.
|
||||
- **After an overflow** the hold is dropped and established again once the previous snapshot's debt has drained
|
||||
(credits back at the full window, Q3), and not more often than a minimum interval. So there is at most one snapshot
|
||||
in flight per hold: no re-hold storm, and no stall for a snapshot larger than the window.
|
||||
- **Calls** are throttled by call credits the provider grants (a generous window). Beyond them calls wait locally.
|
||||
Nothing is refused for being "too many", and holds are not limited by count; authorization and input validation
|
||||
protect them.
|
||||
- **Byte-heavy traffic** such as terminals uses separate channels (chapter XIII), so a large state message never
|
||||
holds up keystrokes.
|
||||
|
||||
**Recovery:** after a link drops, the consumer re-holds every hold over the new link and receives snapshots; calls in
|
||||
flight fail as on any disconnect, calls made meanwhile park. A relay whose window overflows releases the hold and
|
||||
re-holds; the provider sends a fresh snapshot. After a routed link drops, Chord re-resolves the address (with the
|
||||
per-address backoff) while holds on it exist, so a passive viewer recovers without anyone pressing anything.
|
||||
|
||||
## Size limits
|
||||
|
||||
Limits apply **per part** (R2, Q2), both to the compressed and the decoded size, and are checked from the sizes in the
|
||||
header before anything is decoded; zlib's `maxOutputLength` enforces the decoded limit in the one decode function.
|
||||
|
||||
| Situation | What fails |
|
||||
| --- | --- |
|
||||
| a part over the limit | only its own hold or call, with `too_large`; the other parts of the message apply |
|
||||
| a snapshot over the limit | its hold ends `failed`, with no automatic re-hold |
|
||||
| a WebSocket frame over `maxPayload` (e.g. 64 MiB) | the connection closes (code 1009); this is the transport's hard cap |
|
||||
|
||||
Defaults for client WebSockets are 16 MiB compressed and 64 MiB decoded per part, configurable per link kind. The
|
||||
decoded limit travels with relayed work: the server stamps the admitting link's limit on the header at admission
|
||||
(never taken from the client), and the host that decodes enforces it. IPC therefore needs no limit of its own.
|
||||
|
||||
Large data is never a large state member or a large argument. Files go through chunked transfers or the HTTP
|
||||
component (chapter XIII); long lists are keyed documents or bounded windows (chapter VIII).
|
||||
|
||||
## What each hop costs
|
||||
|
||||
Chord v2's goal is as little compute and memory as possible while staying correct: **O(size of the change)** per
|
||||
commit and per viewer, independent of the size of the value. A 500 KB value is touched as a whole only when a new
|
||||
viewer takes its first snapshot. Delta, the value library under documents, was built for this, and its own primitives
|
||||
are almost free: `diffRevisions` on shared subtrees takes 0.018 ms, `applyImmutable` of a small change 0.004 ms.
|
||||
|
||||
| Hop | Work |
|
||||
| --- | --- |
|
||||
| commit | Delta's `prepare` (copies and checks only the values placed in this commit), adopt by pointer swap; storage writes the delta |
|
||||
| reads in a transaction | the draft overlay, no copy; an unchanged draft writes nothing |
|
||||
| subscribers on the same host | the same value and ops, by reference |
|
||||
| derives | compute plus `diffRevisions`; unchanged parts stay the same objects (`mapShared`, chapter VIII) |
|
||||
| send | one encoding per hold part at its origin, size taken from the encoded bytes |
|
||||
| relay | header only: ids mapped, principal stamped, parts grouped; parts never parsed |
|
||||
| receive | compiled header schema on untrusted links; arguments stripped and validated once at the provider (compiled schema); inputs normalized |
|
||||
| apply | `applyImmutable`, structure shared |
|
||||
| new hold | the value encoded once (unavoidable) |
|
||||
| memory | one authoritative value per document per host; replicas and derived values share structure; one cached value per adapted binding |
|
||||
|
||||
These are targets. The frozen kernel measured far worse, which is why the design is so strict here:
|
||||
|
||||
:::stats
|
||||
- 35 ms | one small change to a 500 KB value in the frozen kernel, no viewer
|
||||
- +13 ms | per viewer on top (10 viewers: 130 ms)
|
||||
- 17 / 33 / 101 ms | a tiny SQLite commit after 0 / 10 / 50 earlier deltas
|
||||
:::
|
||||
|
||||
Nearly all of that came from layers around Delta: whole-value validation on every access, whole-value freezing,
|
||||
JSON-ness walks on every received frame, double stringification to measure a frame, snapshots cloned per viewer, and
|
||||
a SQLite storage that replayed history, and schemas rebuilt for every check (a compiled TypeBox check takes
|
||||
0.012 ms, an interpreted one 5.6 ms). The redesign removes each of them (chapters V, VIII and this one); the
|
||||
before/after per hop is in `apps/p2/docs/p3-reviews/v2-cost-model.md`.
|
||||
|
||||
:::review | Review: v2-cost-model.md is partly outdated
|
||||
The measurements and the per-hop "after" targets in `apps/p2/docs/p3-reviews/v2-cost-model.md` are current. Its
|
||||
closing section "Decisions this needs" is not: items 6 and 7 (validation by the caller's trust; contract descriptions
|
||||
once per link and fingerprint) were replaced by R7 (validate for every caller) and R6 (no descriptions on the wire),
|
||||
and §10 still describes the description fetch.
|
||||
:::
|
||||
|
||||
### Cost guards
|
||||
|
||||
Chord reports **full-value walks, deep copies, encodes, schema validations and materializations** as ordinary
|
||||
telemetry metrics, always on; with the NOOP adapter they cost an empty call where such work happens. Tests use the
|
||||
in-memory adapter and assert them per hop. The reference case: one appended note to a 500 KB document with 10 viewers
|
||||
does no walks, no copies, one encode per hold part at its origin and none at relays, and no validations. A
|
||||
`vitest bench` suite measures the hops.
|
||||
|
||||
### Ways to make it slow anyway
|
||||
|
||||
| Mistake | Why it costs | Instead |
|
||||
| --- | --- | --- |
|
||||
| a derive that builds new objects for unchanged data | every commit diffs and sends O(value) | keep unchanged items identical: `mapShared`, `previous` (chapter VIII) |
|
||||
| one long array that changes often (R24) | sharing is per path; each change copies one slot per element and visits each | keyed documents or a bounded window |
|
||||
| bytes as base64 strings | inflated, parsed, compressed as text | `bytes()` leaves; chunked transfers for files |
|
||||
| awaiting I/O inside `self.tx` | the object's line is blocked for every caller | do the I/O first, then a short transaction |
|
||||
| storing registry entries or references in documents | stale after a reload, not data | look up the current value at use |
|
||||
| opening natives at module level or in a facet | no owner: never closed, never restarted | `self.open` in an object (chapter IX) |
|
||||
| callbacks as arguments | cannot cross a link, keep old code alive | registries, jobs, flows |
|
||||
| one big object that everybody views | one large snapshot per viewer and per reconnect | split by viewer need; views of the same variant share one value |
|
||||
|
||||
## Telemetry
|
||||
|
||||
Chord reports traces, events and metrics through `@earendil-works/pi-telemetry`. Each host gets **one adapter**,
|
||||
`createHost({ telemetry })`, NOOP by default; the adapter decides sampling, buffering, export and filtering. Only hosts
|
||||
hold the adapter, to start root spans. Code sees spans:
|
||||
|
||||
- `ctx.telemetry` is the current span (`Ctx` extends `Context`, so it is the same field Durable and pi-ai read). Every
|
||||
span carries its adapter's metrics and propagation, so code with a `ctx` can record a metric or inject a carrier.
|
||||
- `ctx.trace(name, attributes, fn)` runs `fn` in a child span with a child `ctx`.
|
||||
- App values such as the principal stay out of Durable's context.
|
||||
|
||||
**Across links**, `call` and `hold` carry the caller's trace carrier as a bounded header field (R5: limited key count
|
||||
and string lengths); the provider runs the method under it, and each relay adds a `chord.relay` span. The call from the
|
||||
sequence above, as a trace:
|
||||
|
||||
```text
|
||||
chord.call Notes.add [owner's tab]
|
||||
└─ chord.relay [server]
|
||||
└─ chord.method Notes.add [session:S1] principal=owner
|
||||
· event chord.commit docs=2 ops=5
|
||||
```
|
||||
|
||||
Adapters trust typed input: no validation of malformed values and no try/catch around recording. Only carriers from
|
||||
another process are checked (keys present, strings), and a bad one falls back to a new root.
|
||||
|
||||
**Shared work** (one activation serving several `get`s, a worker start) is its own root span, linked to the trigger
|
||||
that started it; each waiting span links to it. Routine runs, job runs, eviction, backoff retries and background
|
||||
closes are root spans too, linked to their trigger where there is one.
|
||||
|
||||
| Tier | What |
|
||||
| --- | --- |
|
||||
| spans | calls, methods, relays, holds, activations, opens, closes, loads, revocations, resolves, routine runs, job runs |
|
||||
| events | commits inside methods, retries, parking |
|
||||
| metrics | state messages, WebSocket bytes, commit durations, in-flight calls, active holds, open links, park durations, backpressure waits, the cost guards |
|
||||
|
||||
Ids are attributes of spans, never of metrics. **Nothing is measured or walked for telemetry**; only sizes the code
|
||||
already has are reported. Errors carry a fixed category and a message cut to 500 characters; cancelling is not an
|
||||
error. Chord ships a typed telemetry schema, as pi-ai (`AI_TELEMETRY_SCHEMA`) and Durable
|
||||
(`DURABLE_TELEMETRY_SCHEMA`) do.
|
||||
|
||||
**Storing and reading:** `@earendil-works/chord/telemetry-sqlite` over `chord/sqlite` (Node and Cloudflare) stores one
|
||||
row per settled span (events and links inline) and metrics aggregated per series per 10 s, flushes in batches every
|
||||
second, keeps 7 days, and drops (and counts) beyond a bounded queue. The read interface `TelemetryStore` is shared:
|
||||
`findTraces` (by name, attributes, errors, time window, with limit and cursor) returns complete traces, `getTrace`
|
||||
one, `findMetrics` metrics. One renderer, `pi-telemetry/render`, prints traces as above; it prints links and never
|
||||
follows them. The playground's trace viewer reads the same store.
|
||||
|
||||
**Durable** already emits submit and phase spans, stores carriers on submissions and tasks (`TaskOptions.trace`),
|
||||
and pi-ai emits `pi.ai.request` and `pi.ai.poll`. With the harness given the host's adapter (chapter XIV), an agent
|
||||
tool that calls `Notes.add` through `chord(fn, context)` shows up as one tree from the model request down to the
|
||||
commit.
|
||||
|
||||
:::note | Built so far
|
||||
Nothing of the protocol is built in v2: messages, relays, flow control and limits are layers L6 and L7, telemetry in
|
||||
Chord is L11. Built in pi: `pi-telemetry` (propagation, links, metrics, the store interface, the renderer),
|
||||
`chord/context` with `Context.telemetry`, pi-ai and Durable telemetry, and `chord/telemetry-sqlite`. Delta is built;
|
||||
`mapShared` and bytes as atomic leaves in `diffRevisions` are still to do. The frozen kernel sends descriptions on the
|
||||
wire and closes a link on an unknown id; both are replaced.
|
||||
:::
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
|
||||
# Building p2: the Core {#p2-core lede="From a list of what the app must do to a set of owners: the base code every p3 plugin builds on."}
|
||||
|
||||
The notes plugin used every Chord concept. A real app is the same concepts many times over. This chapter and the
|
||||
next two assemble **p3**, the Chord v2 version of p2, from the parts the guide introduced. p3 is built next to p2 in
|
||||
`apps/p3`, fully isolated with its own data directory, and **without migrating p2's data** (no import of
|
||||
`catalog.sqlite`, `shares.sqlite` or the subagents table). Its plugins are derived from what the app must do, not
|
||||
ported one to one from p2's plugin layout.
|
||||
|
||||
:::review | Review: p3 is not designed yet
|
||||
The design fixes the rules (`design.md` §25) and lists the p3 decisions still open (#17, #18, #20 to #23); the
|
||||
per-domain design of p3 has not started. What these three chapters show is one construction that follows the rules,
|
||||
derived from the functionality map in `apps/p2/docs/p3-functions/`. Object names are working names. Where a domain
|
||||
has an open decision, the text says which.
|
||||
:::
|
||||
|
||||
## Start from functions, not from plugins
|
||||
|
||||
The functionality map in `apps/p2/docs/p3-functions/` lists what p2 does, in plain words, with four facts per
|
||||
function: the data and its size, how durable it is, who may see or do it, and where it must be available. It groups
|
||||
the functions into 22 domains:
|
||||
|
||||
| # | Domain | # | Domain |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | sessions | 12 | access |
|
||||
| 2 | session workers | 13 | models, credentials, settings |
|
||||
| 3 | conversations and agent | 14 | prompt resources |
|
||||
| 4 | shell commands | 15 | status and presence |
|
||||
| 5 | chat mode | 16 | search |
|
||||
| 6 | agents (subagents, one-shots, peer messages) | 17 | remote access and forwards |
|
||||
| 7 | code mode | 18 | logs and notices |
|
||||
| 8 | session files | 19 | dev and reload |
|
||||
| 9 | environments and execution | 20 | web shell |
|
||||
| 10 | workspace | 21 | TUI shell |
|
||||
| 11 | terminals | 22 | transport |
|
||||
|
||||
The four facts answer the four questions every Chord object needs, in order:
|
||||
|
||||
:::flow wide animate | From a function in the map to code. Each fact in the map answers one question Chord needs answered.
|
||||
row "The map says"
|
||||
box data "data" "what, how big"
|
||||
box dur "durability" "ephemeral · until restart · persisted · transcript-bound"
|
||||
box who "who" "owner · guest read · guest write · worker"
|
||||
box where "where" "server · worker · web · TUI · environment"
|
||||
row "Chord needs" accent
|
||||
box owner "the owner" "smallest object it belongs to" accent
|
||||
box store "where it is kept" "Durable, persisted or in-memory document, tab"
|
||||
box variant "variants" "and revocation" blue
|
||||
box host "host and code" "placement; core or plugin"
|
||||
arrow data -> owner
|
||||
arrow dur -> store
|
||||
arrow who -> variant
|
||||
arrow where -> host
|
||||
note "Durability decides storage: transcript-bound data is Durable's; persisted app data is a persisted document with a stable identity; until-restart data is an in-memory document or a native; tab-scoped UI state stays in the client."
|
||||
:::
|
||||
|
||||
**One owner per thing, the smallest object it belongs to.** A guest's grant belongs to the access owner, not to the
|
||||
session; a draft belongs to one (conversation, author) pair, not to the chat plugin as a whole. Ownership nests, and
|
||||
rules set at creation are written in the creating commit (Durable's `createConversation({ init })`).
|
||||
|
||||
**Core or plugin.** Core owners are what the app always needs and everything builds on; they are always present, so
|
||||
other code may depend on them directly. Plugin owners may be missing; anything that depends on an optional feature
|
||||
does so through a registry it owns itself (chapter XIII). The risk to avoid is a **core god object**: each core owner
|
||||
owns only what belongs to it.
|
||||
|
||||
## The ownership map
|
||||
|
||||
The result of that procedure for p3, as a starting point:
|
||||
|
||||
| Domain | Owner object(s) | Host | Code |
|
||||
| --- | --- | --- | --- |
|
||||
| access | `Access` (grants, devices, share links), `authenticate` | server | core |
|
||||
| sessions | `Catalog` (session records), lifecycle jobs | server | core |
|
||||
| session workers | the supervisor (`route(Session)` + idle stop) | server | core (app code) |
|
||||
| session location | `SessionLocation { session }` | worker | core |
|
||||
| conversations and agent | `Harness`, `ConversationStore`, `Conversation` views | worker | core |
|
||||
| status and presence | `SessionStatus` (server copy), reporters on workers | both | core |
|
||||
| shell commands | `ShellJob` in the chat plugin | worker | plugin |
|
||||
| chat mode | chat plugin | worker | plugin |
|
||||
| agents | subagents plugin (Durable child conversations) | worker | plugin |
|
||||
| code mode | code mode plugin (sandbox native) | worker | plugin |
|
||||
| session files | `SessionFiles { session }` (proposed, #17) | server | core or plugin, open |
|
||||
| environments | envs plugin (config, SSH pools #20) | server and worker | plugin |
|
||||
| workspace | workspace plugin | worker | plugin |
|
||||
| terminals | terminal host process (#23), terminals plugin | separate process | plugin |
|
||||
| models, credentials | models plugin, login job + flow | server | plugin |
|
||||
| settings | `Settings` (single writer, #21) | server | core |
|
||||
| prompt resources | prompt plugin (watchers in the session's environment) | worker | plugin |
|
||||
| search | search plugin (index on the server, feed on workers) | both | plugin |
|
||||
| remote access, forwards | radius plugin, ports plugin | server | plugin |
|
||||
| logs and notices | `Notices` (one channel), log capture | server | core |
|
||||
| dev and reload | the reload control, `reload_plugins` tool | server | plugin |
|
||||
| web and TUI shells | UI facets over the objects above | clients | shells |
|
||||
|
||||
The rest of this chapter builds the core rows. Chapters XIX and XX build the plugins.
|
||||
|
||||
## The base code
|
||||
|
||||
```text
|
||||
apps/p3/
|
||||
src/
|
||||
hosts.ts Server, Session host kinds
|
||||
principal.ts P, Revocation, principalKey, revokedBy, can()
|
||||
server/main.ts createHost, load, route(Session), WebSocket and Radius entry points, supervisor
|
||||
worker/main.ts createHost with the start data, connect to the parent
|
||||
core/ the core facets: Access, Catalog, SessionStatus, Settings, Notices (server);
|
||||
SessionInfo, SessionLocation, Harness, ConversationStore (worker)
|
||||
plugins/ chat, subagents, codemode, envs, workspace, terminals, models, login,
|
||||
prompt, search, ports, radius, files, dev
|
||||
```
|
||||
|
||||
`hosts.ts`, `principal.ts`, the `SessionInfo` start data and the `Harness` object are the ones the guide already
|
||||
built for Studio (chapters I, III, IX and XIV). Base code restarts the hosts when it changes; everything in
|
||||
`plugins/` reloads.
|
||||
|
||||
## Who is calling: authentication
|
||||
|
||||
Chord stamps principals; the app produces them. p3's server has four ways in, each turned into a principal by
|
||||
`authenticate(req)` when a WebSocket connects (chapter III):
|
||||
|
||||
| Way in | Principal |
|
||||
| --- | --- |
|
||||
| loopback without a cookie (TUI, CLI, first page load) | `{ kind: "owner", access: "local" }` |
|
||||
| loopback owner browser cookie (a per-port secret file) | `{ kind: "owner", access: "local" }` |
|
||||
| a device cookie (HMAC-signed, per port, 30 days), obtained with a one-time sign-in token | `{ kind: "owner", access: "device", device }` |
|
||||
| a guest cookie from a share link | `{ kind: "guest", session, mode, grant, revision }` |
|
||||
| the Radius relay channel | `{ kind: "owner", access: "relay" }` |
|
||||
|
||||
The checks that are not Chord's stay in this function: origin checks and Fetch Metadata (a browser request from
|
||||
another site gets no principal, except a top-level navigation), proxy trust, the sign-in token rules (10 minutes,
|
||||
single use, at most 32 outstanding, memory only), cookie expiry and revocation lists. A failed check closes the socket
|
||||
before Chord sees it.
|
||||
|
||||
**One permission matrix.** Every function in the map names who may use it, and p2 checks those rules in several
|
||||
places. p3 writes them once, in `principal.ts`, and every factory and `authorize` uses it:
|
||||
|
||||
```ts
|
||||
// src/principal.ts
|
||||
type Action = "read" | "send" | "upload" | "stop" | "workspace" | "terminal" | "manage";
|
||||
|
||||
export function can(p: P, action: Action, session?: string): boolean {
|
||||
if (p.kind === "owner" || p.kind === "server") return true;
|
||||
if (p.kind === "worker") return action !== "manage" && p.session === session;
|
||||
if (p.kind !== "guest" || p.session !== session) return false;
|
||||
if (action === "terminal" || action === "manage") return false; // terminals: owner only
|
||||
if (p.mode === "read") return action === "read"; // no workspace for guest read
|
||||
return true; // guest write: send, upload, stop, workspace
|
||||
}
|
||||
```
|
||||
|
||||
Factories pick variants from it (`can(p, "send", session) ? "writer" : "reader"`), so the matrix is visible in one
|
||||
file and the members a caller lacks are simply not there.
|
||||
|
||||
## Access: grants, devices, share links
|
||||
|
||||
`Access` is the core owner of everything that lets someone in: share links per session and mode, guest records, the
|
||||
device list and the signing key. It lives on the server as the chapter IV pattern: a `local` store that persists the
|
||||
documents (only stable identities may), and a `remote` view for the owner's settings screen:
|
||||
|
||||
```ts
|
||||
// src/core/access.ts (server, in the core server facet)
|
||||
const Links = defineState("access.links", { schema: map(ShareLink), version: 1, persist: true,
|
||||
initial: () => ({}), checkpointWhen: (_v, _o, info) => info.deltasSinceBase > 64 });
|
||||
const Guests = defineState("access.guests", { … }); // same shape: map(Guest), persisted
|
||||
const Devices = defineState("access.devices", { … }); // map(Device), persisted
|
||||
|
||||
host.serve(AccessStore, (_input, self) => { // local: may persist, acts for the host
|
||||
const links = self.state(Links), guests = self.state(Guests), devices = self.state(Devices);
|
||||
return {
|
||||
links, guests, devices,
|
||||
removeGuest: async ({ grant }, ctx) => {
|
||||
await self.tx(async (tx) => { (await tx.draft(guests))[grant].blocked = true; }, ctx);
|
||||
host.revoke({ grant }); // live access ends now
|
||||
},
|
||||
resetLink: async ({ session, mode }, ctx) => {
|
||||
const blocked = await self.tx((tx) => rotateLink(tx, links, guests, session, mode), ctx);
|
||||
for (const grant of blocked) host.revoke({ grant });
|
||||
},
|
||||
signOut: async ({ device }, ctx) => {
|
||||
await self.tx(async (tx) => { (await tx.draft(devices))[device].revoked = true; }, ctx);
|
||||
host.revoke({ device });
|
||||
},
|
||||
… // shareLink, join, signOutAll
|
||||
};
|
||||
});
|
||||
|
||||
host.serve(Access, (_input, principal, self) => { // remote view: owner only
|
||||
if (!can(principal, "manage")) throw forbidden();
|
||||
const store = self.get(AccessStore, {});
|
||||
return {
|
||||
variant: "owner",
|
||||
guests: self.derive("guests", [store.guests], ([g]) => publicGuests(g)), // names, modes; no tokens
|
||||
removeGuest: (args, ctx) => store.removeGuest(args, ctx),
|
||||
resetLink: (args, ctx) => store.resetLink(args, ctx),
|
||||
signOut: (args, ctx) => store.signOut(args, ctx),
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
`authenticate` is base code on the same host and checks `AccessStore`'s current documents on every new connection
|
||||
(it holds a reference to the store, taken with `server.root` at start). Live access and lasting denial stay separate, as chapter VII explained:
|
||||
|
||||
| Event | Lasting effect (data) | Live effect |
|
||||
| --- | --- | --- |
|
||||
| remove a guest | guest record blocked | `revoke({ grant })` |
|
||||
| reset a share link | new token; its guests' grants blocked | `revoke({ grant })` for each |
|
||||
| archive a session | grants of that session void while archived | `revoke({ session })` |
|
||||
| restore a session | grants valid again; revision bumped | none: guests reconnect |
|
||||
| sign out a device | device revoked until its expiry | `revoke({ device })` |
|
||||
| sign out all devices | signing key rotated, tokens cleared | `revoke({ device })` for each |
|
||||
|
||||
A guest who rejoins with the same browser keeps its identity; a removed guest cannot mint a new one, because the
|
||||
block is in `Guests` and `authenticate` refuses it. Names are unverified, cleaned, at most 40 characters, unique per
|
||||
session ("Ann (2)").
|
||||
|
||||
## The catalog
|
||||
|
||||
`Catalog` owns the session records: id, title (and whether it was edited), location, model default, pinned,
|
||||
archived, timestamps. It lives on the server, persisted, and it **never starts a worker**: listing sessions, showing
|
||||
their status or their unread marks reads server-side copies only.
|
||||
|
||||
```ts
|
||||
export const Catalog = remote("p3.catalog", {
|
||||
version: 1,
|
||||
input: obj({}),
|
||||
placement: () => Server(),
|
||||
variants: {
|
||||
owner: {
|
||||
state: { sessions: obj({ items: arr(SessionRow) }) },
|
||||
methods: {
|
||||
create: method(CreateArgs, str()), rename: method(RenameArgs), pin: method(PinArgs),
|
||||
archive: method(obj({ id: str() })), restore: method(obj({ id: str() })),
|
||||
},
|
||||
},
|
||||
guest: { state: { sessions: obj({ items: arr(SessionRow) }) } }, // only the guest's own session, if not archived
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Behind it sits a persisted `local` `CatalogStore`; the guest variant is a derive over it, as in chapter IV. The lifecycle operations (archive, clone, fork,
|
||||
relocate, delete) are chapter XIX; here only the rule that keeps the catalog small: **the catalog does not know which
|
||||
plugins exist.** Archiving a session writes `archived: true` and revokes the session; every plugin that keeps
|
||||
per-session things reconciles from `catalog.sessions` with a routine (chapter X), as the notes plugin did. The ports
|
||||
plugin closes forwards of archived sessions that way, also after a crash.
|
||||
|
||||
## On the worker: start data, location, harness
|
||||
|
||||
Three core objects exist on every worker from its start:
|
||||
|
||||
- **`SessionInfo`** (chapter IX): the start message as `constant(init)`, with the session id, data directory and the
|
||||
location the server chose at start.
|
||||
- **`SessionLocation { session }`** (working name), lifetime host: the current cwd and environment, with a location
|
||||
revision. Relocation changes it while the worker runs (chapter XIX); natives that depend on the location follow it
|
||||
with a routine and a generation (chapter IX), and conversations that follow the session's cwd are listed in the core
|
||||
per-conversation policy document.
|
||||
- **`Harness`** (chapter XIV): the Durable harness for the session's store, one SQLite database per session, opened
|
||||
under an exclusive lock keyed by the real data directory and session id. It refuses to start when the saved root
|
||||
model is not configured, and resumes interrupted Durable work on start.
|
||||
|
||||
`ConversationStore` and the `Conversation` views in front of it are chapter XIX.
|
||||
|
||||
## Workers: start, keep alive, stop
|
||||
|
||||
Starting, supervising and stopping workers is app code (chapter I); Chord only routes and counts. p3's supervisor is
|
||||
`server.route(Session, { authorize, resolve })` plus a small server object that keeps per-session worker state:
|
||||
|
||||
:::steps | A worker's life, as the supervisor sees it
|
||||
+++ Authorize, then start
|
||||
A tab gets `Notes { S1 }`. `authorize` checks `can(p, "read", "S1")` and that the session exists and is not archived
|
||||
(an archived session refuses, except an index rebuild that starts the worker without resuming work). `resolve` forks
|
||||
`worker/main.ts` with the start data from the catalog, bounded by a 60 s readiness timeout.
|
||||
+++ A start failure sticks
|
||||
If the worker fails to start (an unconfigured environment, a missing model), the error is kept per session and
|
||||
every `get` fails at once with it, until the owner presses Retry. That clears the error and the related notices.
|
||||
Guests see that the session is unavailable, not the error text.
|
||||
+++ Counting
|
||||
The core facet on the worker reports `host.activity` (`{ references, calls, jobs, lingering }`) and the harness's
|
||||
running Durable task count to the supervisor object, from a routine whenever either changes. The call carries
|
||||
`ctx.incarnation`, so a report from an older worker incarnation is ignored (chapter X).
|
||||
+++ Idle stop
|
||||
When everything has been zero for five minutes (checked every second, as in p2), the supervisor stops the worker:
|
||||
the worker closes its host (routines stop, natives close, storage flushes) and exits. Durable work stays persisted
|
||||
and resumes on the next start. Detached work that must keep the worker alive (a shell command whose caller left) is
|
||||
a job, so it counts.
|
||||
+++ Crash
|
||||
If the worker exits on its own, the link drops. The supervisor records the exit code and a redacted stderr tail
|
||||
(at most 4,000 characters) for that incarnation and posts one notice. References that still exist make Chord call
|
||||
`resolve` again with backoff; a passive viewer recovers without a click.
|
||||
+++ Shutdown
|
||||
On server shutdown the supervisor stops every worker, with a forced exit after three seconds. Durable work remains
|
||||
persisted.
|
||||
:::
|
||||
|
||||
## Status without a worker
|
||||
|
||||
The session list shows per session whether it is running, busy (which conversations), its task count, usage and cost,
|
||||
and an error. None of that may start a worker. The pattern is chapter X's pub/sub: a reporter on each worker (a
|
||||
routine over the harness's state) calls `SessionStatus.report(summary)` on the server; `SessionStatus` keeps the latest
|
||||
value per session and ignores calls whose `ctx.incarnation` is older than the newest it has seen; clients read
|
||||
`SessionStatus`, which lives on the server. Children lists, unread marks, search index rows and artifact lists use the
|
||||
same pattern (chapter XIX).
|
||||
|
||||
## Settings and notices
|
||||
|
||||
**Settings** (default model and thinking, run settings, compaction, queue modes, code mode switch, owner name) have
|
||||
**one writer**: a core `Settings` object on the server owns the file, validates writes and serializes them; every
|
||||
other process follows its state through a reference instead of watching and rewriting the file itself. p2 watches
|
||||
settings, models and auth in every process and writes them from two, which is decision #21.
|
||||
|
||||
**Notices** are one channel: one core `Notices` object on the server, written by everything that fails or finishes
|
||||
(worker crashes, reload results, model errors, login outcomes, grants), keeping one latest notice per source, at most
|
||||
50. The owner's shell reads one list; dismissing is a method. p2 has five notice channels for the same thing.
|
||||
|
||||
:::review | Review: settings and file formats (#21, #22)
|
||||
Single writer for settings, models and auth (#21) and versioning on-disk formats such as `envs.json` like documents
|
||||
(#22) are open p3 decisions in `design.md` §26. The construction above assumes "one owner on the server, others
|
||||
follow its state" for #21.
|
||||
:::
|
||||
|
||||
:::note | Built so far
|
||||
Nothing of p3 exists. The functionality map is complete (`apps/p2/docs/p3-functions/`, five area files plus the
|
||||
condensed `README.md`), and the design's app rules (`design.md` §25) are decided. p3 starts after the layered rebuild
|
||||
and the playground's first real proof.
|
||||
:::
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
|
||||
# Sessions and Conversations {#p2-sessions lede="The session lifecycle, status without a worker, chat, shell commands, subagents and search, each mapped to an owner and the Chord parts it uses."}
|
||||
|
||||
This chapter builds the domains a person touches most: the session list and its operations, the conversation view
|
||||
and composer, shell commands, chat mode, subagents, messages between sessions, search and unread marks. Each section
|
||||
names the owner, the host, and the Chord parts it uses; code appears only where it shows something new.
|
||||
|
||||
## The session lifecycle
|
||||
|
||||
The catalog (chapter XVIII) owns the session records. Its operations, by the Chord part each needs:
|
||||
|
||||
| Operation | Owner and form | Notes |
|
||||
| --- | --- | --- |
|
||||
| create | `Catalog.create`, a method | absolute directory, `~` expanded, must be a directory; on an environment, resolved there through the envs plugin; refuses an unknown model; model and thinking default to the last used |
|
||||
| rename, pin | methods on `Catalog` | title 1 to 120 characters, whitespace collapsed; an explicit title is never replaced automatically |
|
||||
| automatic title | a routine on the search feed | the first user message, line breaks to spaces, 100 characters |
|
||||
| agent rename | an agent tool on the worker calling `Catalog` | root conversation only |
|
||||
| archive, restore | methods; revocation; reconciling routines | below |
|
||||
| clone, fork, relocate | jobs on the catalog store | outlive the call, cancellable, one outcome |
|
||||
| delete | a job; decision #18 | below |
|
||||
|
||||
### Archive and restore
|
||||
|
||||
Archiving must stop the worker whatever it is doing, end guest access, and close the session's port forwards, while
|
||||
keeping its storage. The catalog does none of the plugin-specific parts:
|
||||
|
||||
:::steps | Archiving S1
|
||||
+++ One write
|
||||
`Catalog.archive({ id: "S1" })` sets `archived: true` in the session record, in one transaction.
|
||||
+++ Revoke the session
|
||||
The catalog calls `host.revoke({ session: "S1" })`. Every guest principal of S1 has `{ session: "S1" }` in its
|
||||
`revokedBy`, so their objects end and their links close on the server and on the worker (chapter VII). New
|
||||
connections are refused by `authenticate`, because grants of an archived session are void.
|
||||
+++ Stop the worker
|
||||
The supervisor reconciles from `catalog.sessions` with a routine: an archived session's worker is stopped, and
|
||||
`authorize` refuses to start it again.
|
||||
+++ Plugins reconcile
|
||||
The ports plugin's routine on `catalog.sessions` closes forwards of archived sessions. The search plugin marks the
|
||||
session's rows archived. If the server crashed between the write and these steps, the routines run again with the
|
||||
current value at the next start: nothing is a notification that can be lost.
|
||||
+++ Restore
|
||||
`Catalog.restore` clears the flag. Grants are valid again, so guests reconnect and authenticate as before; forwards
|
||||
stay closed.
|
||||
:::
|
||||
|
||||
### Clone, fork, relocate
|
||||
|
||||
Each is work that outlives its call, can be cancelled and has one outcome, so each is a **job** (chapter XI) on the
|
||||
catalog's local store, keyed by the owner's request id. They differ in what they pause:
|
||||
|
||||
- **Clone** copies the session's store, uploads, downloads and artifacts under a new id, with the source worker
|
||||
paused (refused while work runs) and title "(copy)"; directories `0700`, files `0600`, no links, no overwrite; a
|
||||
partial copy is removed on failure.
|
||||
- **Fork** before a user message copies the active context as of the entry before it into a new session (title
|
||||
"(fork)"), with the message waiting in the new session's editor. It needs two workers: the source reads history,
|
||||
the new one is seeded empty. A failure archives the copy.
|
||||
- **Relocate** (cwd, environment or both): refused while the session is busy (naming the running subagents) or when
|
||||
a subagent has its own cwd and the move crosses machines. The job fences the worker (new admissions wait), updates
|
||||
the record, and moves the running worker in place by updating `SessionLocation`: natives that follow the location
|
||||
switch with a generation (chapter IX), and conversations that follow the session's cwd are rewritten in one Durable
|
||||
commit before work resumes. After 60 s it restarts the worker instead; on failure it releases the fence.
|
||||
|
||||
:::review | Review: jobs as objects (#9)
|
||||
The design defers whether a long relocation or a login is a record inside its owner (watched through `running`) or an
|
||||
object of its own (`Relocation { id }`). The construction above uses records. Decide when login is ported
|
||||
(`design.md` §15, §26).
|
||||
:::
|
||||
|
||||
### Delete
|
||||
|
||||
p2 has no session delete. p3 needs one that removes the session everywhere: every document tagged with the session
|
||||
(`store.retireByTag(SessionTag, id)`, chapter V), the worker's databases (the Durable store), the session's files, the
|
||||
search rows, read marks, children copies, forwards, guest records, and finally the catalog entry. The design commits to
|
||||
this outcome (`design.md` §25); how plugins take part is open.
|
||||
|
||||
:::review | Review: session delete (#18)
|
||||
Options in `design.md` §26: per-session objects plus `SessionTag` plus one session directory (delete is retire by tag
|
||||
and remove a directory, plugins need do nothing), or owner hooks contributed through a catalog registry. Also open:
|
||||
whether the search index is per session (deleted with it) or global with a hook. Who holds the store for
|
||||
`retireByTag` is not specified either.
|
||||
:::
|
||||
|
||||
## Status, children, unread: reports to the server
|
||||
|
||||
The session list must show status, subagents and unread marks for sessions whose workers are not running, and must
|
||||
not start them. All three follow chapter X's pub/sub pattern: a routine on the worker calls a server object with the
|
||||
current summary, the server object keeps the latest value per session (persisted where it must survive a restart),
|
||||
ignores calls from an older `ctx.incarnation`, and clients read the server object.
|
||||
|
||||
| Summary | Source on the worker | Kept on the server |
|
||||
| --- | --- | --- |
|
||||
| status: running, error, task count, busy conversations, usage and cost | the harness's task graph and usage | `SessionStatus`, in memory: a stopped worker is "not running" |
|
||||
| children: name, model, thinking, tools, cwd, last entry, archived | the subagents plugin's child list | `Children`, persisted |
|
||||
| latest root answer | the search feed | `Unread`: compared with the owner's read mark, persisted, shared by all owner devices |
|
||||
|
||||
Guests see their own session's status without the error text. The owner's read mark is written when the root
|
||||
conversation is visible, and a new session starts as read.
|
||||
|
||||
## Conversations
|
||||
|
||||
The harness (chapter XIV) owns the Durable store. The UI needs, per conversation: a live window of entries, older
|
||||
pages, usage numbers, the working indicator, the inbox (queued inputs), and the actions. Two core objects provide them:
|
||||
|
||||
- **`ConversationStore { conversation }`** (local, on the worker): opens the Durable watch as its native, reaching
|
||||
the harness through the core-private `HarnessRuntime` registry entry, and exposes a **window** as a derive: the
|
||||
newest entries (at most 100 or 512 KiB, at least 20) plus the conversation's documents. The window's start stays
|
||||
put while it is within 1.5 times the limits, so new entries arrive as small appends instead of a shifted list
|
||||
(chapter VIII's window pattern).
|
||||
- **`Conversation { session, conversation }`** (remote, placement `Session`): the per-principal view. Variants by
|
||||
`can()`: `owner` (everything, including model, thinking and handoff), `writer` (guest write: send, steer, stop,
|
||||
compact, shell), `reader` (the window only).
|
||||
|
||||
```ts
|
||||
host.serve(Conversation, ({ session, conversation }, principal, self) => {
|
||||
const store = self.get(ConversationStore, { conversation });
|
||||
const policy = self.get(ConversationPolicy, { conversation }); // core: readOnly, followsSessionCwd
|
||||
const author = authorOf(principal); // structured: kind, id, display name
|
||||
if (!can(principal, "send", session)) return { variant: "reader", view: store.window, page: store.page };
|
||||
return {
|
||||
variant: can(principal, "manage") ? "owner" : "writer",
|
||||
view: store.window,
|
||||
page: store.page, // older history: returns data
|
||||
send: ({ text, attachments, mode, requestId }, ctx) => {
|
||||
if (policy.value.readOnly) throw forbidden("read-only conversation");
|
||||
return store.submit({ type: "input", content: text, attachments, author, whenBusy: mode, requestId }, ctx);
|
||||
},
|
||||
stop: (_args, ctx) => store.stop(author, ctx), // allowed even when read-only
|
||||
…
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
What each function becomes:
|
||||
|
||||
| Function | How |
|
||||
| --- | --- |
|
||||
| send, retried send | Durable `submit` with the author's `requestId`: a retry with the same id is admitted once (Durable dedupes, chapter XIV) |
|
||||
| steer or queue while busy | `whenBusy: "steer"` or a follow-up; the inbox is `docs["pi.inbox"]` in the window |
|
||||
| attribution | the view derives the author from the principal; the entry stores it structured, the model sees a speaker block; nothing parses text |
|
||||
| wait for prompt resources | the first input of a fresh worker waits until the prompt plugin's resources are loaded (a registry readable the store follows) |
|
||||
| stop, dequeue | withdraw queued inputs, abort the run; the caller's texts return to its editor, other authors' to their **restored drafts** |
|
||||
| restored drafts | a keyed document per (conversation, author), tagged `SessionTag`, persisted in the store; claimed once, first tab wins |
|
||||
| read-only conversations | the core per-conversation policy document, written at `createConversation({ init })` by whoever creates the conversation; send, dequeue, configure, compact and shell refuse; stop is allowed |
|
||||
| history pages | a method returning a page (100 entries, 1 MB, 2,000 scanned); compaction and reset markers always included |
|
||||
| model and thinking | owner variant only; validated against the models plugin's catalog; the root conversation's model is also reported as the session default |
|
||||
| compaction, handoff | Durable operations through the store |
|
||||
|
||||
**Drafts in the composer** are UI state, not Chord state: the web shell keeps one draft in `sessionStorage`, the TUI
|
||||
keeps one per conversation across UI replacements, and pending sends keep their request id until acknowledged
|
||||
("Sending when connected"). Only restored drafts, which the worker produces, are documents.
|
||||
|
||||
## Shell commands
|
||||
|
||||
`!cmd` runs a command in the conversation's cwd on its environment and records the result as an entry; `!!cmd` does
|
||||
the same but keeps the output out of the model's context. One command at a time per conversation; the live tail is
|
||||
at most 50 KB or 2,000 lines, sampled every 200 ms; the full output spills to a file; the result entry is placed at the
|
||||
run's next boundary while the agent is busy and never starts a run. This is the design's job example almost as is:
|
||||
|
||||
```ts
|
||||
const ShellJob = defineJob("chat.shell", {
|
||||
version: 1,
|
||||
args: obj({ command: str({ max: 100_000 }), include: bool(), author: Author }),
|
||||
progress: obj({ output: str() }),
|
||||
result: ShellResult,
|
||||
concurrency: 1, // one per conversation
|
||||
});
|
||||
|
||||
const results = self.state(ShellResults); // keyed by run id: dedupe after completion
|
||||
const shell = self.job(ShellJob, {
|
||||
run: async ({ command, include, author }, ctx) => {
|
||||
const out = new OutputBuffer({ maxBytes: 50_000, maxLines: 2000, retain: "tail" });
|
||||
const env = await environmentFor(ctx); // the conversation's environment, chapter XX
|
||||
const r = await env.exec(command, { cwd: env.cwd, signal: ctx.signal, spill: spillPath(ctx.jobId) },
|
||||
(chunk) => { out.push(chunk); ctx.progress({ output: out.snapshot().text }); });
|
||||
const data = { command, output: out.snapshot().text, exit: r.code, cancelled: ctx.signal.aborted };
|
||||
await conversation.submit(
|
||||
{ type: "write", entry: shellEntry(data, author, include), requestId: `shell:${ctx.jobId}` }, ctx);
|
||||
return data;
|
||||
},
|
||||
finish: async (done, tx) => {
|
||||
(await tx.draft(results, done.id)).outcome = done.kind === "done" ? done.result : { error: done.error };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The request id `shell:${ctx.jobId}` makes the entry idempotent: an attempt interrupted after the write cannot write
|
||||
it twice. Cancel is `shell.abort(id)`, allowed for the command's author and the owner. A retried `runShell` with the
|
||||
same request id returns the running job (dedupe of unsettled runs), or the stored result (the owner's own dedupe after
|
||||
completion, chapter XI). p2's "cancel when nobody watched for 30 s" is dropped: a job keeps its owner alive and does
|
||||
not depend on viewers.
|
||||
|
||||
## Chat mode
|
||||
|
||||
Chat mode is a per-conversation switch that everyone in the session sees and only the owner changes; forks and new
|
||||
subagents start with it off. Because it must fork and rewind with the conversation, it is **transcript-bound**: a
|
||||
Durable conversation document, not a Chord document. Messages without `@pi` become display-only Durable `write`
|
||||
entries (author, text, attachments, reply quote); a message with `@pi` becomes an input with a digest of the chat
|
||||
since the last mention (at most 100 messages, 20 files, 4 images), recording which entries it passed on. The reply
|
||||
quote is derived on the worker from the entry id the client names: the client never supplies author or quote text.
|
||||
|
||||
## Subagents and one-shot agents
|
||||
|
||||
Subagents are Durable child conversations owned by tasks, as in Durable's own background-subagent example: one
|
||||
`subagent` tool spawns, steers, waits for, stops and lists them; each child is owned by a background anchor task, and
|
||||
each message is delivered by a reporter task that posts the answer back to the parent once, with request ids so a
|
||||
restart sends nothing twice. Chord adds only three things:
|
||||
|
||||
- the **child list** for the sidebar is reported to the server (`Children`, above), so it shows without a worker;
|
||||
- a child that follows the session's cwd, or a read-only one-shot agent created by a code-mode script, is marked in
|
||||
the core **per-conversation policy document** when it is created, so chat and relocation enforce it without knowing
|
||||
the subagents plugin (`design.md` §25 porting guideline);
|
||||
- archive and restore of a child from the UI are methods on the subagents plugin's view, refused for one-shots.
|
||||
|
||||
Subagents cannot spawn subagents; thinking levels are clamped to what the model supports, in one helper shared with
|
||||
the session's model choice.
|
||||
|
||||
## Messages between sessions
|
||||
|
||||
The root agent can list other sessions and send one an attributed message. The tool runs on the sender's worker and
|
||||
calls a server object through `chord(fn, context)` (chapter XIV):
|
||||
|
||||
```ts
|
||||
const peerSend = defineTool({
|
||||
name: "session_send",
|
||||
execute: (args, api, context) => chord(async (ctx) => {
|
||||
const peers = await ctx.get(Peers, {}); // server, worker variant
|
||||
await peers.send({ to: args.session, text: args.text, mode: args.mode, requestId: `peer:${api.taskId}` }, ctx);
|
||||
return { output: [{ type: "text", text: "sent" }] };
|
||||
}, context),
|
||||
});
|
||||
```
|
||||
|
||||
`Peers` checks that the sender is a root conversation of a non-archived session, refuses sending to itself and
|
||||
archived recipients, then gets the recipient's `Conversation` (which starts its worker) and submits with the
|
||||
sender as a structured author and the same request id (the tool task's id, stable across restarts), so a retried
|
||||
tool call delivers once. The sender's
|
||||
incarnation is `ctx.incarnation`; a call from a replaced worker is refused. Only owner-initiated turns may use the
|
||||
tool; guests never.
|
||||
|
||||
## Search
|
||||
|
||||
The search plugin indexes user and assistant messages of root and subagent conversations (no shell output, no
|
||||
Durable internal inputs) for title, cwd and message search across sessions, active or archived.
|
||||
|
||||
:::flow wide animate | The search feed. The worker pushes pages from a cursor; the server owns the index and never starts a worker to answer a query.
|
||||
row "Worker S1" blue
|
||||
box commit "Durable commit" "in any conversation"
|
||||
box wake "wake" "a routine on the harness's commit signal"
|
||||
box page "read from cursor" "200 entries per page"
|
||||
row "Server" accent
|
||||
box index "SearchIndex" "native: SQLite FTS" accent
|
||||
box cursor "cursor per conversation" "moves after the whole range"
|
||||
box query "query" "terms, scope, at most 41 hits"
|
||||
arrow commit -> wake
|
||||
arrow wake -> page
|
||||
arrow page -> index "report(page)"
|
||||
arrow index -> cursor
|
||||
note "Reports carry ctx.incarnation. Indexing is idempotent: a page sent twice changes nothing."
|
||||
:::
|
||||
|
||||
The index is a native owned by a host-lifetime object on the server; cursors live with the index, so after a crash
|
||||
the worker asks for the cursor and continues. Rebuilding one session's index drops its rows and rescans, starting
|
||||
an archived session's worker without resuming its work. Queries are methods returning data: at most 41 hits,
|
||||
snippets of 300 characters, queries up to 500 characters. The same feed sets automatic titles, moves "updated at"
|
||||
forward and supplies the latest root answer for unread marks.
|
||||
|
||||
## Presence
|
||||
|
||||
Viewers per session are `host.references(Conversation)` on the worker, counted per client (chapter VIII), and
|
||||
reported with the status. Connected guests (name and mode) are the references whose principal is a guest. Presence
|
||||
is not keep-alive: the supervisor uses `host.activity`, which also counts calls and jobs.
|
||||
|
||||
:::note | Built so far
|
||||
None of this exists in p3. The parts it relies on are Durable (built: submit with request ids, steer and queue,
|
||||
child conversations, background tasks) and the Chord layers L3 to L9 (not built).
|
||||
:::
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
|
||||
# Machines, Files, Models {#p2-machines lede="Environments, workspace, terminals, session files, ports and Radius, models and logins, prompt resources, logs, reload and the shells: the rest of p3."}
|
||||
|
||||
The last domains are the ones that touch the outside world: other machines over SSH, the file system and git,
|
||||
terminals, the relay, model providers and their credentials. Most of them are native objects, and most of them are
|
||||
places where p2 has the same thing several times (four SSH connections to one environment, five notice channels,
|
||||
three copies of the environment config parser). The rule that fixes that is the one from chapter XVIII: one owner per
|
||||
thing.
|
||||
|
||||
## Environments
|
||||
|
||||
The **envs plugin** owns the environment configuration: name, host, user, port, identity file, shell and its
|
||||
variables, plus a managed ed25519 key per environment and its own `known_hosts` (kept apart from the user's, aliased
|
||||
per environment name).
|
||||
|
||||
| Function | Owner and form |
|
||||
| --- | --- |
|
||||
| list, add, edit, delete (refused while a session, archived too, uses it) | `Envs` on the server: a persisted document behind an owner-only view |
|
||||
| validation (no leading `-`, no whitespace or control characters in host and user) | the view's methods, before the write |
|
||||
| a hand-edited `envs.json` is picked up; an invalid file keeps the last valid set and shows an error | a native file watcher (poll fallback) in the envs store; one writer (#21) |
|
||||
| environment revision: a hash of the config, part of its identity | a derive |
|
||||
| connection test (connect, deploy the daemon, platform, latency, warnings) | a job; the result per revision in a document |
|
||||
| unknown or changed host key: fingerprints, trust after confirmation, forget | a flow (chapter XII) on the server |
|
||||
| browse directories (`~`, relative paths, at most 10k scanned, 1,000 shown) | a method with a **call-scoped** SSH session (chapter IX) or the pooled connection |
|
||||
| a changed environment restarts its sessions' workers once idle | a routine on the envs store and the status reports |
|
||||
|
||||
**Execution in the agent.** The worker's harness asks `HarnessEnvironments` for an environment per target (chapter
|
||||
XIV). The envs plugin contributes it from its worker facet: local execution at the session's cwd, or a remote one over
|
||||
SSH through the `pi-env` daemon, deployed per platform and connecting at the first operation. A remote session never
|
||||
falls back to local tools. The environment follows `SessionLocation`: after a move, the old connection closes once
|
||||
the move has committed and watchers reopen at the new location.
|
||||
|
||||
```ts
|
||||
// envs/worker.ts
|
||||
export default defineFacet((host, self) => {
|
||||
const location = self.get(SessionLocation, { session });
|
||||
const pool = self.get(EnvPool, {}); // one owner of SSH connections (#20)
|
||||
self.contribute(HarnessEnvironments, (target) =>
|
||||
target.env === "local" ? localEnv(target.cwd) : pool.environment(target.env, target.cwd));
|
||||
});
|
||||
```
|
||||
|
||||
:::review | Review: connection pools (#20)
|
||||
SSH connections to one environment exist four times in p2 (worker environment, browse connection, test connection,
|
||||
terminal host). `design.md` §26 asks for one owner with a pool or deliberate per-use connections. `EnvPool` above is
|
||||
the first option: a native object that closes idle connections after 10 minutes and invalidates them on a config or
|
||||
key change. Reloading the plugin that owns the pool kills running remote commands (chapter XV), so it should change
|
||||
rarely.
|
||||
:::
|
||||
|
||||
## Workspace and file index
|
||||
|
||||
The **workspace plugin** serves the viewed conversation's working tree (a subagent's own cwd included) on the worker:
|
||||
list a folder (at most 2,000 entries, `.git` hidden), read a file (1 MiB, binary detected), git status, diff, log and
|
||||
single commits, literal text search, and all paths for name filtering. Every query is a **method returning data**, not
|
||||
state: results are bounded and requested by the UI. Only change notifications are state: a counter in a document,
|
||||
bumped by a native watcher of the tree, `HEAD`, the index and config (debounced 100 ms); viewers reload what they show
|
||||
when it changes.
|
||||
|
||||
Two rules live in one place each instead of three:
|
||||
|
||||
- **Confinement:** path rules, a canonical root, no `.git`, the app's private data directory denied (also excluded
|
||||
from search). One helper used by listing, reading, uploads and the file index.
|
||||
- **Git hardening:** no optional locks, no fsmonitor, filters disabled, no prompts or pager, no system config, 10 s
|
||||
timeout, output caps.
|
||||
|
||||
Downloads (25 MiB, byte ranges for media) go through the HTTP component (chapter XIII) with a route on the server that
|
||||
reads chunks from the worker; uploads into the project use the chunked protocol of chapter XIII (1 MiB chunks, temp
|
||||
file then rename, never overwrite, writes serialized). Guest read gets no workspace: the view has no variant for it.
|
||||
|
||||
The **file index** for `@` completion belongs to the same owner, so completion consumes the index instead of scanning
|
||||
on its own: built from the environment's listing (else git or a walk, at most 50k paths), rebuilt at most once per
|
||||
second while files change, discarded after a move. Review comments drafted on files and diffs are browser state until
|
||||
sent; sending them is an ordinary `Conversation.send`.
|
||||
|
||||
## Terminals
|
||||
|
||||
Terminals must survive server and worker restarts, so their PTYs live in a **detached terminal host process**, one per
|
||||
data directory, with its own process lock; it exits after 10 minutes with no terminals and no clients. The terminals
|
||||
plugin on the server is the owner the UI talks to: list (title, cwd, environment, size, exit status), open at the
|
||||
session's cwd and environment (refused for archived sessions), attach, close (asking first if a foreground job runs).
|
||||
Terminals are **owner only**.
|
||||
|
||||
Bytes do not go over object links. Attaching opens a **channel** (chapter XIII) with a single-use token (30 s): JSON
|
||||
control on the object, bytes on the channel, with its own acknowledged window (256 KiB, acknowledgements per 64 KiB; a
|
||||
viewer that falls behind gets a fresh screen snapshot; an 8 MiB backlog disconnects). Attaching gets the current
|
||||
screen from a headless emulator with 10k lines of scrollback.
|
||||
|
||||
:::review | Review: the terminal host process (#23)
|
||||
The terminal host runs its own code. Is it part of the revision, reloaded or restarted with it (and running terminals
|
||||
end), or deliberately outside it with a "host runs older code" notice as in p2? Open in `design.md` §26.
|
||||
:::
|
||||
|
||||
## Session files
|
||||
|
||||
Attachments (25 MiB, at most 20 per message), sent-file snapshots made by the agent's `send_file` tool, versioned
|
||||
artifacts (HTML, SVG, Markdown, text; 2 MiB; each update a new immutable version) and shell spill files are all
|
||||
per-session bytes with caps, immutable ids and range reads; p2 has two upload routes, two caps and two directory
|
||||
layouts for them. The proposal is one owner:
|
||||
|
||||
```ts
|
||||
export const SessionFiles = remote("p3.session-files", {
|
||||
version: 1,
|
||||
input: obj({ session: str() }),
|
||||
placement: () => Server(), // readable without a worker
|
||||
variants: { writer: { … }, reader: { … }, worker: { … } },
|
||||
});
|
||||
```
|
||||
|
||||
`SessionFiles { session }` on the server holds the index (per kind, readable without a worker), the chunked upload
|
||||
and read protocol, the caps per kind, copying on clone and fork, and deletion with the session. Attachments are copied
|
||||
into a remote environment before an input is admitted, with no fallback on failure. Artifact previews run in a
|
||||
sandboxed frame with its own CSP and no app identity; ordinary file previews allow only safe types.
|
||||
|
||||
:::review | Review: session files (#17)
|
||||
Proposed in `design.md` §26, not decided. Open sub-points: one preview limit (p2 has 256 KiB for sent files and
|
||||
512 KiB for artifacts), what a fork copies of the artifact list, and how attachments are copied into remote
|
||||
environments. Related: #22, whether on-disk layouts such as downloads and uploads get versions like documents.
|
||||
:::
|
||||
|
||||
## Ports and Radius
|
||||
|
||||
The **radius plugin** keeps the outbound relay connection, a native in a host-lifetime object on the server: sign-in
|
||||
by device code from the CLI (credential in a private file), token refresh, a heartbeat in the link adapter (20 s ping,
|
||||
60 s timeout; TCP does not notice a sleeping laptop), backoff from 1 to 30 s, a disable switch. The relay delivers two
|
||||
kinds of channels:
|
||||
|
||||
```ts
|
||||
// radius/server.ts (the entry point: one line, as in chapter I)
|
||||
radius.onChannel((ws) => host.accept(webSocketLink(ws), { principal: { kind: "owner", access: "relay" } }));
|
||||
radius.onForward((channel) => proxyToForward(channel, host.registry(RadiusForwards).value));
|
||||
```
|
||||
|
||||
Object links become owner principals with `access: "relay"`: owner, but treated as remote (no localhost-only
|
||||
powers). p2 had to fake a loopback request with a secret header for this; with `accept` that is gone.
|
||||
|
||||
Forward channels go to `localhost:<port>` (Host, Origin and Location rewritten; 502 when nothing listens; anonymous
|
||||
visitors only to public forwards). Which forwards exist is not radius's business: it owns a registry,
|
||||
`radius.forwards`, and the **ports plugin** contributes its forwards (at most 50, random ids, a public toggle that
|
||||
needs confirmation, an agent tool `ports` for top-level owner-initiated conversations). Without the ports plugin the
|
||||
table is empty and radius still works: an optional dependency is a registry owned by the dependent (chapter XIII). The
|
||||
ports plugin closes forwards of archived sessions with a routine on `catalog.sessions` (chapter XIX).
|
||||
|
||||
:::review | Review: may a plugin call `accept`?
|
||||
`design.md` §3 shows the Radius line in the server's start code and calls entry points "app or adapter code". If
|
||||
radius is a plugin, its facet needs the host's `accept`, which no other part of this guide gives a plugin. The
|
||||
alternative is that the start code keeps the entry point and gets channels from the radius object. Not specified.
|
||||
:::
|
||||
|
||||
## Models and credentials
|
||||
|
||||
The **models plugin** owns the model runtime (pi-ai's models with custom providers and the model cache) in a
|
||||
host-lifetime object, and the credentials. It contributes the runtime to `HarnessModels` (chapter XIV), so after a
|
||||
models reload the next request uses the new runtime while a running stream finishes on the old one. **Credentials
|
||||
never go to clients**: the views expose providers, login methods and which type is signed in, never tokens. Every
|
||||
secret is registered for redaction in one place, the log capture.
|
||||
|
||||
Views: a model catalog for pickers (names, ids, context windows, thinking levels; at most 300 matches in the web),
|
||||
logout (refused during a login), refresh a provider's model list. The default model and the cycling scope are
|
||||
settings (chapter XVIII).
|
||||
|
||||
### Login
|
||||
|
||||
A login is the guide's flow example (chapter XII): a **job** on the server that runs pi-ai's login, and a flow that
|
||||
shows its steps to the starting owner.
|
||||
|
||||
```ts
|
||||
const LoginJob = defineJob("models.login", {
|
||||
version: 1, args: obj({ provider: str(), type: AuthType }), progress: LoginState, result: obj({ ok: bool() }),
|
||||
concurrency: 1, // one login at a time on the server
|
||||
});
|
||||
|
||||
const login = self.job(LoginJob, {
|
||||
run: async ({ provider, type }, ctx) => {
|
||||
await runtime.value.login(provider, type, {
|
||||
signal: ctx.signal, // 10 minute deadline, cancel, leaving
|
||||
prompt: (prompt) => steps.ask(prompt, ctx), // text, secret, select, manual_code
|
||||
notify: (event) => steps.notice(event, ctx), // info, auth_url, device_code, progress
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
finish: async (done, tx) => { /* record the outcome; refresh the model list */ },
|
||||
});
|
||||
```
|
||||
|
||||
`prompt` is a promise-based callback inside the work; the resolver stays in the job (`steps.ask` records the step in
|
||||
the flow state and waits for `answer`), and nothing crosses an object boundary. On a relayed connection the device
|
||||
code method is selected automatically. The flow is visible only to the owner principal that started it; others are
|
||||
refused. Leaving ends it: when the starting owner's references are all gone (presence, not an unmount), a routine
|
||||
aborts the job. Codes, URLs and typed secrets are redacted from logs.
|
||||
|
||||
## Prompt resources
|
||||
|
||||
`AGENTS.md`, skills and templates are read in the session's environment, watched (poll fallback, last valid state),
|
||||
and expanded on the worker before sending. The **prompt plugin** owns them per session on the worker, contributes the
|
||||
system prompt section and skills to `HarnessExtensions` as readable contributions (a change recomposes), and exposes a
|
||||
readiness readable: the first input of a fresh worker waits until it is ready (chapter XIX). After a move the watchers
|
||||
reopen at the new location.
|
||||
|
||||
## Code mode
|
||||
|
||||
Code mode is a plugin (chapter XIV): its `pi-codemode` sandbox is a native; scripts call tools as nested Durable calls
|
||||
and replay after a restart; the tool entry looks up the current sandbox when it starts. A reload seals and drains the
|
||||
old sandbox for at most 30 s. The on/off switch is a setting.
|
||||
|
||||
## Logs and notices
|
||||
|
||||
**Log capture** tees stdout and stderr of the server, workers and detached hosts without changing their behaviour,
|
||||
redacts known secrets and credential patterns, and keeps bounded rings (at most 128 sources, 1,000 lines or 256 KiB
|
||||
each). Detached hosts publish redacted snapshots to a private file. Logs are read without waking a stopped worker.
|
||||
**Notices** are the one channel from chapter XVIII; dismissal stores only notice ids.
|
||||
|
||||
## Dev and reload
|
||||
|
||||
The dev plugin is chapter XV's reload control for p3: a source watcher marks plugins dirty and never applies anything;
|
||||
the `reload_plugins` agent tool and a control in the owner's shell reload the changed plugins, or restart all hosts for
|
||||
base code (through the supervisor, reopening the sessions that were running). UI bundles are published only after a
|
||||
successful backend change; a web page detects a new manifest and reloads with its drafts and navigation state saved;
|
||||
the TUI swaps its sandboxed UI code with a snapshot of its state. A local Unix socket (`0700`) accepts development
|
||||
commands without a network admin port.
|
||||
|
||||
## The shells
|
||||
|
||||
The web and TUI shells are UI facets over the objects above. Two rules matter for Chord:
|
||||
|
||||
- **UI state survives navigation, reload and reconnect on purpose.** Composer drafts, pending sends with their request
|
||||
ids, reading positions and review drafts are kept in tab storage or the TUI's replacement snapshot. A reconnect
|
||||
re-holds and gets snapshots; nothing is lost that was not in flight.
|
||||
- **Leaving is never derived from an unmount.** A plugin reload remounts components (chapter XV). Cancelling a login,
|
||||
closing an overlay or giving up a lease is an explicit action or a dropped reference.
|
||||
|
||||
The TUI launcher keeps downloaded UI code in a permission-dropped worker with no access to files, network or
|
||||
credentials; local side effects (copy, upload, download, open a URL, an external editor) need a real user gesture or a
|
||||
confirmation. That is app code around Chord, not part of it.
|
||||
|
||||
## Transport
|
||||
|
||||
The last domain, transport, is chapter XVII: one message format, per-part limits (16 MiB compressed and 64 MiB decoded
|
||||
on client WebSockets, instead of p2's 1 MiB input cap and 32 pending calls), one byte window per link, re-holds with
|
||||
snapshots after a reconnect, and a protocol version that makes an outdated web page reload.
|
||||
|
||||
:::note | Built so far
|
||||
None of p3 exists. pi-ai's login API, pi-codemode and the `pi-env` daemon exist and are used by p2 today; the HTTP
|
||||
component and channels are deferred until after the kernel's first real proof (chapter XIII).
|
||||
:::
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
|
||||
# Other Ways to Do It {#compared lede="Cordis and DeepSeek Harness attack the same problem, plugins that come and go while an agent runs, inside one process. What that buys, and what it costs."}
|
||||
|
||||
Chord v2 is not the only answer to "replace plugin code while the app keeps running". The closest relative is
|
||||
**Cordis**, a TypeScript plugin framework by Shigma that has been the plugin kernel of the Koishi chatbot framework
|
||||
since 2019, and its most visible user, **DeepSeek Harness** (`dsh`), DeepSeek's open-source agent harness, whose
|
||||
motto is "everything is a plugin". This chapter compares them with Chord v2 on the questions the guide cared about:
|
||||
who owns side effects, how plugins depend on each other, what a reload keeps, what a crash keeps, and how far the model
|
||||
reaches beyond one process.
|
||||
|
||||
:::note | Sources and status
|
||||
DeepSeek Harness at commit `687f282` (release 0.2.1-rc.2), which vendors Cordis as `@deepseek-ai/cordis` 4.0.5 (Cordis
|
||||
RC7 with local changes, `vendor/README.md`): its Cordis tutorial (`docs/cordis-tutorial/`), `docs/architecture.md`,
|
||||
`docs/subsystems/persistence.md`, `docs/subsystems/core.md` and `packages/boot/hmr/README.md`; the Cordis paper "A
|
||||
Programming Paradigm for Spatiotemporal Composability" (arXiv 2608.25512). Cordis and dsh are built and in use; Chord v2
|
||||
is the design this guide describes. The comparison is between a working system and a design.
|
||||
:::
|
||||
|
||||
## Cordis in one example
|
||||
|
||||
A Cordis plugin is a function (or an object with `apply`, or a `Service` subclass) that receives a **context**. It may
|
||||
declare the services it needs with `inject`, and everything it does to the world goes through the context:
|
||||
|
||||
```ts
|
||||
export const name = "greeter";
|
||||
export const inject = ["tools"]; // activate only while a `tools` service exists
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.tools.register(defineTool({ name: "greet", /* … */ })); // a registration is an effect: undone on unload
|
||||
ctx.on("session/start", (s) => log(s.id)); // an effect-bound listener
|
||||
ctx.effect(() => { // any other side effect, with its disposer
|
||||
const timer = setInterval(work, 200);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Each plugin instance is a **fiber** with states `PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED` (or `FAILED`).
|
||||
Every effect, registration, listener, child plugin and provided service is recorded on the fiber, and unloading runs
|
||||
the disposers. The paper calls this **temporal composability**: a component can be removed at any time and reverses its
|
||||
own effects. Its counterpart, **spatial composability**, is the dependency side: `inject` is live, so a plugin starts
|
||||
when its services appear, and unloads (and later reloads) when a service it depends on goes away or is replaced. The
|
||||
paper's claim is that independently written components can therefore be combined and replaced without disturbing each
|
||||
other. The next sections check how far that holds.
|
||||
|
||||
**Services** are named capabilities on the context (`ctx.provide("x", value)` or `class X extends Service`). Names are
|
||||
runtime strings in one flat namespace per application; TypeScript declaration merging makes `ctx.x` typecheck, but
|
||||
nothing checks the service's shape at runtime, and optional dependencies are probed with `ctx.get("x")`. Scopes come
|
||||
from the context tree: `ctx.extend()` derives a child context, `ctx.isolate(name)` gives one subtree its own binding of
|
||||
a service, so two parts of an app can use two different `shell` providers.
|
||||
|
||||
**Hot reload** follows from the two halves: the reload plugin unloads the old fiber (unwinding all its effects) and
|
||||
loads the new module with the same config. Nothing held in the old instance's closures or fields survives; a plugin
|
||||
that needs state across reloads must keep it in a service or on disk. Config edits are diffed by entry id and
|
||||
mount, unmount or reconfigure only what changed; config fields declared volatile update in place without a remount.
|
||||
|
||||
## DeepSeek Harness on Cordis
|
||||
|
||||
dsh applies "everything is a plugin" to an agent: the model adapters (`ctx.llm`), the tool registry (`ctx.tools`), the
|
||||
session log (`ctx.sessions`), the agents (`ctx.agents`) and the agent loop itself (`ctx.agentLoop`) are plugins and
|
||||
services. A running dsh is a plugin tree composed at boot from a **profile** (`web`, `headless`, `sdk`, `acp`) that
|
||||
stacks **bundles** of Cordis config rows, with user patches on top. There is no privileged core: replacing the agent
|
||||
loop is a config patch.
|
||||
|
||||
| Concern | How dsh does it |
|
||||
| --- | --- |
|
||||
| sessions | a durable, append-only event log per session (JSONL backend, single writer, `flush` as the durability barrier); model history is derived from the events |
|
||||
| crash mid-turn | the log keeps everything appended; on resume the agent loop closes the open turn by appending missing tool errors and a synthetic `turn/end { reason: "interrupted" }` |
|
||||
| tools | `ctx.tools.register(defineTool(…))`, an effect of the registering plugin; calls go through pre/execute/post policy waterfalls |
|
||||
| approval | closed by default; `allowed-once` is the only grant; plugin installation needs full access or per-call approval |
|
||||
| reload | a source watcher evicts changed modules; Loader entries running affected plugins stop and start again with the new code; a failed replacement is an activation failure, its previous code no longer runs |
|
||||
| processes | one plugin tree in the Host process; the browser client and Electron's renderer talk to it over RPC streams |
|
||||
| distribution | a Plugin Manager installs and enables bundles per profile; plugin markets are optional indexes |
|
||||
| subagents | local subagents with their own durable sessions, and external ones |
|
||||
|
||||
## What the guarantees are in practice
|
||||
|
||||
Reading the Cordis source vendored in dsh and dsh's own documentation, the guarantees are narrower than the paper's
|
||||
framing. An earlier review for p2 came to the same result (`apps/p2/docs/p3-reviews/dsh-compare.md`).
|
||||
|
||||
- **Reversal is cooperative and unordered.** `_unload` runs all of a fiber's top-level disposers concurrently
|
||||
(`Promise.all` in `vendor/cordis/src/fiber.ts`); order holds only inside one effect, and the primer tells authors to
|
||||
"keep the related work in one effect" when teardown order matters. A disposer that throws is logged and skipped. A
|
||||
disposer that never settles stalls the teardown; dsh's jobs service works around this by force-failing a job whose
|
||||
cancel throws and warning that "the work may be orphaned" (`packages/jobs/jobs-local/README.md`). Reversal is as
|
||||
good as every disposer, and nothing checks that a plugin registered all its effects.
|
||||
- **Behaviour depends on registration order, and a reload changes it.** `emit`, `waterfall`, `serial` and `bail`
|
||||
listeners run in registration order (`docs/cordis-primer.md`). Registering appends the listener (`push` in
|
||||
`vendor/cordis/src/events.ts`, unless `prepend`), so when a plugin reloads, its listeners move to the end of the
|
||||
list. A tool-policy listener that ran first runs last after its own plugin reloads; a waterfall that short-circuits
|
||||
now sees a different decision first.
|
||||
- **Dependencies are strings, and every provider reload restarts its dependents.** A fiber's activation key is the
|
||||
concatenation of its injected providers' fiber ids (`_refresh` in `fiber.ts`), so replacing a provider unloads and
|
||||
reloads every plugin that injects it, with their in-memory state. Service APIs are "pre-stable; update every
|
||||
consumer" (`AGENTS.md`): there is no versioned contract between a provider and its consumers.
|
||||
- **Reloading a core service ends live work.** Unloading the agent provider "stops and drains every live handle it
|
||||
made" (`packages/core/agent/README.md`), so running agents stop; reloading the session persistence backend under
|
||||
live sessions invalidates their write handles, and "writes fail loudly until the sessions restart"
|
||||
(`.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md`).
|
||||
- **Registrations leave a gap.** A reloading plugin's tools and other registry entries are removed with its old fiber
|
||||
and added again by the new one; anything that looks them up in between does not find them.
|
||||
|
||||
None of this is a bug in the narrow sense: each point is documented, and dsh is built around it. But "components
|
||||
interleave without disturbing each other" holds only for plugins that own nothing other plugins depend on, register
|
||||
every effect, order nothing by registration, and hold no state that must outlive a reload.
|
||||
|
||||
## Side by side
|
||||
|
||||
| | Cordis and dsh | Chord v2 |
|
||||
| --- | --- | --- |
|
||||
| unit | a plugin instance (fiber) in one context tree | an object per (type, input, principal), placed on a host (chapters II, III) |
|
||||
| side effects | recorded on the fiber with `ctx.effect` and friends; on unload the disposers run concurrently, each as good as its author made it | declared through `self` (documents, natives, routines, jobs, contributions); Chord activates and undoes them (chapters V, IX to XI) |
|
||||
| dependencies | service names as strings in one flat namespace, via `inject`; a provider reload restarts every dependent | typed contracts via `get(Type, input)`; a reference parks and recovers through restarts (chapter VI); optional dependencies are registries (chapter XIII) |
|
||||
| ordering | listeners run in registration order, which changes when a plugin reloads | registries compose with a declared `compose` function; nothing depends on load order (chapter XIII) |
|
||||
| where code runs | one process; remote UIs over RPC to the Host | a tree of hosts (server, workers, tabs); objects reachable across links, relayed by the server (chapters I, XVII) |
|
||||
| who is calling | not a framework concept | principals stamped by links, variants per caller, revocation (chapters III, VII) |
|
||||
| state | the plugin's own business; dsh's session log is a service | documents written in transactions, persisted, versioned and migrated (chapter V) |
|
||||
| reload | unload the old instance, load the new one: in-memory state and running work of that plugin end | pure objects swap their code in place with their documents and running work kept; only objects with natives restart (chapter XV) |
|
||||
| a running agent turn during reload | entries of changed plugins stop; unloading the agent provider stops its live agents | the harness is base code; Durable pins tools per task phase, so a running call finishes on old code and the next phase uses the new (chapter XIV) |
|
||||
| crash recovery of a turn | the turn is closed as `interrupted` | pi-durable continues the turn's tasks from their checkpoints; replay-safe tools rerun (chapter XIV) |
|
||||
| contracts and versions | TypeScript types only, pre-stable APIs, every consumer updated together; config validated with a schema | contract versions with fingerprints and provider-side mappings for old consumers (chapter XVI) |
|
||||
| status | in production in Koishi since 2019; dsh released | a design with the contract layer built |
|
||||
|
||||
## What the comparison says
|
||||
|
||||
**The core idea is the same.** "Factories declare, Chord activates" (chapter II) and Cordis' reversible effects are
|
||||
two forms of one rule: a plugin may only change the world through its runtime, so the runtime knows what to undo.
|
||||
Both reject the module-level timer and the stored callback for the same reason.
|
||||
|
||||
**The difference is what survives a reload.** Cordis reloads at plugin granularity: the old instance unwinds and the
|
||||
new one starts from nothing, together with every plugin that depends on it. That is simple, but a plugin's in-memory
|
||||
state and its dependents' state and work in flight end with it, and correctness rests on every disposer and on
|
||||
registration order. Chord v2 keeps state out of closures on purpose (documents, references, jobs as
|
||||
records) so that it can swap the code of a pure object while its data and running work stay, and restarts only what
|
||||
holds OS handles. That is more machinery: transactions, a store, versions on every document and contract.
|
||||
|
||||
**The other difference is reach.** Cordis composes plugins inside one process, and dsh adds RPC to its own clients.
|
||||
Chord v2 makes the process boundary part of the model: objects live on the host their placement names, callers are
|
||||
principals, and a reload rolls out across the server, the workers and every tab. An app like p2, with a process per
|
||||
session and guests in other browsers, needs that; a single-process agent does not.
|
||||
|
||||
:::review | Review: what a reload does to a running tool call in dsh
|
||||
The sources say that Loader entries running affected plugins stop and that unloading the agent provider stops its live
|
||||
agents (`packages/boot/hmr/README.md`, `docs/subsystems/core.md`). They do not say what happens to a tool call in
|
||||
flight when only the tool's plugin reloads; the comparison above does not claim either way.
|
||||
:::
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
|
||||
# What's Next {.epilogue lede="Where Chord v2 stands, what is still undecided, and where the written sources disagree with each other."}
|
||||
|
||||
The guide built Studio from one object to a p2-sized app. Every piece used the same few rules: one owner per thing,
|
||||
factories that only declare, state that changes only in transactions, long-lived behaviour in declared forms, and
|
||||
mappings and migrations owned by the provider. Those rules are what let Mario press Reload on his phone in the
|
||||
preface while the agent keeps running. This last chapter says how much of that exists and what is still open.
|
||||
|
||||
## Status
|
||||
|
||||
:::stats
|
||||
- ~10k | lines in the frozen first kernel (steps 1 to 6, 443 tests), kept as a reference only
|
||||
- 1 of 11 | layers of the rebuild started: L1 contracts, built and reviewed
|
||||
- 6–7k | target size of Chord v2 without tests
|
||||
:::
|
||||
|
||||
- **The design is decided** except the points marked below. `packages/chord/docs/next/design.md` is the target;
|
||||
`packages/chord/docs/next/decisions.md` is the log and wins where they differ.
|
||||
- **The rebuild** goes layer by layer, each with a one-page spec the owner approves, a minimal build with counting and
|
||||
garbage-collection tests, the cost-guard metrics, and a cleanup loop: L1 data and contracts; L2 readable and
|
||||
derive; L3 documents and store; L4 objects; L5 references and `ctx`; L6 protocol and links; L7 relay, routes,
|
||||
revocation; L8 routines and jobs; L9 registries; L10 reload and rollout; L11 telemetry in Chord.
|
||||
- **Built:** the contract layer (`src/next/contracts/`, commit `7d2bb8ea8`, chapter XVI). In pi: Delta,
|
||||
`pi-telemetry`, `chord/context`, `chord/telemetry-sqlite`, pi-ai and Durable telemetry, Durable's required
|
||||
`checkpointWhen`.
|
||||
- **Next, in order** (`apps/p2/docs/p3-handoff.md`): decide Q12 and Q15, which the L1 spec needs; write the spec for
|
||||
the rest of L1 (values, inputs and identity, argument checks, principals, errors; draft in
|
||||
`packages/chord/docs/next/layer-1.md`); then Q11, Q13, Q14 and the cuts C3 to C17 before the layers they affect.
|
||||
- **In pi:** `mapShared` and bytes as atomic leaves in Delta's `diffRevisions`.
|
||||
- **Then** the playground web shell (React, esbuild) with the reload control and a preview at `p3.mariozechner.at`,
|
||||
and the first real proof: the harness, one tool with an approval pause, a code mode reload. Then the HTTP and
|
||||
channel components, then p3, domain by domain.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
Kept with stable numbers in `design.md` §26, each waiting for a concrete case:
|
||||
|
||||
| # | Topic | When | Chapter |
|
||||
| --- | --- | --- | --- |
|
||||
| 9 | jobs as objects: a record inside the owner, or an object addressed by its id | when login is ported | XI, XIX |
|
||||
| 11 | where a flow lives: a keyed document of its owner, or an object `LoginFlow { id }` | when login is ported | XII |
|
||||
| 14 | project plugins: per-directory plugins with trust prompts and snapshots | own pass over `plugin-loading.md` | XV |
|
||||
| 16 | a Durable Object profile | when a DO target becomes concrete | |
|
||||
| 17 | session files: one `SessionFiles { session }` owner on the server (proposed) | p3 design | XX |
|
||||
| 18 | session delete: retire by tag plus a session directory, or owner hooks | p3 design | XIX |
|
||||
| 20 | connection pools: one owner of SSH connections per environment, or per-use connections | p3 design | XX |
|
||||
| 21 | config files with a single writer: one owner on the server, others follow | p3 design | XVIII |
|
||||
| 22 | file formats (`envs.json`, downloads, uploads) as versioned interfaces | p3 design | XX |
|
||||
| 23 | the terminal host process: inside the revision or deliberately outside | p3 design | XX |
|
||||
|
||||
## Open from review round 3
|
||||
|
||||
Decisions (`apps/p2/docs/p3-reviews/v2-review3-summary.md`; Q1 to Q10 are decided):
|
||||
|
||||
| # | Problem | Proposal |
|
||||
| --- | --- | --- |
|
||||
| Q11 | a singleton that does not exist yet: `undefined` or `initial()`? | singletons read `initial()`; `undefined` only for absent keyed documents |
|
||||
| Q12 | stripping arguments (R7) against "nothing is copied" | copy on change: a new object only when the argument had extra fields; never mutate the caller's value (proposed in the L1 draft) |
|
||||
| Q13 | `ctx.incarnation` on relayed calls | relays stamp the origin host's incarnation, as they stamp the principal |
|
||||
| Q14 | a writable holding functions exposed as a state member | only members with a JSON schema in the contract are exposable, checked at serve |
|
||||
| Q15 | arguments of `local` methods: validated or not | apply R7 to `local` too, or keep `local` as the one exception |
|
||||
|
||||
Cuts the owner may choose (each removes a primitive this guide teaches):
|
||||
|
||||
| # | Cut | What it costs |
|
||||
| --- | --- | --- |
|
||||
| C3 | shrink jobs to a runner over documents (no versions, `accepts`, `blocked`, `persist`), or move jobs out of the kernel | job versioning, or `self.job` for core-only users |
|
||||
| C4 | routines: `on` and `loop` only | `every`'s due time across reloads |
|
||||
| C5 | merge `lifetime: "host"` objects into the facet | a plugin's always-on natives share one failure domain |
|
||||
| C6 | remove `broken()` for opens and `retryOn` | nothing; reload and Retry still work |
|
||||
| C7 | remove provider-side `linger` | ten lines of app code |
|
||||
| C8 | remove `state: false` (method-only references) | a method-only consumer pays one snapshot |
|
||||
| C9 | remove `previous` from derives | derives that used it move to `mapShared` |
|
||||
| C10 | one `cell(initial)` for `constant` and `writable` | nothing |
|
||||
| C11 | remove `ctx.trace` (use `traceIn(ctx, …)`) | nothing |
|
||||
| C12 | remove call credits (the byte window covers calls) | nothing |
|
||||
| C13 | two timeouts (`closeTimeout`, `readyTimeout`) instead of eight | per-thing overrides |
|
||||
| C14 | remove the `handle.change` and `handle.retire` shorthands | nothing |
|
||||
| C15 | remove `inVariant` and `expectVariant` (keep `RefIn`) | nothing |
|
||||
| C16 | remove facet-owned mutable documents and their transaction exception | atomic writes across an object and its facet move to one owner |
|
||||
| C17 | no harness-specific start-barrier exception | factories before the barrier cannot depend on the harness |
|
||||
|
||||
## Where the sources disagree
|
||||
|
||||
This guide follows the decision log where it is newer than the design, and the design where it is newer than
|
||||
everything else. The places that need a fix in the sources:
|
||||
|
||||
:::review | Review: drift in the written sources
|
||||
- `design.md` §26 lists only the deferred decisions; it omits the open review-3 items Q11 to Q15 and the cuts C3 to
|
||||
C17.
|
||||
- `design.md` §3 has a garbled sentence, "Values are method arguments are stripped".
|
||||
- `apps/p2/docs/p3-handoff.md` §3 is stale: it says all R items are decided and, two lines later, that R2 to R5 and
|
||||
R7 to R26 are open, and "resume R6 next".
|
||||
- `kernel.md` and `step-*.md` describe the frozen code: host values, a development freeze, consumer-side linger,
|
||||
JSONL storage, descriptions on the wire, and a link closed on an unknown id. None of these is the target.
|
||||
- `chord-next-walkthrough.md` lags the log: validation by trust (now R7, every caller), dev mode and freeze (removed
|
||||
by R9), descriptions once per link (gone with R6), an IPC frame-count window (now one byte window, Q4), job outcome
|
||||
`{ ok }` (now `{ kind }`), harness activation as the start barrier (R17), registries "newer refused at load" (Q8),
|
||||
and hooks using `host.root` (now `chord(fn, context)`, Q6).
|
||||
- `apps/p2/docs/questions.md` uses `self.resource` and `replace` (both rejected) and `host.root` in hooks.
|
||||
- `apps/p2/docs/p3-reviews/v2-cost-model.md`: the measurements are current, its "Decisions this needs" section is
|
||||
not (chapter XVII).
|
||||
- L1's `Implementation<C>` shape differs from the factory return value used in the design (chapter XVI).
|
||||
:::
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
|
||||
# API Reference {.appendix #api lede="Every name the guide uses, grouped by where you meet it. Working names; only the contract layer is built."}
|
||||
|
||||
## Hosts
|
||||
|
||||
| Call | Meaning | Chapter |
|
||||
| --- | --- | --- |
|
||||
| `createHost<P, R>({ address?, principal?, storage?, principalKey?, revokedBy?, telemetry?, onError?, timeouts })` | a host; with an address it is addressable and its own code acts as `principal`; without, a client | I |
|
||||
| `host.load([{ name, facet }])` | load facets by plugin name; base code passes its core facet the same way | I |
|
||||
| `host.connect(link, { principal })` | the link to the parent (at most one) | I |
|
||||
| `host.accept(link, { principal })` | a link from a client; every request on it acts as `principal` | I |
|
||||
| `host.route(Kind, { authorize, resolve })` | how to reach hosts of a kind; `resolve` returns `{ link, principal }` | I |
|
||||
| `host.revoke(r)` | end matching objects and links here and below | VII |
|
||||
| `host.root(fn, { principal?, signal?, telemetry? })` | a call scope with a `ctx` for code outside objects | VI |
|
||||
| `host.get(Type, input, { signal, state?, parkTimeout? })`, `host.hold(…)` | a reference from outside objects; `release()` ends it | VI |
|
||||
| `host.serve(Type, factory, { lifetime?, linger? })` | serve an object type (in a facet) | II |
|
||||
| `host.registry(Registry)` | the composed value of a registry, a readable | XIII |
|
||||
| `host.references(Type)` | who holds references to objects of a type on this host (presence) | VIII |
|
||||
| `host.activity` | `{ references, calls, jobs, lingering }`, for keep-alive | I |
|
||||
| `host.close()` | end the host: objects, natives, links | I |
|
||||
|
||||
## Inside a factory: `self`
|
||||
|
||||
| Member | Meaning | Chapter |
|
||||
| --- | --- | --- |
|
||||
| `self.state(Definition)` | a document handle | V |
|
||||
| `self.tx(async (tx) => …, ctx)` | a transaction on the object's line | V |
|
||||
| `self.derive(name, [sources], fn)` | a derived readable | VIII |
|
||||
| `self.get(Type, input)` | a reference held for the object's life | VI |
|
||||
| `self.open(open, close, { closeTimeout? })` | a native; `.value` once open | IX |
|
||||
| `self.routines.on(readable, fn)`, `.every(interval, fn)`, `.loop(fn)`, `.custom(…)` | standing behaviour | X |
|
||||
| `self.job(Job, { run, finish? })` | owned work that outlives its call | XI |
|
||||
| `self.contribute(Registry, value)` | a contribution (facets and `lifetime: "host"` objects only) | XIII |
|
||||
| `self.writable(initial)` | a host-local readable of any in-process value, with `set` | XIII |
|
||||
| `self.retryOn(readable)` | a trigger that retries a broken activation | VI |
|
||||
|
||||
**Document handles:** `value`, `get(key)` (keyed), `keys`, `change(fn, ctx)` / `change(key, fn, ctx)` and `retire(ctx)` / `retire(key, ctx)` (shorthands for
|
||||
one-document transactions). **Transactions:** `tx.draft(handle, key?, { tags? })`, `tx.retire(handle, key?)`,
|
||||
`tx.start(job, args, { key? })`. **Jobs:** `job.start(args, ctx, { key? })`, `job.abort(id)`, `job.running`,
|
||||
`run.outcome(ctx)`.
|
||||
|
||||
## References and `ctx`
|
||||
|
||||
| Member | Meaning |
|
||||
| --- | --- |
|
||||
| `ref.<member>` | a state member (readable: `.value`, subscribe) or a method |
|
||||
| `ref.state`, `ref.onState(fn)` | `loading`, `live`, `failed` (cause, `retryAt`), `released` |
|
||||
| `ref.retry()` | retry a failed reference now |
|
||||
| `ref.release()` | end a reference taken outside objects |
|
||||
| `ref.members` | the variant's members |
|
||||
| `ctx.signal`, `ctx.principal`, `ctx.incarnation`, `ctx.get` | cancellation, caller, calling host's incarnation, call-scoped references |
|
||||
| `ctx.telemetry`, `ctx.trace(name, attributes, fn)` | the current span; a child span with a child `ctx` |
|
||||
| `ctx.jobId`, `ctx.progress(value)` | in a job's `run` |
|
||||
|
||||
## Definitions
|
||||
|
||||
| Name | What it declares | Chapter |
|
||||
| --- | --- | --- |
|
||||
| `hostKind(name, input?)` | a kind of host and its address | I |
|
||||
| `remote(name, { version, input, placement, variants })` | an object type reachable across links | II |
|
||||
| `local(name, { version, input, members })` | an object type on its own host | IV |
|
||||
| `method(args?, result?)` | a method in a base declaration | II |
|
||||
| `evolve(prev, (e) => …)`, `checkAccepts(contract, accepts)` | the next contract version; the provider's mappings (**built**) | XVI |
|
||||
| `Implementation<typeof C>` | the type of a provider for the current version (**built**) | XVI |
|
||||
| `str num int bool nul lit oneOf union obj arr map opt bytes unknown`, `Static` | schema helpers (**built**) | II |
|
||||
| `defineState(name, { schema, version, initial, checkpointWhen, persist?, keyed?, ordered?, migrate? })` | a document | V |
|
||||
| `defineTag<T>(name)` | a tag; `store.retireByTag(tag, value)` | V |
|
||||
| `defineJob(name, { version, args, progress, result, concurrency?, persist?, accepts? })` | a job type | XI |
|
||||
| `defineRegistry(name, { version, compose, accepts? })` | an extension point | XIII |
|
||||
| `bindFacet<P>()` | base code's `defineFacet`, with factories' principals typed as the app's `P` (working name) | III |
|
||||
| `defineFacet((host, self) => …)` | a plugin's setup for one kind of host; imported from base code (`@studio/core`) | II, III |
|
||||
| `constant(value)` | a readable that never changes (start data) | IX |
|
||||
| `broken(error)` | a failure that is not retried by the backoff | VI |
|
||||
| `inVariant(ref, …)`, `expectVariant(ref, …)`, `RefIn<…>` | narrowing a reference by variant | III |
|
||||
| `mapShared(fn)` | an array map that keeps unchanged items identical (planned in Delta) | VIII |
|
||||
| `OutputBuffer`, `Progress` | bounded output and throttled progress (`@earendil-works/chord/output`) | XI |
|
||||
| `memoryLinkPair()`, `ipcLink(child)`, `webSocketLink(ws)` | link adapters | I |
|
||||
| `DocumentStorage`, `registerDocumentStorageConformance` | the storage port and its shared test suite | V |
|
||||
|
||||
# Timeouts and Defaults {.appendix #defaults}
|
||||
|
||||
| Timeout or default | Bounds | Default | Override |
|
||||
| --- | --- | --- | --- |
|
||||
| `closeTimeout` | a native's `close`, then reported as a possible leak | 30 s | per native |
|
||||
| call drain | an ending object's own call handlers | = `closeTimeout` | per type |
|
||||
| `readyTimeout` | activation; an awaited `get` | 60 s | per `get` |
|
||||
| park deadline | parked calls (link drop, loading, rollout) | 60 s | per `get` (`parkTimeout`); per type |
|
||||
| routine handover | a new routine waiting for the old handler on reload | 10 s | per routine |
|
||||
| `finish` retry | backoff of a throwing job `finish` | 1 s to 60 s | per job |
|
||||
| rollout install | one host installing a revision | 60 s | none |
|
||||
| linger | an object kept alive after its last reference | per type | per type |
|
||||
| backoff | retries of transient failures, per address | 1 s doubling to 60 s | none |
|
||||
| eviction | keyed persisted documents idle in memory | 10 minutes | |
|
||||
| client WebSocket limits | per part | 16 MiB compressed, 64 MiB decoded | per link kind |
|
||||
| compression | JSON parts compressed at their origin | above about 16 KiB | |
|
||||
| worker keep-alive (p2, app code) | idle time before the supervisor stops a worker | 5 minutes | app |
|
||||
|
||||
# Versions {.appendix #versions}
|
||||
|
||||
| Surface | Version | On change | Chapter |
|
||||
| --- | --- | --- | --- |
|
||||
| object type | `version` plus fingerprint | `evolve` and `checkAccepts`; otherwise `contract_mismatch` | XVI |
|
||||
| document | `version` plus schema fingerprint | pure `migrate` on access; no bump fails `broken` | V |
|
||||
| job | `version` | `accepts` on outcomes and `running`; `blocked` without | XI |
|
||||
| registry | `version` | `accepts` on contributions; unmatched ones left out and reported | XIII |
|
||||
| native | none | restarts on reload | IX |
|
||||
| Durable tasks and documents | Durable's `version` | Durable's `migrate` | XIV |
|
||||
| link protocol | protocol version | refused; hosts update together | XVII |
|
||||
| UI components, Chord and app APIs | none yet | exposed as they are | |
|
||||
|
||||
# Errors and States {.appendix #errors}
|
||||
|
||||
`ChordError` has a stable `code` and a finer `reason`; on the wire it is `{ code, reason?, message, details? }`
|
||||
(specified in `layer-1.md`, not built).
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `invalid` | input, arguments or placement rejected (`invalid/input`, `invalid/args`, `invalid/placement`) |
|
||||
| `contract_mismatch` | the consumer's version and fingerprint are neither current nor accepted |
|
||||
| `forbidden` | the caller may not do this |
|
||||
| `gone` | the object or hold ended |
|
||||
| `not_ready` | not activated in time |
|
||||
| `released` | the reference was released |
|
||||
| `too_large` | a part exceeded its size limit |
|
||||
| `unavailable` | could not be reached before the deadline (`unavailable/locked` and others) |
|
||||
| `aborted` | cancelled by a signal |
|
||||
|
||||
| Type | Values |
|
||||
| --- | --- |
|
||||
| `FailureCause` | `{ kind: "transient"; error }` (retried with backoff) or `{ kind: "broken"; error }` (retried only by a trigger) |
|
||||
| reference state | `loading`, `live`, `failed` (cause, `retryAt`), `released` |
|
||||
| job outcome | `{ kind: "done"; result }` or `{ kind: "failed"; error: "threw" \| "aborted" \| "lost" }` |
|
||||
| job phase | `running`, `finishing`, `blocked` |
|
||||
| rollout status of a provider | `updating` (calls park), rejected (fail at once), same revision (`contract_mismatch`) |
|
||||
| host install report | `installed`, `installed_failed`, `rejected` |
|
||||
|
||||
# Rejected Designs {.appendix #rejected}
|
||||
|
||||
From `design.md` §31, grouped. Each was considered and is not coming back without a new reason.
|
||||
|
||||
| Area | Rejected |
|
||||
| --- | --- |
|
||||
| reload | drain-based handover; object restart for state changes; a factory trial; a migrate dry run; a consumer contract check during staging; reload votes; per-plugin rollback within a host; keeping natives across reloads by name; `restart_required`; host values and a development freeze |
|
||||
| state | version fences without transactions; volatile documents; per-group lines; optimistic transactions; `tx.get`; a JSONL document storage; validating state and documents on commits or reads; copying values on same-host calls; op-rewriting projections and a view algebra; Durable's Session as the object store |
|
||||
| objects and references | resources replaced in place (`replace`, `deps`, stale handles); revoking proxies on `.value`; consumer-side linger; detached references counting toward presence; an `optional` flag on references; `opaque` members; callbacks as method arguments; generic call or request ids; a stream type in Chord |
|
||||
| registries | registries created at runtime; entries resolved through mutable indirection; generic registry keys with a `conflict` flag; numeric middleware priorities; registry composition as a staging check |
|
||||
| principals and placement | principal field patterns and re-stamping for revocation; `storeKey`; placement as data naming an input field; host-name strings in app code; "magic" input-field matching for delete |
|
||||
| wire | an outbox for transient delivery errors; cross-relay reset coordination; count limits that refuse calls or holds; contracts taken from the running app |
|
||||
| process and tooling | a topology layer above the host API; loader variants A and B; Chord ending the process on a storage failure; `defaultProjectTrust: "always"`; ceremony (`native/` directories, `defineNative`, lints); weakening type checking |
|
||||
|
||||
# Glossary {.appendix #glossary}
|
||||
|
||||
**Adapted binding:** a consumer bound at an older accepted contract version, served through the provider's mappings.
|
||||
**Base code:** the app's own code, imported statically; a change restarts the hosts. **Client:** a tab or TUI host,
|
||||
not addressable. **Contract:** an object type's declaration (name, input, placement, members, version).
|
||||
**Contribution:** a value a plugin adds to a registry. **`ctx`:** what every call receives. **Derive:** a value
|
||||
computed from readables. **Document:** a named JSON value an object owns. **Facet:** a plugin's code for one kind of
|
||||
host. **Factory:** the synchronous function that declares an object. **Fingerprint:** a hash of a contract's or
|
||||
schema's normal form. **Flow:** input into running work, kept as state by its owner. **Host:** one participant of the
|
||||
app, with an identity and an incarnation. **Incarnation:** the id a host gets on every start. **Input:** an object's
|
||||
key among objects of its type; with the type and the caller's principal key it identifies the object. **Job:** owned work that outlives its call. **Link:** an ordered connection between
|
||||
two hosts. **Local type:** an object type reachable only on its own host, without principals. **Native:** an OS-level
|
||||
value opened with `self.open`. **Park:** a call waiting for a reference to become live again. **Placement:** the
|
||||
function from input to host address. **Plugin:** a directory with a `package.json`, reloadable. **Principal:** who
|
||||
calls, stamped by the link. **Principal key:** the string that identifies a principal in object identities. **Pure
|
||||
object:** an object without natives; swapped in place on reload. **Readable:** a value plus its ordered changes.
|
||||
**Reference:** what `get` returns. **Registry:** an owner's extension point. **Relay:** a host forwarding messages
|
||||
between two links, rewriting only headers. **Reload:** swapping a plugin's code in running hosts. **Restart:** ending a
|
||||
process. **Revision:** the complete code set the hosts should run. **Revocation:** ending live access for principals
|
||||
matched by `revokedBy`. **Routine:** standing behaviour of an object. **Server:** the addressable root host. **Shared
|
||||
store:** a local object that holds data once behind per-principal views. **Tag:** a typed label on documents, used to
|
||||
retire them together. **Transaction:** an atomic set of document writes on the object's line. **Variant:** the set of
|
||||
members a principal gets. **View:** a per-principal remote object in front of a shared store. **Worker:** a host the
|
||||
server starts for one session.
|
||||
|
||||
# Sources {.appendix #sources}
|
||||
|
||||
| Source | Used for | Weight |
|
||||
| --- | --- | --- |
|
||||
| `packages/chord/docs/next/decisions.md` | every decision, review rounds 2 and 3 | wins where newer |
|
||||
| `packages/chord/docs/next/design.md` | the target design, all chapters | authoritative otherwise |
|
||||
| `packages/chord/src/next/contracts/` and its `README.md` | chapter XVI | built code |
|
||||
| `packages/chord/docs/next/layer-1.md` | errors, inputs, argument checks | draft spec |
|
||||
| `apps/p2/docs/p3-reviews/v2-review3-summary.md` | open Q11 to Q15, cuts C3 to C17 | current |
|
||||
| `apps/p2/docs/p3-reviews/v2-cost-model.md` | measurements, per-hop costs | current except its last section |
|
||||
| `apps/p2/docs/p3-functions/` | chapters XVIII to XX | current |
|
||||
| `apps/p2/docs/p3-handoff.md` | status and order of work | §3 partly stale |
|
||||
| `packages/durable/README.md` | the harness, submit, tasks, subagents | current |
|
||||
| `chord-next-walkthrough.md`, `kernel.md`, `step-*.md`, `apps/p2/docs/questions.md` | background only | lag the decisions (see What's Next) |
|
||||
| DeepSeek Harness at `687f282` (its Cordis tutorial, architecture, persistence, core and HMR docs), the Cordis paper (arXiv 2608.25512) | "Other Ways to Do It" | external, built systems |
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
---
|
||||
title: Chord v2
|
||||
kind: walkthrough
|
||||
kicker: Walkthrough · Chord v2
|
||||
subtitle: Build an app like p2 on Chord v2 and pi-durable, one concept at a time, with a tutor that draws the figures and asks the questions
|
||||
model: anthropic/claude-sonnet-5-5
|
||||
sources:
|
||||
- packages/chord/docs/next/explainer/book.md
|
||||
- packages/chord/docs/next/design.md
|
||||
- packages/chord/docs/next/decisions.md
|
||||
- packages/chord/docs/next/layer-1.md
|
||||
- packages/chord/src/next/contracts/README.md
|
||||
- packages/chord/src/next/contracts/*.ts
|
||||
- packages/durable/README.md
|
||||
truth: treat Chord v2 as fully built as designed; ignore what the sources say about build status. redesign-decisions.md wins over design.md; design.md wins over the guide (book.md)
|
||||
---
|
||||
|
||||
:::tutor
|
||||
The learner knows pi-durable (harness, conversations, tasks, tools) and nothing about Chord.
|
||||
|
||||
Teach Chord v2 as a finished, working runtime: never say a part is "not built", "planned" or "the target design", even
|
||||
though the sources say so in places. Open decisions may be mentioned as "still being decided" when a concept depends on
|
||||
one.
|
||||
|
||||
The running example is Studio, introduced on the page before the first concept (see the introduction). Use it in every
|
||||
concept and grow it step by step, as the guide book.md does chapter by chapter: read the matching guide chapter before
|
||||
each concept, and check details in design.md and the decision log. Name the guide chapter in "Read more" lines.
|
||||
|
||||
Keep code short and complete enough to read on its own. Prefer one figure per concept: a sequence for message orders,
|
||||
a flow for structure, a stepper for lifecycles.
|
||||
:::
|
||||
|
||||
:::intro
|
||||
#### What this is about
|
||||
|
||||
Picture **p2** (Pi Mobile): a web app for working with pi agents from any device. It runs on your laptop as one
|
||||
**server** process. Every chat session gets its own **worker** process, and in each worker a pi-durable agent works
|
||||
through its turns. You use it from **browser tabs** on your desktop or phone, and you can invite a colleague to watch.
|
||||
Most features (a notes panel, file browsing, terminals, provider logins) are **plugins**.
|
||||
|
||||
Now you change one line in a plugin and press Reload, while an agent is twenty minutes into a task. The panel should
|
||||
show the new code on every screen. The agent's turn, the running tool, the open connections and every bit of saved
|
||||
data should simply continue. No restart, no lost work.
|
||||
|
||||
**Chord v2** is the runtime that makes this normal. An app on Chord is a set of **objects** spread over the server, the
|
||||
workers and the tabs. Each object owns its data and its OS handles, declares what it needs, and Chord creates,
|
||||
connects, reloads and ends it. Because Chord knows every piece of state an object has, it can swap the object's code
|
||||
while its data stays.
|
||||
|
||||
#### The example: Studio
|
||||
|
||||
This walkthrough builds **Studio**, a small p2, on Chord v2 and pi-durable. It has the same three kinds of processes:
|
||||
|
||||
:::flow wide | Studio: one server, a worker per session with its agent, browser tabs as clients. The notes plugin is the first feature we build.
|
||||
row "Clients" muted
|
||||
box owner "owner's tab" "phone or desktop"
|
||||
box guest "guest's tab" "read-only"
|
||||
row "Server" accent
|
||||
box server "server" "session list, access" accent
|
||||
row "Workers" blue
|
||||
box worker "worker S1" "agent + notes" blue
|
||||
box worker2 "worker S2" "agent + notes" blue
|
||||
arrow owner -> server "WebSocket"
|
||||
arrow guest -> server "WebSocket"
|
||||
arrow server -> worker "IPC"
|
||||
arrow server -> worker2 "IPC"
|
||||
:::
|
||||
|
||||
The first feature is a **notes plugin**: a panel where the owner adds notes to a session, a guest reads them, and the
|
||||
agent can add notes through a tool. Each concept adds one piece to Studio, until it has everything a p2 needs: who may
|
||||
do what, saved data, live updates, background jobs, logins, extension points, the agent, and reload.
|
||||
|
||||
#### How it progresses
|
||||
|
||||
1. **The pieces:** processes, objects, callers, shared data, saved data.
|
||||
2. **Living objects:** references that survive restarts, revocation, computed values, OS handles, standing behaviour,
|
||||
background jobs, input from people.
|
||||
3. **The app:** extension points between plugins, and the pi-durable agent inside Chord.
|
||||
4. **Change:** reloading code while everything runs, evolving contracts, and what goes over the wire.
|
||||
:::
|
||||
|
||||
# What we're building {#goal part="The pieces"}
|
||||
Guide preface ("Before we begin", "Ten things to know"). Must cover, briefly and concretely: the reload scenario (phone,
|
||||
agent mid-turn, guest watching, one-line fix, Reload) and why restarting processes or hot-swapping modules fails; the
|
||||
one rule behind Chord (every piece of state, OS handle and running work has one owner, an object, and objects only
|
||||
declare what they need); Studio's pieces from the introduction. Do not go into APIs yet.
|
||||
|
||||
:::check
|
||||
1. Why is restarting the worker processes not a good way to load new plugin code?
|
||||
+++ answers
|
||||
1. It loses the agent's turn in flight, open terminals and connections (pi-durable would resume the turn, but the running tool and the connections are gone).
|
||||
:::
|
||||
|
||||
# The shape of an app {#shape part="The pieces"}
|
||||
Guide chapter I. Must cover: hosts (server, workers, clients), links, host kinds and placement, createHost/load/connect/accept/route,
|
||||
clients reach workers only through the server, incarnations, keep-alive is app code (host.activity).
|
||||
|
||||
:::check
|
||||
1. A tab wants an object that lives on worker S1, which is not running. What happens, in order?
|
||||
2. Why does Chord have no listen() call?
|
||||
+++ answers
|
||||
1. The tab's call goes to the server; the address is not the server's, so it uses route(Session): authorize, then resolve starts the worker and returns a link plus principal; the server relays the hold, the worker builds the object and answers with a snapshot.
|
||||
2. Entry points differ too much (loopback WebSocket, Radius channels, a Durable Object edge, memory links); each authenticates and calls accept.
|
||||
:::
|
||||
|
||||
# A first object {#first-object}
|
||||
Guide chapter II. Must cover: contract (name, input, placement, variants, version): the input is the object's key
|
||||
among objects of its type, `placement` computes the host from it; schema helpers, facet and factory (a facet imports
|
||||
Studio's own defineFacet from @studio/core), serve and get, methods return data, placement checked on both sides.
|
||||
|
||||
:::check
|
||||
1. Two tabs of the owner ask for Notes of S1. How often does the factory run?
|
||||
2. A caller with an outdated placement rule asks worker S2 for Notes of S1. What happens?
|
||||
+++ answers
|
||||
1. Once per principal key; the owner's two tabs on the same access path share the object, so once.
|
||||
2. The provider runs the placement function itself and refuses with invalid/placement.
|
||||
:::
|
||||
|
||||
# Who is calling {#principals}
|
||||
Guide chapter III. Must cover: the app's principal type, links stamp principals, principal key in the object identity,
|
||||
variants per principal, narrowing with inVariant/RefIn, and how a facet knows P: base code exports defineFacet bound to
|
||||
P (bindFacet<P>()), every facet imports it, and host.load accepts only facets typed for its P.
|
||||
|
||||
:::check
|
||||
1. Can a client send a principal field to claim it is the owner?
|
||||
2. Why do guests not get an add method that throws "forbidden"?
|
||||
3. The notes plugin's worker facet narrows principal.kind === "guest". Where does the type checker get Studio's principal union from?
|
||||
+++ answers
|
||||
1. No: a client's principal field is always overwritten by the link's stamp; only a trusted parent link may carry one.
|
||||
2. Variants: the reader variant simply has no add member, and Chord refuses calls to members outside the caller's variant.
|
||||
3. From Studio's base code: @studio/core exports defineFacet = bindFacet<P>(), the facet imports that one, and the worker's host.load accepts only facets typed for the same P.
|
||||
:::
|
||||
|
||||
# One store, many views {#shared-store}
|
||||
Guide chapter IV. Must cover: local types, the shared store pattern, attribution as data, argument validation for every caller (R7, Q15 open).
|
||||
|
||||
:::check
|
||||
1. Where does "who wrote this note" come from if the store is local and has no principal?
|
||||
+++ answers
|
||||
1. The remote view knows the caller and passes the author into the store's method as data.
|
||||
:::
|
||||
|
||||
# Writing it down {#documents}
|
||||
Guide chapter V. Must cover: defineState, self.tx, drafts and ops, persistence only for stable identities, versions with migrate, tags, storage.
|
||||
|
||||
:::check
|
||||
1. Why may a remote per-principal object not persist documents?
|
||||
2. A schema changes but the version is not bumped. What happens?
|
||||
+++ answers
|
||||
1. Its identity includes the principal key, which changes (devices, grant revisions); persisted data needs a stable identity, so it lives in a local store or a host-lifetime object.
|
||||
2. The stored fingerprint differs from the definition's at access, and the object fails broken ("schema changed without a version bump").
|
||||
:::
|
||||
|
||||
# Holding on {#references part="Living objects"}
|
||||
Guide chapter VI. Must cover: reference lifecycle, ref.state, parking, transient vs broken failures, backoff, ctx.
|
||||
|
||||
:::check
|
||||
1. The link to a worker drops while a tab calls a method. What does the call do?
|
||||
+++ answers
|
||||
1. A call already in flight fails like on a disconnect; calls made while the link is down park until the reference is live again (up to the park deadline, 60 s), then fail unavailable.
|
||||
:::
|
||||
|
||||
# Taking access away {#revocation}
|
||||
Guide chapter VII. Must cover: revokedBy, host.revoke, propagation to children, no re-stamping, lasting denial in app data.
|
||||
|
||||
:::check
|
||||
1. After revoke({ grant: "g1" }), Ann reloads her tab. Why does she not get access back?
|
||||
+++ answers
|
||||
1. revoke cuts only live access; the app's guest record is blocked and authenticate refuses it on the new connection.
|
||||
:::
|
||||
|
||||
# Computed values {#derives}
|
||||
Guide chapter VIII. Must cover: derives, the footgun (new objects for unchanged data), mapShared, container width, presence with host.references.
|
||||
|
||||
:::check
|
||||
1. Why is items.map(stripAuthor) in a derive expensive on every commit?
|
||||
+++ answers
|
||||
1. It builds new objects for every unchanged note, so the diff walks and sends O(value) instead of O(change); mapShared keeps unchanged items identical.
|
||||
:::
|
||||
|
||||
# Things the OS gives you {#natives}
|
||||
Guide chapter IX. Must cover: natives with self.open, pure vs native objects, start data with constant, lifetime host, call-scoped natives.
|
||||
|
||||
:::check
|
||||
1. What happens to a native object on reload, and why?
|
||||
+++ answers
|
||||
1. It restarts: its natives close and reopen with the new code, because old and new code sharing an OS handle would interleave unchecked.
|
||||
:::
|
||||
|
||||
# Standing behaviour {#routines}
|
||||
Guide chapter X. Must cover: routines (on, every, loop), serialized handlers, reconciling instead of notifying, pub/sub reports with ctx.incarnation.
|
||||
|
||||
:::check
|
||||
1. Why does archiving a session not call notes.archive(session) directly?
|
||||
+++ answers
|
||||
1. A notification can be lost (worker not running, link drops, crash); a routine on the catalog's sessions reconciles from the current value, also after a crash.
|
||||
:::
|
||||
|
||||
# Work that outlives the call {#jobs}
|
||||
Guide chapter XI. Must cover: defineJob, the intent / effect / outcome sandwich, dedupe of unsettled runs (Q9), finish in current code, abort as a request.
|
||||
|
||||
:::check
|
||||
1. A shell job's run crashes after writing the result entry. How is a duplicate entry avoided?
|
||||
+++ answers
|
||||
1. The external write carries a key derived from ctx.jobId (requestId shell:jobId), so Durable dedupes it.
|
||||
:::
|
||||
|
||||
# Asking a person {#flows}
|
||||
Guide chapter XII. Must cover: a flow is state, not a call; steps helper; the Questions service; decisions #9 and #11 open.
|
||||
|
||||
:::check
|
||||
1. Why not a long-held step() call that returns the next login step?
|
||||
+++ answers
|
||||
1. A link drop fails it and the consumer cannot tell if it missed a step; state re-holds by itself and shows the current step.
|
||||
:::
|
||||
|
||||
# Extension points {#registries part="The app"}
|
||||
Guide chapter XIII. Must cover: registries, compose, who may contribute (R21), contributions that change, optional dependencies, HTTP component, channels, chunked transfers.
|
||||
|
||||
:::check
|
||||
1. Radius needs forwards from the ports plugin, which may be missing. How is that expressed?
|
||||
+++ answers
|
||||
1. Radius owns a registry (radius.forwards) and ports contributes to it; without ports the table is empty.
|
||||
:::
|
||||
|
||||
# The agent {#agent}
|
||||
Guide chapter XIV. Must cover: the Harness as a core host-lifetime object, HarnessModels / HarnessEnvironments / HarnessExtensions registries, chord(fn, context) for tools, approval via Questions.
|
||||
|
||||
:::check
|
||||
1. How does a Durable tool get a Chord ctx?
|
||||
+++ answers
|
||||
1. Through chord(fn, context): the host is in Durable's context under a context key, and host.root gives fn a ctx.
|
||||
:::
|
||||
|
||||
# Reload and rollout {#reload part="Change"}
|
||||
Guide chapter XV. Must cover: load, staging, commit, per-object swap or restart, rollback all-or-nothing per host, revisions, rollout order, updating vs contract_mismatch.
|
||||
|
||||
:::check
|
||||
1. A staging error happens in one of three plugins being reloaded on a host. What runs afterwards?
|
||||
+++ answers
|
||||
1. The whole host stays on its previous code; rollback is all-or-nothing per host.
|
||||
:::
|
||||
|
||||
# Changing contracts {#contracts}
|
||||
Guide chapter XVI; the code is in src/next/contracts. Must cover: evolve, checkAccepts and mapping directions, binding by version and fingerprint, new history bases, the fingerprint normal form.
|
||||
|
||||
:::check
|
||||
1. Which way does a state mapping go, and which way an args mapping?
|
||||
2. Why can an input change not be adapted?
|
||||
+++ answers
|
||||
1. State: current to old (the old consumer reads). Args: old to current (the old caller sends).
|
||||
2. The input is the object's address; changing it changes what the object is, so it needs a new history base and old consumers get contract_mismatch.
|
||||
:::
|
||||
|
||||
# On the wire {#wire}
|
||||
Guide chapter XVII. Must cover: messages and parts, relays rewrite only headers, one state message per commit per link, flow control (R19, Q3), size limits per part, the cost model, telemetry.
|
||||
|
||||
:::check
|
||||
1. What does the server do with a state part it relays from a worker to a tab?
|
||||
+++ answers
|
||||
1. It reads and rewrites only the header (maps ids, groups parts per destination link) and forwards the part without decoding it.
|
||||
:::
|
||||
|
||||
# Other ways to do it {#compared part="In context"}
|
||||
Guide chapter "Other Ways to Do It" (after chapter XX). Must cover: Cordis (Koishi's plugin framework since 2019) and
|
||||
DeepSeek Harness (dsh, everything is a plugin, built on Cordis): reversible effects on a plugin fiber, inject-based
|
||||
services, reload by unload-and-load; dsh's durable session log and its "interrupted" crash repair. Then what the
|
||||
guarantees are in practice (the chapter's section of that name): disposers run concurrently and are only as good as
|
||||
their authors, listener order follows registration order and changes on reload, services are untyped strings and a
|
||||
provider reload restarts all dependents, and reloading a core service stops live agents. Then the two differences that
|
||||
matter: what survives a reload (Cordis: nothing of the plugin's or its dependents' in-memory state; Chord: a pure
|
||||
object's data and running work) and reach (one process vs. a tree of hosts with principals). Say what Chord costs:
|
||||
more concepts and machinery. A flow or table comparing a reload in both helps.
|
||||
|
||||
:::check
|
||||
1. A Cordis plugin keeps a list of notes in a field of its instance. What happens to the list when the plugin hot-reloads, and what would the same plugin do on Chord v2?
|
||||
2. Two Cordis plugins register waterfall listeners on the same tool-policy event; the first one short-circuits. What changes after the first plugin is hot-reloaded?
|
||||
3. Why does an app like p2 need Chord's multi-host model, when dsh runs its plugin tree in one Host process?
|
||||
+++ answers
|
||||
1. The old fiber unloads and the new one starts empty, so the list is gone unless it was kept in a service or on disk. On Chord the notes are a document of a pure object; a reload swaps the object's code and keeps the document.
|
||||
2. Its listener is registered again at the end of the list, so the other plugin's listener now runs first and the decision can change, unless the listener was registered with prepend.
|
||||
3. p2 has a worker process per session and clients (including guests) in other browsers; objects must live on the right host, callers must be stamped principals, and reload has to roll out across server, workers and tabs. A single-process tree does not cover that.
|
||||
:::
|
||||
|
||||
733
packages/chord/docs/next/guide-plugins-hosts/book.md
Normal file
733
packages/chord/docs/next/guide-plugins-hosts/book.md
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
---
|
||||
title: Hosts and Plugins: a tutorial for Chord v2
|
||||
cover: Hosts / and Plugins
|
||||
kicker: A guide · Chord v2 · 2026-11-02
|
||||
subtitle: Build a small notes app step by step, from one host to plugins on a server and on workers, edited while it runs
|
||||
stats:
|
||||
- 3 | kinds of host in an app: server, worker, client
|
||||
- 1 | file per host kind in a plugin
|
||||
- 0 | restarts to apply a plugin edit
|
||||
- 1 | rule: plugins talk only through object types and registries
|
||||
note: |
|
||||
A tutorial for people building apps on Chord v2, written by the redesign session from `design.md`, `slice-7c.md`,
|
||||
`slice-10.md`, `slice-12.md`, `slice-13.md`, the decision log and the code in `packages/chord/src/next` and
|
||||
`src/next-node`. API names are the current ones. The early chapters wire hosts by hand to show the parts; the last
|
||||
chapter shows the app kit (`chord/next/app`), which does that wiring for an app.
|
||||
colophon: Hosts and Plugins · third edition · 2026-11-02 · Chord v2 slices 1-10, 12 and 13
|
||||
---
|
||||
|
||||
# Before we begin {.preface}
|
||||
|
||||
You want to build a notes app. You type a note in a browser tab, and every other tab sees it at once. Each project
|
||||
gets its own notes in its own process, so a project that misbehaves cannot take the others down. And you, or an agent
|
||||
you work with, add features while the app runs: a search, an export button, a word count, without restarting
|
||||
anything.
|
||||
|
||||
Chord gives you three things for that. **Hosts**: the processes of your app (the server, a worker per project, the
|
||||
browser tabs), connected to each other. **Objects**: the state and methods your app is made of, living on one host and
|
||||
used from any other. **Plugins**: directories of code that declare objects for each kind of host, loaded and
|
||||
reloaded while the app runs.
|
||||
|
||||
This book builds the app in that order. Each chapter adds one concept and shows complete code.
|
||||
|
||||
## Seven things to know
|
||||
|
||||
:::findings
|
||||
1. **A host is one participant.** The server, each worker, each browser tab and each terminal UI is a host; hosts
|
||||
connect over links. Tabs and terminal UIs are clients: no address, they connect to the server and ask.
|
||||
2. **An object has a type, and a type is a contract.** The contract names the object's state and methods; any host can
|
||||
ask for an object by type and params, and Chord finds where it lives.
|
||||
3. **A facet declares, an object acts.** A facet is a plugin's code for one host kind: it says which object types this
|
||||
host serves. The objects hold the state and do the work.
|
||||
4. **A plugin is a directory** (or a single file). A `package.json` with the plugin's name, and one file per host kind:
|
||||
`server.ts`, `worker.ts`, `web.tsx`. It imports its host only through the app's versioned API (`@app/api/v1`).
|
||||
5. **The server builds, every host evaluates.** The plugins component builds each facet into a small script; the
|
||||
server, workers and tabs load the scripts for their kind.
|
||||
6. **Plugins meet through types and registries.** One plugin uses another's objects through its contract, or
|
||||
contributes to a registry another defines. Nothing else crosses between plugins.
|
||||
7. **An edit is a rollout.** Change a file, build, roll out: the server, then every worker, swap the plugin's objects in
|
||||
place; a broken edit keeps the old code running and reports why.
|
||||
:::
|
||||
|
||||
:::flow wide animate | How it all fits: plugins on disk are built once on the server; each host evaluates the facets of its kind; tabs and the TUI are clients that reach workers' objects through the server.
|
||||
row "On disk" muted
|
||||
box disk "plugins/notes/" "`server.ts`, `worker.ts`, `web.tsx`, `tui.ts`, `contract.ts`" muted
|
||||
box core "src/ (base code)" "`@app/api/v1`: Chord's surface, host kinds, principals" muted
|
||||
row "Server process" accent
|
||||
box comp "plugins component" "discover, build every facet, watch, roll out" accent
|
||||
box server "server host" "server facets; routes Project → workers"
|
||||
row "Clients and workers"
|
||||
box tab "client: tab or TUI" "web or tui facets, local UI objects; holds `Notes { p1 }`" blue
|
||||
box w1 "worker p1" "worker facets; serves `Notes { p1 }`"
|
||||
arrow disk -> comp "read, build"
|
||||
arrow core -> comp "host packages"
|
||||
arrow comp -> server "server scripts"
|
||||
arrow comp -> tab "web or tui scripts"
|
||||
arrow tab -> server "hold Notes { p1 }"
|
||||
arrow server -> w1 "start (worker scripts), relay"
|
||||
note "Each host loads only its own kind's facets. Plugins meet only through object types and registries."
|
||||
:::
|
||||
|
||||
## Words used throughout
|
||||
|
||||
A **host** is one participant of the app, made with `createHost`. A **host kind** (`Server`, `Session`) describes
|
||||
addressable hosts; an **address** (`Session({ id: "p1" })`) names one. An **object type** is a contract: `local` (used
|
||||
on its own host only) or `remote` (used from any host). An **object** is one instance of a type, per params. A
|
||||
**facet** is a plugin's declarations for one host kind. A **plugin** is a directory with a `package.json`. A
|
||||
**reference** is what you get when you ask for an object (`host.get`, `self.get`, `ctx.get`). A **document** is data an object
|
||||
stores, changed in **transactions**; a **table** holds many records with queries, in the host's **store**; a **derive** is a value computed from others; a **native** is an outside
|
||||
resource an object opens and closes. A **registry** is an
|
||||
extension point: one plugin defines it, others contribute. A **rollout** applies a build to the hosts in order.
|
||||
|
||||
# One Host {#host lede="The smallest app: one process, one object type, one call."}
|
||||
|
||||
Start without plugins. One host, one object that keeps notes:
|
||||
|
||||
```ts
|
||||
import { createHost, defineFacet, defineState, local, method, obj, arr, str } from "@earendil-works/chord/next";
|
||||
|
||||
const NoteList = obj({ items: arr(str()) }); // one schema, used by the contract and the document
|
||||
|
||||
// the contract: what a Notes object offers
|
||||
const Notes = local("notes.store", {
|
||||
version: 1,
|
||||
params: {}, // one object per host: no params
|
||||
members: {
|
||||
list: NoteList, // state anyone can read and watch
|
||||
add: method(obj({ text: str() })), // a method: arguments checked by Chord
|
||||
},
|
||||
});
|
||||
|
||||
// the document the object stores (its own version: storage can change without changing the contract)
|
||||
const NotesDoc = defineState("notes", { schema: NoteList, version: 1, initial: () => ({ items: [] }) });
|
||||
|
||||
// the facet: which types this host serves, and how to make each object
|
||||
const facet = defineFacet((host) => {
|
||||
host.serve(Notes, (_params, self) => {
|
||||
const doc = self.state(NotesDoc);
|
||||
return {
|
||||
list: doc,
|
||||
add: async ({ text }, ctx) => self.tx(async (tx) => { (await tx.draft(doc)).items.push(text); }, ctx),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const server = createHost({ onError: console.error });
|
||||
await server.load([{ name: "@app/notes", facet }], ctx);
|
||||
const notes = await server.get(Notes, {}, ctx); // a reference: live, with its state
|
||||
await notes.add({ text: "buy milk" }, ctx);
|
||||
console.log(notes.list.value); // { items: ["buy milk"] }
|
||||
```
|
||||
|
||||
Four things happened. The **contract** `Notes` says what the object offers; the **document** says what it stores.
|
||||
Here they have the same shape, so one schema serves both; they are separate because they change separately (an
|
||||
object may offer a computed value or a shortened view instead of what it stores, and storage can change its shape
|
||||
without users noticing).
|
||||
The **facet** says this host serves
|
||||
`Notes`, with a **factory** that declares the object's parts: a document, and a method writing it in a transaction.
|
||||
`load` publishes the facet. `get` returns a **reference** once the object is live; its members are the contract's.
|
||||
|
||||
The factory only declares: it is synchronous and opens nothing. Chord activates the object (loads its documents, opens
|
||||
what it asked to open) and then serves calls. That is why the same factory can run again when the code changes.
|
||||
|
||||
# An Object's Parts {#parts lede="Documents, tables, transactions, computed values, watching, and outside resources: the vocabulary of every factory."}
|
||||
|
||||
A factory builds an object from a handful of parts, each declared with one call on `self`. The notes object grows to
|
||||
show all of them.
|
||||
|
||||
**Documents** hold the object's data: `self.state(NotesDoc)` gives a handle whose `.value` is the current data. Data
|
||||
changes only in a **transaction**: `self.tx(fn, ctx)` runs `fn` with `tx`, and `await tx.draft(handle)` gives a
|
||||
mutable copy of the document's data; whatever you change on it is committed at once when `fn` returns, or not at all
|
||||
if it throws.
|
||||
|
||||
```ts
|
||||
add: async ({ text }, ctx) => self.tx(async (tx) => {
|
||||
const draft = await tx.draft(doc); // the document's data, writable inside this transaction only
|
||||
draft.items.push(text); // recorded as a change, committed when the function returns
|
||||
}, ctx),
|
||||
```
|
||||
|
||||
Documents live in memory unless they are **persisted**: `defineState(..., { persist: true, checkpointWhen })` keeps
|
||||
one in the host's **store** (a SQLite file the app opens and gives to `createHost({ store })`), loaded when the
|
||||
object activates. For many records with queries, an object declares a **table**:
|
||||
|
||||
```ts
|
||||
const NoteRows = defineTable("notes", {
|
||||
version: 1,
|
||||
columns: { id: str(), text: str(), at: int() },
|
||||
key: ["id"],
|
||||
indexes: { by_time: ["at"] },
|
||||
});
|
||||
|
||||
host.serve(NoteStore, (_params, self) => { // a local type with empty params: it may persist
|
||||
const rows = self.table(NoteRows); // created (or migrated) when the object activates
|
||||
const recent = query(NoteRows).orderBy((n) => n.at, "desc");
|
||||
return {
|
||||
add: async ({ text }, ctx) => self.tx(async (tx) => tx.put(rows, { id: uuid(), text, at: Date.now() }), ctx),
|
||||
page: async ({ after }, ctx) => rows.page(recent, 50, ctx, { after }), // 50 at a time, with a cursor
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
Persisted data lives only in `local` objects with empty params (one per host and type), so its address never depends
|
||||
on who asked or with which params; an object with params (a view per project) reads from such a store. Writes go
|
||||
through the same transactions as documents: a note row and a document can change in one commit.
|
||||
|
||||
A **derive** is a value computed from other values, recomputed whenever they change. It is how an object offers
|
||||
something it does not store:
|
||||
|
||||
```ts
|
||||
const count = self.derive([doc], ([notes]) => notes.items.length); // 0, 1, 2, … as notes are added
|
||||
return { list: doc, count, add }; // `count` can be a member like `list`
|
||||
```
|
||||
|
||||
**Watching** is how anyone follows a value as it changes: a document handle, a derive, a reference's member. `watch`
|
||||
gives the current value, then one call per change:
|
||||
|
||||
```ts
|
||||
const watch = await notes.count.watch(ctx);
|
||||
watch.start(async (call) => {
|
||||
if (call.kind === "value") console.log("notes:", call.value); // "failed" calls say a value cannot be computed now
|
||||
});
|
||||
```
|
||||
|
||||
A **routine** is the object watching on its own behalf: `self.on(source, fn)` runs `fn` with each new value, for as
|
||||
long as the object lives (a reconciler: "whenever the notes change, rebuild the export file").
|
||||
|
||||
A **native** is something outside Chord the object opens and must close: a file watcher, a database connection, a
|
||||
child process. `self.open(open, close)` opens it while the object activates and closes it when the object ends:
|
||||
|
||||
```ts
|
||||
const inbox = self.open(async (ctx) => watchDirectory(inboxPath, ctx.abortSignal), (watcher) => watcher.close());
|
||||
self.onLive(async (ctx) => { // a routine started once the object is live
|
||||
for await (const file of inbox.value.files(ctx.abortSignal)) await importNote(file, ctx);
|
||||
});
|
||||
```
|
||||
|
||||
Natives are why some objects restart when their plugin changes: an open connection cannot be moved to new code, so
|
||||
Chord closes it and opens it again. Objects without natives are swapped in place.
|
||||
|
||||
# Several Hosts {#hosts lede="A server, a worker per project, browser tabs: how they find each other's objects."}
|
||||
|
||||
Now give each project its own process. Declare the kinds of addressable host, and make notes a `remote` type that
|
||||
lives on its project's worker:
|
||||
|
||||
```ts
|
||||
export const Server = hostKind("server");
|
||||
export const Project = hostKind("project", { id: str() });
|
||||
|
||||
export const Notes = remote("notes", {
|
||||
version: 1,
|
||||
params: { project: str() },
|
||||
hostAddress: ({ project }) => Project({ id: project }), // where an object of these params lives
|
||||
variants: { editor: { state: { list: NoteList }, methods: { add: method(obj({ text: str() })) } } },
|
||||
});
|
||||
```
|
||||
|
||||
Two things are new. A `remote` type can be used from any host, and it has **variants**: different sets of members
|
||||
for different callers, chosen by the factory (an `editor` variant with `add`, a `reader` variant for guests without
|
||||
it). And `hostAddress` says where its objects live; it is the whole routing story.
|
||||
|
||||
A tab asks for `Notes { project: "p1" }`. Its host computes the address,
|
||||
`Project(p1)`, and sends the request to its parent, the server. The server has a **route** for `Project`: it starts (or
|
||||
finds) the worker for `p1` and passes the request on. The worker serves `Notes`. The tab gets a reference whose state
|
||||
follows the worker's document, one frame per change.
|
||||
|
||||
:::flow wide animate | A tab holds a project's notes: the server routes by address to the project's worker and relays, without reading the data.
|
||||
row "Clients" blue
|
||||
box tab1 "tab A" "holds Notes { project: p1 }"
|
||||
box tab2 "tab B" "holds Notes { project: p1 }"
|
||||
row "Server" accent
|
||||
box server "server" "route Project → worker"
|
||||
row "Workers" muted
|
||||
box w1 "worker p1" "serves Notes { p1 }"
|
||||
box w2 "worker p2" "serves Notes { p2 }"
|
||||
arrow tab1 -> server "hold"
|
||||
arrow tab2 -> server "hold"
|
||||
arrow server -> w1 "relay"
|
||||
:::
|
||||
|
||||
Each host is created the same way, with what it is:
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError });
|
||||
|
||||
// worker.ts (started by the server for one project)
|
||||
const worker = createHost<P>({ address: Project({ id }), principal: { kind: "worker", project: id }, onError });
|
||||
worker.connect(parentLink, { principal: { kind: "server" }, trusted: true }, ctx);
|
||||
|
||||
// a browser tab: no address, it is a client
|
||||
const tab = createHost<P>({ onError });
|
||||
tab.connect(webSocketLink(new WebSocket("/chord")), { principal: { kind: "server" } }, ctx);
|
||||
```
|
||||
|
||||
`P` is your app's type of **principal**, who is calling: an owner, a guest, the server, a worker. Links stamp it;
|
||||
a `remote` object is made per principal, so a guest can get a read-only variant of the same notes.
|
||||
|
||||
:::note | The app kit does this wiring
|
||||
Starting workers, routing a host kind to them, the WebSocket entry and authentication are slice 10's components
|
||||
(`createWorkers`, `chord.http`). An app built with the app kit (`chord/next/app`, the last chapter) does not wire them:
|
||||
`startServer(...)` on the server and `startWorker(...)` in a worker's entry do.
|
||||
:::
|
||||
|
||||
# Client Hosts {#clients lede="Browser tabs and terminal UIs are hosts too: what they can do, and what they cannot."}
|
||||
|
||||
A browser tab and a terminal UI are hosts like the server, with one difference: they have **no address**. Nobody can
|
||||
send a request *to* a tab; a tab only connects to the server and asks for things. That is a deliberate rule: a tab
|
||||
runs on a device the server does not control, so nothing on the server ever waits for, or trusts, a tab's state.
|
||||
|
||||
```ts
|
||||
// a browser tab (slice 11's page host will do this wiring for you)
|
||||
const tab = createHost<P>({ onError }); // no address, no principal: a client
|
||||
tab.connect(webSocketLink(new WebSocket("/chord")), { principal: { kind: "server" } }, ctx);
|
||||
await tab.load([{ name: "@app/web", facet: webCore }, ...webFacets], ctx);
|
||||
|
||||
const notes = tab.hold(Notes, { project: "p1" }, ctx); // a reference at once, live soon
|
||||
(await notes.list.watch(ctx)).start(async (call) => {
|
||||
if (call.kind === "value") render(call.value.items); // every change, as it commits on the worker
|
||||
});
|
||||
await notes.add({ text: "from the tab" }, ctx); // a call: tab → server → worker
|
||||
```
|
||||
|
||||
**Who the tab is** is not the tab's to say. The server authenticates the WebSocket when it connects (a cookie, the
|
||||
owner on loopback, a guest's share link) and stamps that principal on everything the tab asks for. The worker's
|
||||
`Notes` factory receives it and may give a guest a read-only variant. A tab cannot claim to be the owner.
|
||||
|
||||
**What a tab serves** is only `local` types: state of this tab's UI, never shared. A draft that survives switching
|
||||
panels, a selection, a tab-local cache:
|
||||
|
||||
```ts
|
||||
// plugins/notes/web.tsx: the web facet serves a local type, and contributes a panel
|
||||
export default defineFacet((host, self) => {
|
||||
host.serve(NoteDraft, (_params, self) => ({ text: self.state(DraftDoc) })); // lives in this tab only
|
||||
self.contribute(Panels, { title: "Notes", Component: NotesPanel });
|
||||
});
|
||||
```
|
||||
|
||||
**When the connection drops** (a phone in a tunnel, a server restart), the tab keeps its references. Their values
|
||||
stay readable, their `state` says `loading`, calls made meanwhile wait; on reconnect every reference is held again and
|
||||
gets the current value in one frame. The UI shows a banner from `ref.state`, nothing more.
|
||||
|
||||
**A terminal UI** is the same in Node: a client host connected to the server over a WebSocket, loading the plugins'
|
||||
`tui` facets, rendering with whatever terminal library the app chose. **What a client never does**: serve a `remote`
|
||||
type, be routed to, or keep data others rely on. Data that must outlive a tab belongs in an object on the server or a
|
||||
worker.
|
||||
|
||||
# Plugins {#plugins lede="Move the code into a directory that can be added, edited and removed while the app runs."}
|
||||
|
||||
So far the facets were part of the app's own code. A **plugin** is the same code in its own directory:
|
||||
|
||||
```text
|
||||
plugins/notes/
|
||||
package.json { "name": "@app/notes" }
|
||||
contract.ts the Notes contract (other plugins import it)
|
||||
worker.ts the facet for workers: serves Notes
|
||||
server.ts the facet for the server: an export route
|
||||
web.tsx the facet for browser tabs: a notes panel
|
||||
```
|
||||
|
||||
The plugin's name is its `package.json` name. Each file named after a **host kind** is that kind's facet, its
|
||||
default export a `defineFacet(...)`. A plugin has only the files it needs: a plugin that only adds a route has a
|
||||
`server.ts` and nothing else.
|
||||
|
||||
```ts
|
||||
// plugins/notes/worker.ts
|
||||
import { defineFacet } from "@app/api/v1";
|
||||
import { Notes } from "./contract.ts";
|
||||
|
||||
export default defineFacet((host) => {
|
||||
host.serve(Notes, ({ project }, principal, self) => {
|
||||
const doc = self.state(NotesDoc);
|
||||
return { variant: "editor", members: { list: doc, add: async ({ text }, ctx) => /* … */ } };
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
`@app/api/v1` is your app's **API**, version 1: the part of its base code plugins import, as a host package (the
|
||||
plugin's build leaves it out; every host passes its own copy in). It holds what plugins share and never reloads:
|
||||
Chord's builders re-exported (`local`, `remote`, `obj`, `str`, ...), host kinds, the principal type, a `defineFacet`
|
||||
bound to it. A plugin never imports Chord itself; the version in the path says what it was written against.
|
||||
|
||||
# Loading Plugins {#loading lede="Two steps in two places: building a plugin into a script happens once, on the server; evaluating it happens in every process that runs it."}
|
||||
|
||||
A plugin is source code on the server's disk: TypeScript files that import each other, import other plugins'
|
||||
contracts, import npm packages, and import things the app itself provides. None of the processes that run the plugin
|
||||
can use those files as they are. A browser tab has no file system and no TypeScript; a worker could read the files,
|
||||
but then every worker would compile every plugin again, and two workers started a minute apart could see two different
|
||||
versions. So loading a plugin is two separate steps:
|
||||
|
||||
1. **Build**: turn each plugin facet (`notes/worker.ts`, `notes/web.tsx`) into one self-contained **script**. This
|
||||
happens once per edit, on the server, for every host kind.
|
||||
2. **Evaluate**: run a script inside one process, which gives back the facet, ready for `host.load`. This happens in
|
||||
every process that hosts that kind: the server for its own facets, each worker for the worker facets, each tab for
|
||||
the web facets.
|
||||
|
||||
The two steps need different things, and that is why the API has two calls.
|
||||
|
||||
## Host packages: what a script leaves out
|
||||
|
||||
Look at what `notes/worker.ts` imports:
|
||||
|
||||
```ts
|
||||
import { defineFacet, obj, remote, Session, str } from "@app/api/v1"; // the app's API: its own part and Chord's
|
||||
import { Notes } from "./contract.ts"; // the plugin's own file
|
||||
import slugify from "slugify"; // an npm package in its dependencies
|
||||
```
|
||||
|
||||
The last two go into the script: the plugin's own files and its npm dependencies are copied in. The first must not.
|
||||
If the script carried its own copy of the API, and with it of Chord, the worker would run two Chords: the plugin's
|
||||
`ChordError` would not be the worker's `ChordError`, and the plugin's `defineFacet` would make a facet the worker's host
|
||||
does not recognize. The plugin must see the very `Session` host kind and the very `Panels` registry the process
|
||||
already has, not a copy.
|
||||
|
||||
Packages the process already has, and that every plugin must share, are its **host packages**. A script leaves them
|
||||
out and asks for them by name when it runs. Building therefore needs only their **names**: "do not copy `@app/api/v1`
|
||||
into this script".
|
||||
|
||||
## Building: `createPlugins`, on the server
|
||||
|
||||
```ts
|
||||
const plugins = createPlugins({
|
||||
roots: [{ path: "./plugins", required: true }], // where the plugin directories are
|
||||
api: { name: "@app/api", versions: ["v1"] }, // the app's API and the versions it provides
|
||||
kinds: {
|
||||
server: { platform: "node", hostPackages: ["@app/api/v1", "@app/api/v1/server"] },
|
||||
worker: { platform: "node", hostPackages: ["@app/api/v1", "@app/api/v1/worker"] },
|
||||
web: { platform: "browser", hostPackages: ["@app/api/v1", "@app/api/v1/web", "react", "react/jsx-runtime"] },
|
||||
},
|
||||
onError,
|
||||
}, ctx);
|
||||
```
|
||||
|
||||
`createPlugins` is the builder, and it builds for every kind, because only the server has the sources. For each kind
|
||||
it needs two facts. The **platform** says where the scripts run: a `node` script may import `node:fs`, a `browser`
|
||||
script may import a stylesheet but not `node:fs`. The **host package names** say what to leave out; they differ per
|
||||
kind, because a tab also shares React with its plugins and a worker does not, and the API's per-kind modules
|
||||
(`/v1/web`) belong to their kind only. With `api` set, the build checks every import of the API: a version the app
|
||||
does not list, another kind's module, two versions in one facet, or Chord imported directly is a build error naming
|
||||
the file and line. Nothing here runs a plugin.
|
||||
|
||||
## Evaluating: `plugins.load`, in the process that hosts
|
||||
|
||||
Evaluating is where the names become things. When the script runs and asks for `@app/api/v1`, someone has to hand
|
||||
it the actual module, the one this process imported when it started. Only the process itself has it:
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
import * as api from "./api/v1/index.ts"; // the app's API, the module plugins import as "@app/api/v1"
|
||||
import * as apiServer from "./api/v1/server.ts";
|
||||
import { serverFacet } from "./core.ts";
|
||||
|
||||
const report = await plugins.load(server, {
|
||||
kind: "server", // evaluate the server kind's scripts
|
||||
modules: { "@app/api/v1": api, "@app/api/v1/server": apiServer }, // this process's own instances
|
||||
core: [{ name: "@app/core", facet: serverFacet }], // base-code facets, loaded beside the plugins
|
||||
}, ctx);
|
||||
```
|
||||
|
||||
`modules` is this process's answer to every host package name. The server passes its own; a worker passes its own in
|
||||
`workerMain`; a page passes its own in `startPage`. Each process has different instances, which is exactly the point:
|
||||
inside each process, plugins share that process's Chord and base code.
|
||||
|
||||
`core` is the app's base code in facet form. It is a facet like a plugin's, but it is not a plugin: it is made in the
|
||||
process's own code, often from values only this process has (`core.workerFacet(start)` closes over a worker's start
|
||||
data and store), and it never reloads, because it defines what all plugins share. Passing it here loads it together
|
||||
with the plugins, so they are published in one step.
|
||||
|
||||
## Why `plugins.load(host)`, not `host.load`
|
||||
|
||||
A host has its own `load`, and `plugins.load` calls it. The host's `load` takes facets that already exist: functions
|
||||
in memory. It knows nothing about directories, builds, scripts or modules, on purpose: the same host runs in a test
|
||||
with facets imported directly, or in a Cloudflare Durable Object where plugins are bundled at deploy time and nothing
|
||||
is built or evaluated at runtime.
|
||||
|
||||
The plugins component is the part that knows about directories. It finds the plugins, builds them, evaluates them
|
||||
with the process's modules, and then calls `host.load` with the result. It also remembers, per host, which facet each
|
||||
plugin runs, so that a later reload can hand the host its complete new plugin set, with unchanged plugins as the very
|
||||
same objects. That memory is why the host is passed to it: `plugins.load(server, …)` on the server,
|
||||
`plugins.load(worker, …)` in a worker (where `workerMain` gives it a `plugins` that only evaluates), the same call
|
||||
in every process.
|
||||
|
||||
:::flow wide | Building on the server needs names; evaluating in each process needs that process's modules.
|
||||
row "Server: build (once per edit)" accent
|
||||
box src "plugin sources" "`notes/worker.ts`, imports `@app/api/v1`" muted
|
||||
box build "createPlugins" "copies own files and npm deps; leaves host packages out" accent
|
||||
box script "worker script" "`require(\"@app/api/v1\")` left as a name"
|
||||
row "Each worker: evaluate" blue
|
||||
box mods "the worker's modules" "its own `@app/api/v1`, its own Chord" muted
|
||||
box load "plugins.load(worker, …)" "runs the script, answers each name" blue
|
||||
box facet "the facet" "uses the worker's `Session`, the worker's `defineFacet`"
|
||||
arrow src -> build
|
||||
arrow build -> script
|
||||
arrow script -> load "shipped"
|
||||
arrow mods -> load
|
||||
arrow load -> facet
|
||||
:::
|
||||
|
||||
## What happens, step by step
|
||||
|
||||
:::steps | `plugins.load(server, { kind: "server", … })`
|
||||
+++ Discover
|
||||
The component lists `./plugins` and reads each `package.json`. Its `name` is the plugin's name; the files named after
|
||||
a kind are its facets. A directory without a `package.json`, or with two `server` files, is reported, not loaded. Files
|
||||
at the root's top named `<name>.<kind>.ts` (`hello.server.ts`) are single-file plugins, grouped by name.
|
||||
+++ Build
|
||||
For each plugin with a `server` facet it builds one script, if no current one exists. A syntax error or an import the
|
||||
rules do not allow (a path into base code, a package not in the plugin's dependencies) is a failed build with the file
|
||||
and line, never a crash.
|
||||
+++ Evaluate
|
||||
The script runs. Each host package name it asks for is answered from `modules`; anything else it may not have. The
|
||||
script's default export must be a facet.
|
||||
+++ Load
|
||||
`host.load` runs the setups of `core` and of every plugin facet and publishes them together. A plugin whose build,
|
||||
evaluation or setup failed is left out and listed in `report.failed` with the reason; the others load. Objects that
|
||||
need it wait until a later edit loads it.
|
||||
:::
|
||||
|
||||
Only the server builds. Workers and clients (browser tabs, terminal UIs) only evaluate: the server builds their kinds
|
||||
too, a worker gets its scripts in its start message, a tab or a TUI gets them from the server over HTTP, and each
|
||||
evaluates them with its own modules (a worker's from `workerMain`, a page's from its base bundle).
|
||||
|
||||
# Plugins Using Plugins {#together lede="Two ways one plugin uses another, and the one it may not."}
|
||||
|
||||
A search plugin wants to search the notes. It **imports the contract** and asks for the object:
|
||||
|
||||
```ts
|
||||
// plugins/search/worker.ts
|
||||
import { Notes } from "../notes/contract.ts"; // a plugin of the same root, by relative path: copied into search's script
|
||||
|
||||
host.serve(Search, ({ project }, principal, self) => {
|
||||
const notes = self.get(Notes, { project }); // a dependency: Search goes live once Notes is
|
||||
const index = self.derive([notes.list], ([list]) => buildIndex(list.items));
|
||||
return { variant: "main", members: { find: async ({ q }) => lookup(index.value, q) } };
|
||||
});
|
||||
```
|
||||
|
||||
Which file a plugin imports decides which version of the contract it claims, so the rule is: **a plugin carries the
|
||||
contract it was written against.** Two cases:
|
||||
|
||||
- **Plugins that change together** (the app's own, in its repository; a user's, in one root) import each other by
|
||||
relative path, `../notes/contract.ts`: the same commit holds both sides. The file is copied into the importer's
|
||||
script, so only contracts and other side-effect-free files belong there (a copied file may import only the API and
|
||||
files of its own plugin). A relative path leaving the root (into base code, into another root) is a build error.
|
||||
- **Plugins from another repository** take contracts as a dependency at a fixed version, by package name:
|
||||
`@bob/notes` (its `contract.ts`, through the package's `exports`), `@bob/notes/web` (its `contract.web.tsx`), pinned
|
||||
in `package.json` by an npm version or a git ref (`"@bob/notes": "github:bob/notes#<commit>"`). The build inlines
|
||||
the installed version: when Bob's notes move to contract version 2, a consumer built against version 1 keeps
|
||||
claiming version 1, and the provider serves it through `accepts` or the consumer waits "contract mismatch", visible,
|
||||
never silently wrong. A plugin inside a repository holding several (which lists them in its own `package.json`,
|
||||
`"chord": { "plugins": ["./notes", "./search"] }`) is a dependency on the whole repository, imported by path into it:
|
||||
`@bob/plugins/notes/contract.ts`.
|
||||
|
||||
A contract file may be for one kind: `contract.web.tsx` (React components in a type, a panel's registry token) is a
|
||||
**web file**. Importing it, directly or through other files, from a facet of another kind is a build error naming
|
||||
the chain (`server.ts → view.ts → ../notes/contract.web.tsx`). The shared `contract.ts` mentions native things only
|
||||
through `import type`.
|
||||
|
||||
`self.get` in a factory declares a dependency: Chord makes `Search` live only once `Notes` is, and if the notes
|
||||
plugin is missing, `Search` waits with a clear reason ("waiting for notes: not served here") instead of failing.
|
||||
|
||||
The other way is a **registry**: an extension point one plugin defines and others contribute to. The app's API
|
||||
defines `Panels` in its web module, and every plugin with a UI contributes one:
|
||||
|
||||
```tsx
|
||||
// plugins/notes/web.tsx
|
||||
import { Panels } from "@app/api/v1/web";
|
||||
export default defineFacet((host, self) => self.contribute(Panels, { title: "Notes", Component: NotesPanel }));
|
||||
```
|
||||
|
||||
What a plugin may not do is reach into another's data. Search never reads notes' documents; it uses the `Notes`
|
||||
object's state. That rule is what lets the notes plugin change its storage without breaking search.
|
||||
|
||||
# Changing a Plugin {#reload lede="Edit, build, roll out: how new code takes over while every tab stays connected."}
|
||||
|
||||
You change the notes plugin: notes now have a timestamp. The watcher sees the saved file and marks the plugin dirty.
|
||||
You (or an agent) build and roll out:
|
||||
|
||||
```ts
|
||||
const built = await plugins.build(plugins.state.value.dirty, ctx);
|
||||
const result = await plugins.rollout([plugins.step("server", server), workers.step("workers")], built, ctx);
|
||||
```
|
||||
|
||||
The rollout goes host by host, server first, then every running worker. On each host Chord compares the new plugin
|
||||
set with the loaded one, runs the new factories against a recording `self` to see what each object would declare, and
|
||||
then gives every object of the changed plugin its fate:
|
||||
|
||||
| The object | What happens |
|
||||
| --- | --- |
|
||||
| holds no natives (open files, sockets), the new code neither | **swapped** in place: new methods, same documents; running calls finish on the old code |
|
||||
| holds natives | **restarted**: calls wait, running ones finish, natives close and reopen with the new code |
|
||||
| its type was removed | **ended**; its users wait until something serves it again |
|
||||
|
||||
The tab's reference follows the new object and gets one frame with the difference. Nothing reconnects. If the edit
|
||||
is broken (a syntax error, a factory that throws), that plugin keeps its old code on that host and the report says why;
|
||||
every other plugin of the rollout applies.
|
||||
|
||||
:::note | Versions
|
||||
A change to a contract (a new field in `Notes`'s state) gets a new `version`. A tab built with the old contract keeps
|
||||
working if the provider maps the old version (`accepts`); otherwise it waits with "version mismatch" until it reloads.
|
||||
:::
|
||||
|
||||
# Base Code and Plugins {#base lede="What goes into the app itself, what goes into plugins, and how an app ships plugins of its own."}
|
||||
|
||||
Every app has two kinds of code, and the line between them decides what can change while the app runs.
|
||||
|
||||
**Base code** is the app's own program: `src/`, started by `node server.ts`. It shrinks to three things: the **API**
|
||||
(the versioned modules plugins import), the **bootstrap** (creating hosts, opening the store, components such as
|
||||
`createHttp` and `createWorkers`) and one small **start-data facet** serving process values (start data, the opened
|
||||
store) as core objects. Base code changes only with a restart: if it reloaded, the process would hold two `Panels`
|
||||
registries, and old and new plugins would no longer meet.
|
||||
|
||||
**Plugins** are everything else: notes, search, export, a terminal, the harness, the providers of the app's core
|
||||
registries. They reload on an edit, a broken one is left out alone, and an agent can write one while the app runs.
|
||||
|
||||
## The API, by version and by kind
|
||||
|
||||
Plugins import their host only through the API, a version in the path:
|
||||
|
||||
```ts
|
||||
// src/api/v1/index.ts: "@app/api/v1", for every kind
|
||||
export { defineRegistry, local, obj, remote, str } from "@earendil-works/chord/next"; // the Chord surface the app supports
|
||||
export { defineFacet, Project, Server } from "../../core.ts"; // bound to the app's principal
|
||||
// src/api/v1/web.ts: "@app/api/v1/web", for pages only
|
||||
export const Panels = defineRegistry<Panel, readonly Panel[]>("app.panels", { version: 1 });
|
||||
```
|
||||
|
||||
The app lists the versions it provides, newest first (`api: { name: "@app/api", versions: ["v2", "v1"] }`), and each
|
||||
kind's modules in `kinds.<kind>.hostPackages`. When the app changes its API in a way that breaks plugins, it adds
|
||||
`/v2` and keeps `/v1` as a thin layer over it for a while; once `/v1` goes, a plugin importing it fails to build with
|
||||
"@app/api/v1 is not available: this app provides v2", the plugin alone, the message saying what to change. One facet
|
||||
uses one version. Contracts the app promises others are re-exported in the API (`export { Notes } from
|
||||
"../../plugins/notes/contract.ts"`), so a plugin built against `/v1` keeps v1's contract files.
|
||||
|
||||
## Plugins the app ships
|
||||
|
||||
An app comes with plugins of its own. They follow the same rules as anyone's, in a root inside the app, next to the
|
||||
root where users and agents add theirs:
|
||||
|
||||
```ts
|
||||
const plugins = createPlugins({
|
||||
roots: [{ path: "./plugins", required: true }, join(dataDir, "plugins")], // the app's own, then the user's
|
||||
api: { name: "@app/api", versions: ["v1"] },
|
||||
kinds,
|
||||
onError,
|
||||
}, ctx);
|
||||
```
|
||||
|
||||
They are built at startup like any plugin (a quarter of a second for twenty), reload like any plugin, and are left out
|
||||
alone when broken at a reload. The app's root is **required**: a plugin of it failing at startup makes
|
||||
`plugins.load` reject with a `PluginError` naming it (after the load settled), so the app stops instead of running
|
||||
without its core. A failure in the user's root is reported and the app runs on. If two roots have a plugin with the
|
||||
same name, the first root wins, and the other is reported.
|
||||
|
||||
## Small plugins, installed plugins, tested plugins
|
||||
|
||||
- **A single file** at a root's top is a plugin: `plugins/hello.server.ts` and `plugins/hello.web.tsx` are the plugin
|
||||
`hello` with two facets. It has no `package.json` and imports only the API; when it needs a second file or a
|
||||
package, it becomes the directory `hello/`, which a reload treats as the same plugin.
|
||||
- **Installing** from git or npm: `plugins.installPlugin({ git: url, ref }, root, ctx)` clones the ref (a branch, tag
|
||||
or commit) into the root, `{ npm: name, version }` extracts the package; then its dependencies are installed. Removal
|
||||
is deleting the directory.
|
||||
- **Testing** a plugin alone: `testPlugin("./plugins/stats", { kind: "worker", api, modules, stubs }, ctx)` builds it
|
||||
as production does, loads it on an in-memory host beside stub facets serving what it uses, collects what it
|
||||
contributes to registries nobody serves, and lists the contracts it waits for (`docs/next/plugin-authoring.md`).
|
||||
|
||||
# The App Kit {#kit lede="startServer and startWorker: the hosts, stores, sign-in, files and plugin roots every app needs, so an app is its API, its plugins and a start script."}
|
||||
|
||||
The chapters so far wired an app by hand: a store, a host, `createHttp`, `createWorkers`, `createPlugins`, an
|
||||
authenticator, a worker entry connecting to its parent. Every app needs the same wiring, and most of it has one right
|
||||
answer. The **app kit** (`chord/next/app`) is that answer, opinionated on purpose: one data directory layout, one way
|
||||
to sign in, one place for files.
|
||||
|
||||
## A whole app
|
||||
|
||||
The notes app of example 53 is four files of base code and two plugins:
|
||||
|
||||
```ts
|
||||
// start.ts
|
||||
const app = await startServer<NotesPrincipal>({
|
||||
name: "notes", // the data directory: ~/.notes unless dataDir says otherwise
|
||||
root: "./plugins", // the app's own plugins: a required root
|
||||
api: { name: "@notes/app", versions: ["v1"], modules: { "@notes/app/v1": v1, "@notes/app/v1/server": v1server } },
|
||||
kinds: { server: { platform: "node" }, worker: { platform: "node" } },
|
||||
principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", project: address.key }) },
|
||||
workers: [{
|
||||
kind: Project, // one worker per project
|
||||
entry: "./worker.ts",
|
||||
start: (address) => ({ project: address.key }),
|
||||
authorize: (principal) => principal.kind === "owner" || principal.kind === "server",
|
||||
}],
|
||||
onError: (error) => log(error),
|
||||
}, ctx);
|
||||
console.log(`Open ${app.ownerLink()}`);
|
||||
|
||||
// worker.ts
|
||||
startWorker({ api: API, kinds: KINDS, onError: (error) => log(error) });
|
||||
```
|
||||
|
||||
`startServer` creates the data directory (`server/store.sqlite`, `workers/<kind>/<address>/`, `plugins/`, `logs/`,
|
||||
`owner-token`), opens the server's store, creates the server host, HTTP, a workers component per worker kind and the
|
||||
plugins component over two roots: the app's `plugins/` and the user's `<dataDir>/plugins/`. Then it loads the server's
|
||||
plugins with the kit's own facets beside them and resolves. `startWorker` does the worker's half: the project's
|
||||
directory and store, the host at the project's address, the link to the server first, the worker's plugins.
|
||||
|
||||
## What plugins get from the kit
|
||||
|
||||
Plugins still import only the app's API; the API re-exports the kit's objects it offers:
|
||||
|
||||
- `AppInfo` on the server (data directory, app name, API version) and `WorkerInfo` on workers (the address and the
|
||||
start data `start(address)` made): the **start-data objects**. Process values reach plugins only through them.
|
||||
- `Files`, one per host: `put` for small files, `begin`, `append` and `finish` for large ones, ranged `read`, `remove`
|
||||
and a watchable `list`, stored in the host's directory with caps per host.
|
||||
- `Sessions` on the server: `issue`, `revoke`, `list`.
|
||||
|
||||
This finishes what the previous chapter started. Base code is the API, the start script and the worker's entry. The
|
||||
providers of the app's own registries live in its required root: example 53's API declares a `Sections` registry, and
|
||||
its home plugin serves it and reads `AppInfo`, so it reloads like any other plugin.
|
||||
|
||||
## Who may come in
|
||||
|
||||
HTTP **denies by default**: a request without a principal gets 401 before any plugin code runs, unless the route says
|
||||
`public: true`. Someone gets a principal through a **session**: a random token, kept in the store only as a hash, sent
|
||||
as a signed cookie by a browser or as `Authorization: Bearer` by a command-line client.
|
||||
|
||||
The first session is the owner's. At each start the kit makes a one-time **owner link**,
|
||||
`http://127.0.0.1:8788/_chord/owner#<token>`, valid ten minutes; the start script prints it. The token is in the
|
||||
fragment, which browsers never send, so it never reaches a log: the page posts it, the kit consumes it and sets the
|
||||
owner's cookie. A command-line client on the same machine reads `<dataDir>/owner-token` (mode 0600). One on another
|
||||
machine **pairs**: it asks for a code, the owner approves the code in a signed-in browser, and the client's next poll
|
||||
returns its token (example 54).
|
||||
|
||||
Users other than the owner come from optional plugins the app adds: `defineUsers(profile)` for accounts,
|
||||
`inviteFacet()` for invite links, `ephemeralLoginFacet()` for "pick a name and you're in".
|
||||
|
||||
## Applying changes, moving projects
|
||||
|
||||
The kit never reloads by itself. An edit or an install marks plugins dirty; `app.plugins.state` drives the app's
|
||||
"apply changes" button, and `app.applyChanges(ctx)` builds and rolls out to the server, then the workers.
|
||||
|
||||
A project's data is what its worker created: its store and its files. `app.exportData(Project({ id }), path, ctx)`
|
||||
writes them as one archive (a manifest, the store's export, the files) and stops nothing: a running worker writes its
|
||||
own store at one consistent point. `app.importData(path, Project({ id: "copy" }), ctx)` makes a new project from it and
|
||||
refuses one that already has data (example 55).
|
||||
|
||||
# Reference {.appendix}
|
||||
|
||||
| Call | What it does |
|
||||
| --- | --- |
|
||||
| `createHost<P>({ address?, principal?, store?, onError })` | a host: addressable with an address, else a client |
|
||||
| `host.load([{ name, facet }], ctx)` / `host.reload(...)` | publish facets / apply a new plugin set |
|
||||
| `host.get(Type, params, ctx)` / `host.hold(...)` | a live reference / a reference at once that follows its object |
|
||||
| `local(name, { version, params, members })` / `remote(name, { version, params, hostAddress, variants })` | contracts |
|
||||
| `defineFacet((host, self) => …)`, `host.serve(Type, factory)`, `self.contribute(R, value)` | a facet |
|
||||
| factory `self`: `state`, `derive`, `get`, `registry`, `open`, `on`, `onLive`, `job`, `tx` | an object's parts |
|
||||
| `createPlugins({ roots, api, kinds, onError }, ctx)` | the plugins component |
|
||||
| `plugins.load(host, { kind, core, modules }, ctx)`, `build(names)`, `rollout(steps, built)` | load, build, roll out |
|
||||
| `plugins.installPlugin(source, root, ctx)`, `testPlugin(dir, options, ctx)` | install a plugin, test one alone |
|
||||
| `hostKind(name, params?)`, `Kind(params)` | host kinds and addresses |
|
||||
| `startServer<P>({ name, dataDir?, root?, listen?, api, kinds, principal, workers?, files?, onError }, ctx)` | an app's server, with the kit |
|
||||
| `startWorker({ api, kinds, busy?, onError })` | a worker's entry, with the kit |
|
||||
| `app.ownerLink()`, `app.applyChanges(ctx)`, `app.plugins.install(source, ctx)` | sign in, apply changes, install |
|
||||
| `app.exportData(address, path, ctx)`, `app.importData(path, address, ctx)` | move a worker's data |
|
||||
| `AppInfo`, `WorkerInfo`, `Files`, `Sessions` | the kit's objects plugins get |
|
||||
781
packages/chord/docs/next/guide-plugins-hosts/index.html
Normal file
781
packages/chord/docs/next/guide-plugins-hosts/index.html
Normal file
File diff suppressed because one or more lines are too long
378
packages/chord/docs/next/http-workers/book.md
Normal file
378
packages/chord/docs/next/http-workers/book.md
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
---
|
||||
title: Serving and Spawning: HTTP and workers in Chord v2
|
||||
cover: Serving / and Spawning
|
||||
kicker: An explainer · Chord v2 slice 10 · 2026-10-31
|
||||
subtitle: How a request reaches a plugin's route, who it acts as, and how a worker process per session starts, stops and fails
|
||||
stats:
|
||||
- 1 | registry plugins contribute routes, wraps, sockets and hosts to
|
||||
- 0 | automatic worker restarts
|
||||
- 5 min | idle before a worker stops (default)
|
||||
- 6 | security rules on by default
|
||||
note: |
|
||||
Written by the redesign session from `slice-10.md`, the decision entries "Slice 10's points", "Origins only from
|
||||
relays", "The HTTP component's security baseline" and their neighbours in `decisions.md`, design §3 and §23, and the
|
||||
build after its four fix rounds (security baseline, `fetch` core, error boundaries).
|
||||
colophon: Serving and Spawning · first edition · 2026-10-31 · Chord v2 slice 10
|
||||
---
|
||||
|
||||
# Before we begin {.preface}
|
||||
|
||||
Mario runs a small chat app on his laptop. Each conversation is a **session**, and each session runs in its own
|
||||
process, a **worker**, so a session that crashes or eats memory takes nothing else down. A browser tab talks to the
|
||||
server; the server talks to the workers. Plugins add features: one adds a chat panel, one adds file downloads, one
|
||||
adds a terminal. Mario edits plugins while the app runs, and sometimes an agent edits them for him.
|
||||
|
||||
Three things have to work for him without thought. A plugin adds an HTTP route, and it is served safely: no other
|
||||
website open in his browser can read his data or act as him through it. A session's worker exists exactly while
|
||||
someone uses it. And when a worker crashes, nothing restarts it in a loop: he sees why it failed and decides.
|
||||
|
||||
Slice 10 builds two components for this: `chord/next/http` (the server's HTTP and WebSocket entry) and
|
||||
`chord/next/workers` (a worker process per session). Both are Node code on top of Chord's kernel; the kernel itself
|
||||
stays portable.
|
||||
|
||||
## Eight things to know
|
||||
|
||||
:::findings
|
||||
1. **Plugins contribute routes, Chord serves them.** The HTTP component defines one registry, `Http`; plugins
|
||||
contribute routes, wraps (middleware), WebSocket handlers and allowed hosts to it, and every reload swaps them.
|
||||
2. **A route returns a description.** JSON, HTML, bytes, a redirect or an error; the component writes it, with ranges,
|
||||
`HEAD`, download headers and backpressure done once for every plugin.
|
||||
3. **Security is built in.** Where a request came from, whether another website sent it, and which headers every
|
||||
response carries are decided by the component before any plugin code runs. The app only says who someone is.
|
||||
4. **A worker starts on the first use of its session** and stops after five idle minutes. The server forks it; the
|
||||
worker connects back and loads its plugins.
|
||||
5. **A worker never restarts by itself.** A crash or a failed start leaves the session failed with its error until a
|
||||
person presses Retry or rolls out new code.
|
||||
6. **Workers never build plugins.** The server builds every plugin once; a worker evaluates the scripts it is sent.
|
||||
7. **A rollout reaches every host.** The server reloads first, then every running worker, each reporting its result.
|
||||
8. **Everything but the bootstrap is changeable by plugins.** Routes, hosts, start data, idle times: contributions or
|
||||
functions read at each use.
|
||||
:::
|
||||
|
||||
## Words used throughout
|
||||
|
||||
A **route** is a function a plugin contributes for a method and path pattern. A **wrap** runs around routes. A
|
||||
**socket** is a WebSocket a plugin handles with its own protocol; a **link** is Chord's own WebSocket at `/chord`,
|
||||
over which tabs hold objects. **Admission** is what the component checks before any plugin code. **Provenance** is
|
||||
where a request came from (local, an allowed host, a relay). A request is **foreign** when a browser sent it for
|
||||
another website. A **worker** is a process serving one **address** (`Session({ id })`). A worker's **start message**
|
||||
carries its start data and its plugins' **scripts**. A **rollout** applies a build to the server and then the workers.
|
||||
|
||||
# The App We Build {#app lede="A server, workers, two plugins: the files a reader needs to follow the rest."}
|
||||
|
||||
The whole app is four files of its own plus its plugins. The server creates its host, the HTTP component and the
|
||||
workers component, then loads its plugins:
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError });
|
||||
const plugins = createPlugins({
|
||||
roots: ["./plugins"],
|
||||
kinds: {
|
||||
server: { platform: "node", hostPackages: ["@earendil-works/chord/next", "@app/core"] },
|
||||
worker: { platform: "node", hostPackages: ["@earendil-works/chord/next", "@app/core"] },
|
||||
},
|
||||
onError,
|
||||
}, ctx);
|
||||
const http = createHttp(server, { listen: { host: "127.0.0.1", port: 8788 }, onError }, ctx); // settings are arguments
|
||||
const workers = createWorkers(server, Session, {
|
||||
kind: "worker", entry: "./worker.ts", plugins, principal: (a) => ({ kind: "worker", session: a.params.id }),
|
||||
start: (address, ctx) => ({ id: address.params.id, dir: sessionDir(address) }),
|
||||
lock: (address) => `chat:${dataDir}:${address.key}`, authorize: mayReach, onError,
|
||||
}, ctx);
|
||||
await plugins.load(server, { kind: "server", core: [core, { name: "chord.http", facet: http.facet }], modules }, ctx);
|
||||
// `core` contributes the app's authenticator: self.contribute(Authenticators, authenticate)
|
||||
```
|
||||
|
||||
```ts
|
||||
// worker.ts: one per session
|
||||
await workerMain(async ({ address, start, parent, plugins, onClose }, ctx) => {
|
||||
const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(join(start.dir, "session.sqlite"))), ctx);
|
||||
onClose((ctx) => store.close(ctx));
|
||||
const worker = createHost<P>({ address, principal: { kind: "worker", session: start.id }, store, onError });
|
||||
onClose((ctx) => worker.close(ctx));
|
||||
worker.connect(parent, { principal: { kind: "server" }, trusted: true }, ctx);
|
||||
await plugins.load(worker, { kind: "worker", core: [workerCore(start)] }, ctx);
|
||||
return { busy: { tasks: 0 } };
|
||||
}, { hostPackages: modules, redact: (error) => ({ message: "worker failed" }), onError });
|
||||
```
|
||||
|
||||
A plugin is a directory with a `package.json` and one file per kind it needs. The chat plugin keeps its messages in a
|
||||
worker (each session has its own) and contributes one route on the server for exporting them:
|
||||
|
||||
```ts
|
||||
// plugins/chat/server.ts
|
||||
export default defineFacet((host, self) => {
|
||||
self.contribute(Http, {
|
||||
routes: {
|
||||
"GET /s/:session/export": async (request, ctx) => {
|
||||
if (!(await mayView(ctx.principal, request.params.session, ctx))) return { kind: "error", status: 403 };
|
||||
const chat = await ctx.get(Chat, { session: request.params.session }); // reaches the session's worker
|
||||
return { kind: "json", value: await chat.export(ctx) };
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
`ctx.get(Chat, { session })` is the one line that connects the two halves: `Chat`'s host address is the session's
|
||||
worker, the server's route for `Session` finds (or starts) that worker, and the call goes over its link.
|
||||
|
||||
# A Request, Start to End {#request lede="What happens between a TCP connection and the route's response, in order."}
|
||||
|
||||
Every request takes the same path. The order matters: nothing a plugin wrote runs before the component decided that
|
||||
the request may run at all, and as whom.
|
||||
|
||||
:::flow wide animate | One request through the HTTP component. Everything left of `authenticate` is the component's own; plugins start at the router.
|
||||
row "Admission" accent
|
||||
box prov "Provenance" "local, allowed host or relay; else 403"
|
||||
box foreign "Foreign?" "another website's browser request"
|
||||
box origin "Origin" "foreign Origin 403; unsafe methods need ours"
|
||||
row "Who" blue
|
||||
box auth "authenticate" "the app: cookies, devices"
|
||||
box anon "anonymous" "foreign or no credential" muted
|
||||
row "Plugins"
|
||||
box router "Router" "pattern → route, as of now"
|
||||
box wraps "Wraps" "observe, headers, transform"
|
||||
box route "Route" "returns a description"
|
||||
row "Answer" muted
|
||||
box write "Write" "headers, ranges, chunks, backpressure"
|
||||
arrow prov -> foreign
|
||||
arrow foreign -> origin
|
||||
arrow origin -> auth
|
||||
arrow origin -> anon "foreign"
|
||||
arrow auth -> router
|
||||
arrow router -> wraps
|
||||
arrow wraps -> route
|
||||
arrow route -> write
|
||||
:::
|
||||
|
||||
**Provenance** answers "where from". A request is local only if three things hold: the socket arrived on a loopback
|
||||
address, the `Host` header names loopback with the port, and no proxy header (`Forwarded`, `X-Forwarded-*`) is present.
|
||||
Anything else must name a host the app allowed (a tunnel's public name) and is then treated as remote https. Anything
|
||||
else is refused before a single plugin line runs. The security audit found that checking the `Host` header alone let
|
||||
anyone reaching the port claim to be local: the socket address is the part a remote client cannot fake.
|
||||
|
||||
**Foreign** answers "did another website make the browser send this". Browsers say so in `Sec-Fetch-Site`. A foreign
|
||||
request never reaches `authenticate`: it runs anonymous, whatever cookies it carries. That one rule stops a page on
|
||||
another site from reading Mario's data through an `<img>` or `<script src>`, from driving his session through a hidden
|
||||
form or `fetch`, and from opening his WebSocket. The single exception is a top-level navigation (Mario clicks a link
|
||||
to the app from his mail) to a route marked `page: true`.
|
||||
|
||||
**Origin** closes what is left: a request whose `Origin` header names another site is refused, and a state-changing
|
||||
request from a browser must name the app's own origin. A webhook that other sites must reach opts out
|
||||
(`crossSite: true`) and then always runs anonymous, checking its own signature.
|
||||
|
||||
Then the app's `authenticate` says who the request is. The **router** picks the most specific route for the method
|
||||
and path, from the router as it was when the request started. **Wraps** run around it, and the route returns a
|
||||
description that the component writes.
|
||||
|
||||
# What a Route Returns {#responses lede="Five kinds of response, and why routes never touch Node's response object."}
|
||||
|
||||
In p2 every plugin wrote Node's `ServerResponse` itself, so each one implemented ranges, download headers and `HEAD`
|
||||
by hand, or forgot to. In Chord a route returns data:
|
||||
|
||||
```ts
|
||||
type HttpResponse = { readonly headers?: Readonly<Record<string, string>> } & (
|
||||
| { kind: "json"; value: JsonValue; status?: number }
|
||||
| { kind: "html"; body: string; status?: number }
|
||||
| { kind: "bytes"; mime: string; size?: number; read(offset: number, length: number, ctx: Ctx): Promise<Uint8Array>;
|
||||
download?: string; ranges?: boolean }
|
||||
| { kind: "redirect"; to: string; status?: 302 | 303 | 307 }
|
||||
| { kind: "error"; status: number; message?: string });
|
||||
```
|
||||
|
||||
The component turns a `bytes` response with a `size` into `Content-Length`, a range request into 206 (or 416 when it
|
||||
cannot be satisfied), `HEAD` into headers only, and `download` into a safe `Content-Disposition`. It reads in 64 KiB
|
||||
pieces and waits whenever the client is slower, so a large file never piles up in memory, and it stops reading the
|
||||
moment the client disconnects. Without a `size` it sends the body chunked until `read` returns an empty piece: a zip
|
||||
built on the fly, or an export paged from a worker one call per piece.
|
||||
|
||||
`fileResponse(path, options)` is a `bytes` response over a file: it opens the file only when the first byte is
|
||||
needed and closes it when the request ends, so a `HEAD` or a disconnect leaves no open handle behind. Request bodies
|
||||
work the same way in reverse: `await request.json()` reads at most 1 MiB and parses it; `request.body(limit)` yields
|
||||
chunks for an upload streamed to disk. A body past its limit gets 413; nothing is read until a route asks.
|
||||
|
||||
A route that throws is a 500 without its message: error text often holds paths or tokens, so it goes to the server's
|
||||
`onError`, never to the client.
|
||||
|
||||
# Who Is Asking {#who lede="The app's one security function, and everything the component does so the app does not have to."}
|
||||
|
||||
The app writes one function about identity and contributes it to the registry `Authenticators` (plugins may add
|
||||
more, a device pairing for instance; the first principal wins):
|
||||
|
||||
```ts
|
||||
const authenticate: Authenticator = async (request, ctx) => {
|
||||
const device = readCookie(request, "device", secret); // signature and expiry checked
|
||||
if (device) return { kind: "owner", access: "device", device };
|
||||
const guest = readCookie(request, "guest", secret);
|
||||
if (guest) return { kind: "guest", grant: guest };
|
||||
if (request.provenance.kind === "local") return { kind: "owner", access: "local" };
|
||||
return undefined; // anonymous
|
||||
};
|
||||
self.contribute(Authenticators, authenticate); // in the app's core facet
|
||||
```
|
||||
|
||||
It never parses `Host`, never checks `Origin` and never sees a foreign request: the component already did all of it.
|
||||
It may read `request.provenance` (`local`, `remote` with the host, or `relayed` through a verified relay such as
|
||||
Radius). Cookies are written with `cookie(name, value, { secret, request, maxAge })`, which sets `HttpOnly`,
|
||||
`SameSite`, `Secure` on a remote origin, and signs the value with its expiry.
|
||||
|
||||
Every response, the component's own 404s and 403s included, carries the same headers: no caching, no MIME sniffing, no
|
||||
referrer, no framing (`X-Frame-Options: DENY` and `frame-ancestors 'none'`, except for framers the app allowed), and a
|
||||
Content Security Policy with a fresh script nonce per response (`request.nonce`), so an injected `<script>` does not run.
|
||||
|
||||
| Attack | What stops it |
|
||||
| --- | --- |
|
||||
| a website points its domain at 127.0.0.1 (DNS rebinding) | provenance: `Host` must be loopback or allowed |
|
||||
| a request from the LAN or a proxy claims to be local | provenance: the socket's own address, no proxy headers |
|
||||
| another site reads private data through `<img>`, `<script src>`, `fetch` | foreign requests run anonymous |
|
||||
| another site posts a form or opens the WebSocket as Mario | foreign `Origin` refused; anonymous anyway |
|
||||
| another site frames the app to trick clicks | `frame-ancestors 'none'`, `X-Frame-Options: DENY` |
|
||||
| a path like `/files/..%2F..%2Fsecret` | decoded `/`, `.`, `..`, `\` in a path segment are 400 |
|
||||
| a crafted request target crashes the server | the target is parsed by the component, a failure is 400 |
|
||||
|
||||
The core is a Web `fetch` handler, so an app with its own server (Hono, Express, plain Node with `nodeRequest`) mounts
|
||||
it and gets the same checks; without the socket's addresses a mounted request is never local.
|
||||
|
||||
# Sockets and Links {#sockets lede="Chord's own WebSocket for tabs, a plugin's own WebSocket for its protocol, and what closes them."}
|
||||
|
||||
A tab holds objects over `/chord`, a **link**: the component authenticates the upgrade and calls `host.accept` with
|
||||
the principal. A link has Chord's heartbeat: silent for 30 seconds, it closes.
|
||||
|
||||
A plugin with its own protocol, a terminal for instance, contributes a **socket**:
|
||||
|
||||
```ts
|
||||
self.contribute(Http, { sockets: { "/terminal": (socket, request, ctx) => terminals.attach(socket, ctx) } });
|
||||
```
|
||||
|
||||
The component upgrades after admission and `authenticate` (no principal: refused before the plugin sees it) and hands
|
||||
the plugin a ready WebSocket. It pings it after 15 seconds of silence and terminates it after 30, so a phone that
|
||||
vanished does not hold a terminal open for hours.
|
||||
|
||||
What was removed is closed at once. When a reload changes or removes the plugin that handed out a socket, the socket
|
||||
is terminated: old code does not keep serving it. When an allowed host is removed, every link and socket that came
|
||||
through it is terminated. "Terminated" means immediately, not a polite close a hostile client could ignore.
|
||||
|
||||
# A Worker's Life {#worker lede="Started by the first use, connected before it loads, stopped when nobody needs it."}
|
||||
|
||||
Nothing starts a worker but a use of its session. A tab opens session S1 and holds `Chat { session: S1 }`:
|
||||
|
||||
:::sequence animate | The first hold of a session starts its worker. The link exists before the worker loads, so it serves while loading.
|
||||
actor tab "tab"
|
||||
actor server "server"
|
||||
actor worker "worker S1" "forked" accent
|
||||
tab -> server: hold Chat { session: S1 }
|
||||
server -> server: route Session(S1): resolve
|
||||
server => worker: fork, start message (start data, scripts)
|
||||
worker -> worker: take the lock for S1
|
||||
worker --> server: started (connected)
|
||||
server -> worker: hold Chat (relayed for the tab)
|
||||
worker -> worker: load plugins
|
||||
worker --> server: bound (Chat's state)
|
||||
server --> tab: bound
|
||||
worker --> server: ready { idle: false }
|
||||
:::
|
||||
|
||||
`started` comes as soon as the worker connected; the server then talks to it while it loads. The 60-second start
|
||||
timer covers only fork, lock and connect: a session with a long migration loads as long as it needs, its objects
|
||||
`loading` meanwhile. `ready` comes after the load and lets idle stops begin.
|
||||
|
||||
The **lock** makes sure one live worker serves an address, even across a server restart while an old worker still
|
||||
closes its store: the new one waits for the old one to let go.
|
||||
|
||||
**Idle** means nothing uses the worker: no holds arriving over its link, no jobs, no lingering objects, and none of the
|
||||
app's own busy counts (its running agent tasks). Local calls inside the worker do not count; they come from work that
|
||||
is counted elsewhere. The worker reports only when that flips between idle and busy. After five idle minutes the
|
||||
server sends `shutdown`; the worker closes its host and store in reverse order of opening and exits.
|
||||
|
||||
`workers.stop(address)` is the archive button: it stops the worker and keeps it stopped while a tab still holds the
|
||||
session (the tab sees "suspended"). With the last holder gone, the next use starts it again.
|
||||
|
||||
# When a Worker Fails {#failure lede="No automatic restarts: why, and how a failure is shown and cleared."}
|
||||
|
||||
A worker crashes: an out-of-memory kill, a bug in a native addon. The obvious design restarts it. Mario rejected that
|
||||
on 2026-10-30: a crash usually has a reason, and the same code on the same data crashes again, while the agent task
|
||||
that caused it is resumed by the restart and runs into it again. A timer only makes the loop slower.
|
||||
|
||||
:::steps | A crash and its recovery
|
||||
+++ The crash
|
||||
Worker S1 exits unasked. The server records the session as failed with the error, and reports the full error and the
|
||||
end of the worker's stderr to its own `onError`, for Mario's logs.
|
||||
+++ Holders see it
|
||||
Every tab holding S1's objects sees `failed { waiting }` with a fixed message ("worker exited (SIGSEGV)"), never the
|
||||
raw error: it may hold paths or secrets, and guests see it too. A new tab opening S1 sees the same and forks nothing.
|
||||
+++ Retry
|
||||
Mario presses Retry (`workers.retry(Session({ id: "S1" }))`). The server starts the worker; the tabs' references bind
|
||||
to it without reloading the page.
|
||||
+++ Or a fix
|
||||
Mario fixes the plugin and rolls out. The rollout clears the failures from before it and starts the failed sessions
|
||||
that tabs still hold, once, with the new code. A session that fails again stays failed.
|
||||
:::
|
||||
|
||||
Every way a worker can fail ends the same: a crash, a `fatal` from the worker, a start that throws or times out, and a
|
||||
link lost while the process lives (the heartbeat after a blocked event loop), which the server ends by killing the
|
||||
process after five seconds.
|
||||
|
||||
# Shipping Plugins to Workers {#ship lede="One build on the server, scripts sent to every worker, a result per host."}
|
||||
|
||||
Building a plugin takes esbuild a few milliseconds per facet, but a cold build of twenty plugins takes a quarter of a
|
||||
second. Doing that in every worker that starts would slow every session's start and let workers see different
|
||||
code. So the server builds every plugin, once, and a worker only evaluates.
|
||||
|
||||
A worker's start message carries the complete current set of worker scripts. When Mario edits a plugin and rolls out,
|
||||
the rollout runs its steps in order:
|
||||
|
||||
```ts
|
||||
const built = await plugins.build(plugins.state.value.dirty, ctx);
|
||||
const result = await plugins.rollout([plugins.step("server", server), workers.step("workers")], built, ctx);
|
||||
// result.steps[1].hosts: [{ host: "session|S1", report }, { host: "session|S2", stopped: true }, { host: "session|S3", error }]
|
||||
```
|
||||
|
||||
The workers step sends the complete new set to every running worker in parallel; each reloads per plugin (a broken
|
||||
plugin keeps its old code, as on the server) and answers with its report. A worker stopped cleanly during the step
|
||||
is listed `stopped` and gets the new set at its next start. A plugin's "dirty" mark clears only when it was applied
|
||||
on every host of every step, so a plugin that failed anywhere stays marked for the next try.
|
||||
|
||||
# Changing It at Runtime {#runtime lede="What plugins can change while the app runs, and what needs a restart."}
|
||||
|
||||
The app is meant to modify itself, so every option that is not needed to start the process is changeable by plugins.
|
||||
Routes, wraps, sockets, allowed hosts and trusted framers are contributions to `Http`; a contribution that changes is a
|
||||
writable, and the router recomposes on each change:
|
||||
|
||||
```ts
|
||||
const hosts = self.writable({ hosts: [] as string[] });
|
||||
self.contribute(Http, hosts);
|
||||
self.on(tunnel.publicHost, ({ value }) => hosts.set({ hosts: value ? [value] : [] }));
|
||||
```
|
||||
|
||||
Authenticators, framers and extra CSP sources are contributions too; functions the workers component calls at each
|
||||
use (a worker's start data, its idle time) can read the app's own registries, which plugins contribute to. What needs a restart is only the
|
||||
bootstrap: the port, the `/chord` path, the worker's entry file and its Node flags.
|
||||
|
||||
# What's Next {.epilogue lede="What the reviews found, and what comes after this slice."}
|
||||
|
||||
Four build reviews, three security audits and three rounds of rechecks shaped the slice. They found a malformed request
|
||||
crashing the server, `Host`-only provenance, origins invented by a tab (a kernel hole since slice 4a, now refused on tab
|
||||
links), secrets reaching guests through worker errors, close and shutdown races, and leaks of request scopes; each is
|
||||
fixed with a regression test. A route costs about 20 µs more than a plain Node handler (12 µs warmed), accepted for
|
||||
what it buys: a scope per request, admission and the baseline headers.
|
||||
|
||||
Next comes the app kit (`chord/next/app`), which wires these components with Chord's conventions so an app writes
|
||||
`startApp` and `startWorker`, then slice 11: the browser page and the terminal UI as hosts.
|
||||
|
||||
# API in Brief {.appendix}
|
||||
|
||||
| Call | What it does |
|
||||
| --- | --- |
|
||||
| `createHttp(host, { listen?, links?, closeTimeout?, onError }, ctx)` | the component: `facet`, `fetch`, `upgrade`, `close` |
|
||||
| `Authenticators` contribution | `(request, ctx) => P \| undefined`: who a request is |
|
||||
| `nodeRequest(message, res)` | a Node request as a Web `Request`, with the listener's checks |
|
||||
| `http.facet` | the facet serving `Http`; load it with the plugins (in `core`) |
|
||||
| `Http` contribution | `{ routes?, wraps?, sockets?, maxPayload?, hosts?, framers?, relays?, csp? }` |
|
||||
| route | `(request, ctx) => Promise<HttpResponse>`, or `{ handler, page?, crossSite? }` |
|
||||
| `fileResponse(path, options)`, `cookie(name, value, options)` | a file as bytes; a safe cookie |
|
||||
| `createWorkers(host, Kind, { kind, entry, start, lock, principal, authorize, plugins, idleMs?, onError }, ctx)` | a worker per address |
|
||||
| `workers.state`, `stop`, `retry`, `close`, `step(name)` | status per address; archive; Retry; shutdown; a rollout step |
|
||||
| `workerMain(fn, { hostPackages, redact, onError })` | the worker process's side |
|
||||
| `RouteHandle.retry(address)`, `suspend(address)` | the kernel's route, as `host.route` returns it |
|
||||
515
packages/chord/docs/next/http-workers/index.html
Normal file
515
packages/chord/docs/next/http-workers/index.html
Normal file
File diff suppressed because one or more lines are too long
216
packages/chord/docs/next/i18n/book.md
Normal file
216
packages/chord/docs/next/i18n/book.md
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
---
|
||||
title: Speaking Every Language: i18n in the app kit
|
||||
cover: i18n / in the App Kit
|
||||
kicker: A proposal · Chord v2 app kit · 2026-11-02
|
||||
subtitle: How an app and its plugins write their texts once, in code, and show them in each viewer's language
|
||||
stats:
|
||||
- 1 | function a plugin calls: t(key, ...args)
|
||||
- 0 | message files outside the code
|
||||
- 0 | page reloads to switch a language
|
||||
note: |
|
||||
A proposal by the redesign session, asked for by the owner on 2026-11-02 ("i18n also needs to be part of the app
|
||||
kit"; "need code example"). It starts from mini-lit's `src/i18n.ts` (typed catalogs in code) and adapts it to
|
||||
plugins that are built separately, reload live and run on several hosts. Not built; it would land in the app kit
|
||||
with slice 11.
|
||||
colophon: Speaking Every Language · proposal · 2026-11-02
|
||||
---
|
||||
|
||||
# Before we begin {.preface}
|
||||
|
||||
Lena uses p3 in German; her colleague Tom, a guest in her session, reads English. The notes plugin shows "New note"
|
||||
to Tom and "Neue Notiz" to Lena, on the same page served by the same server. A French translator later adds French to
|
||||
the app and to every plugin, without touching any of them. And when Lena switches to French, nothing reloads: the
|
||||
text on her screen changes, the half-written message stays.
|
||||
|
||||
mini-lit already does the first part well: messages are TypeScript objects, keyed and type-checked, so a missing
|
||||
German text is a compile error. Three things do not carry over: its messages are one global table extended by
|
||||
declaration merging (which separately built plugins cannot share), the language lives in `localStorage` and switching
|
||||
reloads the page, and every text is formatted where it is shown, which breaks when the server formats a message for
|
||||
two viewers with two languages.
|
||||
|
||||
## Five things to know
|
||||
|
||||
:::findings
|
||||
1. **Messages stay in code,** per plugin: `defineMessages("notes", { en: {...}, de: {...} })` returns a typed `t`.
|
||||
2. **Values may be functions,** for plurals and parameters; dates and numbers go through `Intl`.
|
||||
3. **Languages are contributions:** a `Translations` registry adds a language to any plugin, the app included.
|
||||
4. **The language is a user preference,** from the browser or the system locale by default; switching re-renders live.
|
||||
5. **Servers and workers never format text for people:** they send a key and its parameters, and each viewer's client
|
||||
renders them in its own language.
|
||||
:::
|
||||
|
||||
## Words used throughout
|
||||
|
||||
A **message** is one text with a key (`newNote`). A **catalog** is the messages of one plugin in one language. A
|
||||
**namespace** is the plugin a catalog belongs to. A **locale** is a language tag (`de`, `pt-BR`).
|
||||
|
||||
# Messages in Code {#messages lede="Each plugin defines its own messages and gets a typed function back."}
|
||||
|
||||
```ts
|
||||
// plugins/notes/messages.ts
|
||||
import { defineMessages } from "@p3/app/v1";
|
||||
|
||||
export const t = defineMessages("notes", {
|
||||
en: {
|
||||
newNote: "New note",
|
||||
empty: "No notes yet",
|
||||
count: (n: number) => (n === 1 ? "1 note" : `${n} notes`),
|
||||
saved: (title: string, at: Date) => `Saved "${title}" at ${formatTime(at)}`,
|
||||
},
|
||||
de: {
|
||||
newNote: "Neue Notiz",
|
||||
empty: "Noch keine Notizen",
|
||||
count: (n) => (n === 1 ? "1 Notiz" : `${n} Notizen`),
|
||||
saved: (title, at) => `„${title}“ um ${formatTime(at)} gespeichert`,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// plugins/notes/web.tsx
|
||||
import { t } from "./messages.ts";
|
||||
|
||||
function NotesHeader({ count }: { count: number }) {
|
||||
return <h2>{t("count", count)} <button>{t("newNote")}</button></h2>;
|
||||
}
|
||||
```
|
||||
|
||||
The first catalog (`en`) defines the keys and their argument types; every other catalog must have the same keys with
|
||||
the same signatures, so a missing German text, a typo in a key or a wrong argument is a type error, not a blank in
|
||||
production. `t("count", "3")` does not compile. The namespace (`"notes"`) keeps two plugins' `empty` apart.
|
||||
|
||||
Functions cover what plain strings cannot: plurals (`count`), parameters in the right word order (`saved` puts the
|
||||
title first in English and German alike, but another language may not), and gender or case where a language needs it.
|
||||
Plural rules beyond one and many use `Intl.PluralRules`; dates, times and numbers use `Intl.DateTimeFormat` and
|
||||
`Intl.NumberFormat` with the viewer's locale, through two small helpers (`formatTime`, `formatNumber`) that read the
|
||||
current locale like `t`.
|
||||
|
||||
# Languages as Contributions {#translations lede="Adding French to the app and every plugin without editing them."}
|
||||
|
||||
A plugin ships the languages its author speaks. Everything else can come from anyone, as a contribution:
|
||||
|
||||
```ts
|
||||
// plugins/french/web.ts (a translation plugin)
|
||||
import { defineFacet, Translations } from "@p3/app/v1";
|
||||
import type { MessagesOf } from "@p3/app/v1";
|
||||
import type { t as notes } from "../notes/messages.ts"; // the notes plugin's keys, for the type check
|
||||
|
||||
const notesFr: MessagesOf<typeof notes> = {
|
||||
newNote: "Nouvelle note",
|
||||
empty: "Aucune note",
|
||||
count: (n) => (n <= 1 ? `${n} note` : `${n} notes`),
|
||||
saved: (title, at) => `« ${title} » enregistrée à ${formatTime(at)}`,
|
||||
};
|
||||
|
||||
export default defineFacet((_host, self) => {
|
||||
self.contribute(Translations, { namespace: "notes", locale: "fr", messages: notesFr });
|
||||
self.contribute(Translations, { namespace: "app", locale: "fr", messages: appFr });
|
||||
});
|
||||
```
|
||||
|
||||
- `MessagesOf<typeof t>` gives a translation plugin the exact keys and signatures to fill, from the plugin it
|
||||
translates (an imported type, erased at build).
|
||||
- A catalog may be partial: a missing key falls back to the plugin's own catalog for that language's base (`pt-BR` to
|
||||
`pt`), then to the first catalog (`en`), key by key. A fallback is reported once in the logs, so gaps are visible.
|
||||
- Two contributions for the same namespace and locale: the first by plugin name wins, the other is reported (the
|
||||
registry rule for conflicts).
|
||||
- A plugin's own catalogs are its built-in contributions; a translation plugin can also override one of them (a
|
||||
better German), contributed explicitly with `override: true`.
|
||||
|
||||
## One file, every host
|
||||
|
||||
Registries are per host: a contribution lives on the host whose facet made it. A French catalog contributed from the
|
||||
web facet exists in browser pages only. So a plugin keeps its messages in one shared file, `messages.ts`, which every
|
||||
facet that renders text imports (the web and TUI facets; the worker's too when it builds messages with `t.message`),
|
||||
and a translation plugin keeps its catalog in one shared file too and contributes it from each facet that renders:
|
||||
|
||||
```ts
|
||||
// plugins/french/fr.ts: the catalogs, written once
|
||||
export const notesFr: MessagesOf<typeof notes> = { newNote: "Nouvelle note", /* ... */ };
|
||||
// plugins/french/web.ts and plugins/french/tui.ts: the same two lines each
|
||||
export default defineFacet((_host, self) => self.contribute(Translations, { namespace: "notes", locale: "fr", messages: notesFr }));
|
||||
```
|
||||
|
||||
The server and workers need catalogs only when they produce final text themselves (an email, a push notification);
|
||||
then their facets contribute them too, and the text is rendered in the recipient's language from their preference.
|
||||
Catalogs never travel over links: they contain functions.
|
||||
|
||||
# Choosing a Language {#locale lede="Per viewer, defaulted from the device, switched live."}
|
||||
|
||||
The language is a user preference, kept with the user's other preferences (the kit's per-principal document, like
|
||||
the theme choice), so it follows a person across devices:
|
||||
|
||||
- **Default:** the browser's `navigator.languages` on the web, the system locale in the TUI, matched against the
|
||||
locales any catalog offers.
|
||||
- **Switching:** the app's settings show the locales available (the union of all catalogs); choosing one writes the
|
||||
preference. `t` reads the current locale from a watchable; React components re-render through a hook, the TUI
|
||||
redraws through its scope. Nothing reloads.
|
||||
|
||||
```tsx
|
||||
// any web component
|
||||
const { t, locale } = useMessages(notesMessages); // re-renders when the locale or a translation changes
|
||||
```
|
||||
|
||||
When a translation plugin is edited and rolled out, the registry recomposes and the open pages show the new text, as
|
||||
with themes.
|
||||
|
||||
# Text from the Server {#server lede="Why the server sends keys, not sentences."}
|
||||
|
||||
The server and the workers also produce text people read: a notice ("Worker failed to start"), a tool card's
|
||||
summary, a status line, an error. If the worker formatted "Notiz gespeichert" for Lena, Tom would read German too:
|
||||
both look at the same object state.
|
||||
|
||||
So objects carry messages as data, and each client renders them:
|
||||
|
||||
```ts
|
||||
// plugins/notes/contract.ts: a member of the Notes object's state holds a message as data
|
||||
lastSaved: opt(obj({ namespace: str(), key: str(), params: arr(unknown()) })),
|
||||
|
||||
// plugins/notes/worker.ts: the object writes the key and its parameters, never a sentence; the same `t` builds it
|
||||
import { t } from "./messages.ts";
|
||||
await self.tx(async (tx) => {
|
||||
(await tx.draft(status)).lastSaved = t.message("saved", note.title, note.at); // typed: key and arguments checked
|
||||
}, ctx);
|
||||
|
||||
// plugins/notes/web.tsx: each viewer renders it in its own language
|
||||
const notes = useHold(Notes, { session });
|
||||
const text = notes.lastSaved && t.render(notes.lastSaved); // typed against the notes catalog
|
||||
```
|
||||
|
||||
`t.message` returns a `Message<"notes">`, plain data `{ namespace, key, params }`, typed against the first catalog:
|
||||
`t.message("saved", 42)` and `t.message("svaed", ...)` are type errors. `t.render` takes only `Message<"notes">`. Across a
|
||||
link the contract's member is a generic message schema, so `t.render` checks the namespace and key at runtime and
|
||||
shows the key for an unknown one. The writer and the reader are type-checked; the hop between them is checked at
|
||||
runtime.
|
||||
|
||||
The same holds for anything else that reaches people: an app's notices object stores `{ namespace, key, params }`
|
||||
per notice, a tool card's summary carries one, an error shown to a user carries a code and parameters.
|
||||
|
||||
Parameters are plain data (strings, numbers, ISO dates), so they cross links like any state. What is never
|
||||
translated: the model's prompts and the model's own answers (the conversation's language is the user's business with
|
||||
the model), log lines (for developers, in English), error codes. Error *messages* shown to people follow the same rule
|
||||
as notices: a code plus parameters, rendered by the client.
|
||||
|
||||
# Open Questions {.epilogue lede="To decide."}
|
||||
|
||||
1. **Keys or English text as keys.** mini-lit keys by the English text (`"Copy"`), which reads well and needs no key
|
||||
names; named keys (`newNote`) survive rewording the English. Recommendation: named keys, with the English catalog
|
||||
as the source of truth.
|
||||
2. **A file format for translators.** Code catalogs are best for developers and agents; human translators may want
|
||||
JSON or PO files. Recommendation: no files now; a small tool can export a plugin's catalogs to JSON and import them
|
||||
back into a translation plugin when someone needs it.
|
||||
3. **Right-to-left languages.** The page sets `dir` from the locale; the app's CSS uses logical properties
|
||||
(`margin-inline-start`). Recommendation: the kit sets `lang` and `dir` on the root; components are the app's.
|
||||
4. **The TUI's locale.** From the system locale, overridden by the user's preference, like the web.
|
||||
|
||||
# API in Brief {.appendix}
|
||||
|
||||
| Piece | Where | What |
|
||||
| --- | --- | --- |
|
||||
| `defineMessages(namespace, catalogs)` | the app's API (shared) | typed `t(key, ...args)`, `t.render(message)` |
|
||||
| `MessagesOf<typeof t>` | the app's API (type) | the keys and signatures a translation must fill |
|
||||
| `Translations` | the app's API (shared registry) | `{ namespace, locale, messages, override? }` |
|
||||
| `useMessages(t)` | the app's web module | the hook re-rendering on locale or translation changes |
|
||||
| `formatTime`, `formatNumber` | the app's API | `Intl` helpers in the viewer's locale |
|
||||
| the locale preference | the kit's per-principal preferences | default from the device, switched live |
|
||||
| `{ namespace, key, params }` | object state and notices | a message as data, rendered by each client |
|
||||
343
packages/chord/docs/next/i18n/index.html
Normal file
343
packages/chord/docs/next/i18n/index.html
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Speaking Every Language: i18n in the app kit</title>
|
||||
<style>
|
||||
|
||||
:root{--paper:#fbf8f2;--ink:#2a2723;--muted:#7b7468;--rule:#e3dbcc;--accent:#b4472f;--blue:#2e6a8a;--sand:#c2b18c;--soft:#f4eee2}
|
||||
*{box-sizing:border-box}
|
||||
html{background:#efe9dd}
|
||||
body{margin:0;color:var(--ink);font:19px/1.68 'Iowan Old Style','Palatino Linotype',Palatino,'Book Antiqua',Georgia,serif;
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
||||
.book{max-width:860px;margin:0 auto;background:var(--paper);box-shadow:0 0 0 1px #e1d9c8,0 30px 80px -40px rgba(60,40,10,.35);padding:0 0 4rem}
|
||||
.inner{padding:0 90px}
|
||||
h1,h2,h3{font-weight:400;line-height:1.15}
|
||||
h2{font-size:2.1rem;margin:.2em 0 .3em;letter-spacing:-.01em}
|
||||
h3{font-size:1.05rem;text-transform:uppercase;letter-spacing:.14em;margin:2.4em 0 .6em;color:var(--ink);font-weight:600;
|
||||
font-family:'Avenir Next','Helvetica Neue',sans-serif}
|
||||
p{margin:0 0 1.05em;hyphens:auto;text-align:left}
|
||||
code{font:.8em/1 'SF Mono',Menlo,Consolas,monospace;background:#f1ebde;padding:.1em .35em;border-radius:3px}
|
||||
pre{font:.74rem/1.55 'SF Mono',Menlo,Consolas,monospace;background:#f4eee2;border-left:3px solid var(--sand);padding:1rem 1.2rem;margin:1.4rem 0 1.8rem;
|
||||
overflow-x:auto;border-radius:0 4px 4px 0;white-space:pre;tab-size:2}
|
||||
pre code{background:none;padding:0;font-size:1em}
|
||||
em{font-style:italic}
|
||||
a{color:var(--blue)}
|
||||
.cover{min-height:92vh;display:flex;flex-direction:column;justify-content:center;padding:90px;background:
|
||||
radial-gradient(circle at 85% 15%,rgba(180,71,47,.13),transparent 40%),radial-gradient(circle at 10% 90%,rgba(46,106,138,.12),transparent 45%),var(--paper);
|
||||
border-bottom:1px solid var(--rule)}
|
||||
.kicker{font:600 .78rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.22em;text-transform:uppercase;color:var(--accent)}
|
||||
.cover h1{font-size:5.2rem;line-height:.98;margin:.35em 0 .25em;letter-spacing:-.025em}
|
||||
.subtitle{font-size:1.45rem;font-style:italic;color:#4a453e;max-width:30em;text-align:left}
|
||||
.cover-stats,.stats{display:flex;flex-wrap:wrap;gap:1.2rem 2.4rem;margin:2.2rem 0}
|
||||
.stats{margin:1.6rem 0 1.8rem;padding:1.1rem 0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
|
||||
.stat b{display:block;font-size:2.1rem;font-weight:400;line-height:1.1;font-variant-numeric:lining-nums}
|
||||
.stat span{font:.78rem/1.3 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);letter-spacing:.03em;display:block;max-width:14em}
|
||||
.cover-note{font-size:.9rem;color:var(--muted);max-width:34em;text-align:left;margin-top:2rem}
|
||||
.toc{padding:4rem 0 2rem}
|
||||
.toc ol{list-style:none;padding:0;margin:1rem 0;columns:2;column-gap:3rem}
|
||||
.toc li{padding:.3rem 0;border-bottom:1px dotted var(--rule);break-inside:avoid}
|
||||
.toc a{color:var(--ink);text-decoration:none}
|
||||
.toc a:hover{color:var(--accent)}
|
||||
.preface{padding-top:2rem}
|
||||
.findings{padding-left:1.4em}
|
||||
.findings li{margin:.55em 0;padding-left:.3em}
|
||||
.findings li::marker{color:var(--accent);font-weight:600}
|
||||
.chapter{padding-top:4.5rem;margin-top:3rem;border-top:1px solid var(--rule)}
|
||||
.opener{text-align:center;margin-bottom:2.6rem}
|
||||
.numeral{font-size:4.2rem;color:var(--accent);line-height:1;font-variant:small-caps;letter-spacing:.05em}
|
||||
.opener h2{font-size:2.5rem;margin:.25em 0}
|
||||
.lede{font-style:italic;color:#5a544a;font-size:1.12rem;max-width:30em;margin:0 auto;text-align:center}
|
||||
.dropcap::first-letter{float:left;font-size:4.1em;line-height:.82;padding:.06em .09em 0 0;color:var(--accent)}
|
||||
.fig{margin:2rem 0 2.2rem}
|
||||
.fig.wide{margin-left:-50px;margin-right:-50px}
|
||||
.fig svg{display:block;margin:0 auto}
|
||||
figcaption{font:.8rem/1.45 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);margin-top:.7rem;padding-left:.8rem;border-left:2px solid var(--rule)}
|
||||
.fignum{color:var(--ink);font-weight:600}
|
||||
.note{font:.86rem/1.55 'Avenir Next','Helvetica Neue',sans-serif;color:#5b5549;background:#f4eee2;border-left:3px solid var(--sand);
|
||||
padding:.9rem 1.1rem;margin:1.8rem 0;border-radius:0 4px 4px 0}
|
||||
.note.review{border-left-color:var(--accent)}
|
||||
.note b.tag{font-weight:600;letter-spacing:.12em;text-transform:uppercase;font-size:.72rem;color:var(--accent);display:block;margin-bottom:.3rem}
|
||||
.note code{font-size:.85em}
|
||||
table{width:100%;border-collapse:collapse;margin:1.4rem 0 1.8rem;font:.8rem/1.4 'Avenir Next','Helvetica Neue',sans-serif}
|
||||
th{text-align:left;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-size:.68rem;border-bottom:1.5px solid var(--ink);padding:.45rem .5rem}
|
||||
td{padding:.42rem .5rem;border-bottom:1px solid var(--rule);vertical-align:top}
|
||||
td code{font-size:.92em}
|
||||
table.num td:not(:first-child),table.num th:not(:first-child){text-align:right}
|
||||
.epilogue p{font-size:1.04em}
|
||||
.appendix{font-size:.92em}
|
||||
.appendix ul,.chapter ul{padding-left:1.2em}
|
||||
.appendix li,.chapter li{margin:.4em 0}
|
||||
.colophon{text-align:center;font:.78rem 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);margin-top:4rem;letter-spacing:.05em}
|
||||
@media (max-width:760px){.inner{padding:0 22px}.cover{padding:40px 22px}.cover h1{font-size:3.2rem}.fig.wide{margin-left:0;margin-right:0}
|
||||
.toc ol{columns:1}p{text-align:left}body{font-size:17px}}
|
||||
@media print{html{background:#fff}.book{box-shadow:none}.chapter{break-before:page}.fig{break-inside:avoid}}
|
||||
.transcript p b{font:600 .78rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-right:.3em}
|
||||
pre .tk-c{color:#8d8576;font-style:italic}pre .tk-s{color:#5f7a3d}pre .tk-n{color:#a0662a}pre .tk-k{color:#b4472f}pre .tk-t{color:#2e6a8a}pre .tk-f{color:#7a3f6a}
|
||||
h4{font:600 .82rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.06em;margin:1.8em 0 .5em;color:var(--muted)}
|
||||
.inner ul,.inner ol:not(.findings){padding-left:1.2em}
|
||||
.inner li{margin:.35em 0}
|
||||
blockquote{margin:1.4rem 0;padding:.2rem 0 .2rem 1.2rem;border-left:3px solid var(--rule);color:#5a544a;font-style:italic}
|
||||
.fig svg text{dominant-baseline:auto}
|
||||
.fig .replay{font:600 .7rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);
|
||||
background:none;border:1px solid var(--rule);border-radius:3px;padding:.2rem .55rem;margin:0 0 0 1rem;cursor:pointer;float:right}
|
||||
.fig .replay:hover{border-color:var(--accent)}
|
||||
.anim.armed .step{opacity:0}
|
||||
.anim.armed.play .step{animation:stepIn .45s ease-out both;animation-delay:calc(var(--i) * var(--pace, .45s))}
|
||||
.anim.armed.play .step.edge{animation-name:edgeIn}
|
||||
@keyframes stepIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||
@keyframes edgeIn{from{opacity:0}to{opacity:1}}
|
||||
.steps{margin:2rem 0 2.2rem;border:1px solid var(--rule);border-radius:6px;background:#fffdf8}
|
||||
.steps-head{display:flex;align-items:center;gap:.8rem;padding:.6rem 1rem;border-bottom:1px solid var(--rule);
|
||||
font:600 .72rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
|
||||
.steps-head .title{flex:1}
|
||||
.steps-head .count{color:var(--muted);letter-spacing:.06em}
|
||||
.steps-head button{font:600 .72rem 'Avenir Next','Helvetica Neue',sans-serif;color:var(--ink);background:var(--soft);border:1px solid var(--rule);
|
||||
border-radius:3px;padding:.25rem .65rem;cursor:pointer}
|
||||
.steps-head button:disabled{opacity:.35;cursor:default}
|
||||
.step-panel{padding:1rem 1.2rem .4rem}
|
||||
.step-panel>h4:first-child{margin-top:.2rem;color:var(--ink)}
|
||||
.steps.js .step-panel{display:none}
|
||||
.steps.js .step-panel.on{display:block}
|
||||
.steps:not(.js) .steps-head button,.steps:not(.js) .steps-head .count{display:none}
|
||||
.steps:not(.js) .step-panel+.step-panel{border-top:1px dashed var(--rule)}
|
||||
.fig figcaption{display:flow-root}
|
||||
.fig svg{max-width:100%;height:auto}
|
||||
table{max-width:100%}
|
||||
@media (max-width:760px){.inner{padding:0 20px}.cover{padding:48px 22px}.cover h1{font-size:3.2rem}.fig.wide{margin-left:0;margin-right:0}table{display:block;overflow-x:auto}.toc ol{columns:1}}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body><div class="book">
|
||||
<section class="cover">
|
||||
<div class="kicker">A proposal · Chord v2 app kit · 2026-11-02</div>
|
||||
<h1>i18n<br>in the App Kit</h1>
|
||||
<p class="subtitle">How an app and its plugins write their texts once, in code, and show them in each viewer's language</p>
|
||||
<div class="cover-stats"><div class="stat"><b>1</b><span>function a plugin calls: t(key, ...args)</span></div><div class="stat"><b>0</b><span>message files outside the code</span></div><div class="stat"><b>0</b><span>page reloads to switch a language</span></div></div>
|
||||
<div class="cover-note"><p>A proposal by the redesign session, asked for by the owner on 2026-11-02 ("i18n also needs to be part of the app
|
||||
kit"; "need code example"). It starts from mini-lit's <code>src/i18n.ts</code> (typed catalogs in code) and adapts it to
|
||||
plugins that are built separately, reload live and run on several hosts. Not built; it would land in the app kit
|
||||
with slice 11.</p>
|
||||
</div>
|
||||
</section><div class="inner">
|
||||
<nav class="toc"><h2>Contents</h2><ol>
|
||||
<li><a href="#before-we-begin">Before we begin</a></li>
|
||||
<li><a href="#messages">I. Messages in Code</a></li>
|
||||
<li><a href="#translations">II. Languages as Contributions</a></li>
|
||||
<li><a href="#locale">III. Choosing a Language</a></li>
|
||||
<li><a href="#server">IV. Text from the Server</a></li>
|
||||
<li><a href="#open-questions">Open Questions</a></li>
|
||||
<li><a href="#api-in-brief">Appendix A: API in Brief</a></li>
|
||||
</ol></nav>
|
||||
|
||||
<section class="preface" id="before-we-begin">
|
||||
<h2>Before we begin</h2>
|
||||
<p class="dropcap">Lena uses p3 in German; her colleague Tom, a guest in her session, reads English. The notes plugin shows "New note"
|
||||
to Tom and "Neue Notiz" to Lena, on the same page served by the same server. A French translator later adds French to
|
||||
the app and to every plugin, without touching any of them. And when Lena switches to French, nothing reloads: the
|
||||
text on her screen changes, the half-written message stays.</p>
|
||||
<p>mini-lit already does the first part well: messages are TypeScript objects, keyed and type-checked, so a missing
|
||||
German text is a compile error. Three things do not carry over: its messages are one global table extended by
|
||||
declaration merging (which separately built plugins cannot share), the language lives in <code>localStorage</code> and switching
|
||||
reloads the page, and every text is formatted where it is shown, which breaks when the server formats a message for
|
||||
two viewers with two languages.</p>
|
||||
<h3 id="five-things-to-know">Five things to know</h3>
|
||||
<ol class="findings">
|
||||
<li><strong>Messages stay in code,</strong> per plugin: <code>defineMessages("notes", { en: {...}, de: {...} })</code> returns a typed <code>t</code>.</li>
|
||||
<li><strong>Values may be functions,</strong> for plurals and parameters; dates and numbers go through <code>Intl</code>.</li>
|
||||
<li><strong>Languages are contributions:</strong> a <code>Translations</code> registry adds a language to any plugin, the app included.</li>
|
||||
<li><strong>The language is a user preference,</strong> from the browser or the system locale by default; switching re-renders live.</li>
|
||||
<li><strong>Servers and workers never format text for people:</strong> they send a key and its parameters, and each viewer's client
|
||||
renders them in its own language.</li>
|
||||
</ol>
|
||||
<h3 id="words-used-throughout">Words used throughout</h3>
|
||||
<p>A <strong>message</strong> is one text with a key (<code>newNote</code>). A <strong>catalog</strong> is the messages of one plugin in one language. A
|
||||
<strong>namespace</strong> is the plugin a catalog belongs to. A <strong>locale</strong> is a language tag (<code>de</code>, <code>pt-BR</code>).</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="messages"><header class="opener"><div class="numeral">I</div><h2>Messages in Code</h2><p class="lede">Each plugin defines its own messages and gets a typed function back.</p></header>
|
||||
<pre><code><span class="tk-c">// plugins/notes/messages.ts</span>
|
||||
<span class="tk-k">import</span> { defineMessages } <span class="tk-k">from</span> <span class="tk-s">"@p3/app/v1"</span>;
|
||||
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> t = <span class="tk-f">defineMessages</span>(<span class="tk-s">"notes"</span>, {
|
||||
en: {
|
||||
newNote: <span class="tk-s">"New note"</span>,
|
||||
empty: <span class="tk-s">"No notes yet"</span>,
|
||||
count: (n: number) => (n === <span class="tk-n">1</span> ? <span class="tk-s">"1 note"</span> : <span class="tk-s">`${n} notes`</span>),
|
||||
saved: (title: string, at: <span class="tk-t">Date</span>) => <span class="tk-s">`Saved "${title}" at ${formatTime(at)}`</span>,
|
||||
},
|
||||
de: {
|
||||
newNote: <span class="tk-s">"Neue Notiz"</span>,
|
||||
empty: <span class="tk-s">"Noch keine Notizen"</span>,
|
||||
count: (n) => (n === <span class="tk-n">1</span> ? <span class="tk-s">"1 Notiz"</span> : <span class="tk-s">`${n} Notizen`</span>),
|
||||
saved: (title, at) => <span class="tk-s">`„${title}“ um ${formatTime(at)} gespeichert`</span>,
|
||||
},
|
||||
});</code></pre>
|
||||
<pre><code><span class="tk-c">// plugins/notes/web.tsx</span>
|
||||
<span class="tk-k">import</span> { t } <span class="tk-k">from</span> <span class="tk-s">"./messages.ts"</span>;
|
||||
|
||||
<span class="tk-k">function</span> <span class="tk-t">NotesHeader</span>({ count }: { count: number }) {
|
||||
<span class="tk-k">return</span> <h2>{<span class="tk-f">t</span>(<span class="tk-s">"count"</span>, count)} <button>{<span class="tk-f">t</span>(<span class="tk-s">"newNote"</span>)}</button></h2>;
|
||||
}</code></pre>
|
||||
<p>The first catalog (<code>en</code>) defines the keys and their argument types; every other catalog must have the same keys with
|
||||
the same signatures, so a missing German text, a typo in a key or a wrong argument is a type error, not a blank in
|
||||
production. <code>t("count", "3")</code> does not compile. The namespace (<code>"notes"</code>) keeps two plugins' <code>empty</code> apart.</p>
|
||||
<p>Functions cover what plain strings cannot: plurals (<code>count</code>), parameters in the right word order (<code>saved</code> puts the
|
||||
title first in English and German alike, but another language may not), and gender or case where a language needs it.
|
||||
Plural rules beyond one and many use <code>Intl.PluralRules</code>; dates, times and numbers use <code>Intl.DateTimeFormat</code> and
|
||||
<code>Intl.NumberFormat</code> with the viewer's locale, through two small helpers (<code>formatTime</code>, <code>formatNumber</code>) that read the
|
||||
current locale like <code>t</code>.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="translations"><header class="opener"><div class="numeral">II</div><h2>Languages as Contributions</h2><p class="lede">Adding French to the app and every plugin without editing them.</p></header>
|
||||
<p class="dropcap">A plugin ships the languages its author speaks. Everything else can come from anyone, as a contribution:</p>
|
||||
<pre><code><span class="tk-c">// plugins/french/web.ts (a translation plugin)</span>
|
||||
<span class="tk-k">import</span> { defineFacet, <span class="tk-t">Translations</span> } <span class="tk-k">from</span> <span class="tk-s">"@p3/app/v1"</span>;
|
||||
<span class="tk-k">import</span> <span class="tk-k">type</span> { <span class="tk-t">MessagesOf</span> } <span class="tk-k">from</span> <span class="tk-s">"@p3/app/v1"</span>;
|
||||
<span class="tk-k">import</span> <span class="tk-k">type</span> { t <span class="tk-k">as</span> notes } <span class="tk-k">from</span> <span class="tk-s">"../notes/messages.ts"</span>; <span class="tk-c">// the notes plugin's keys, for the type check</span>
|
||||
|
||||
<span class="tk-k">const</span> notesFr: <span class="tk-t">MessagesOf</span><<span class="tk-k">typeof</span> notes> = {
|
||||
newNote: <span class="tk-s">"Nouvelle note"</span>,
|
||||
empty: <span class="tk-s">"Aucune note"</span>,
|
||||
count: (n) => (n <= <span class="tk-n">1</span> ? <span class="tk-s">`${n} note`</span> : <span class="tk-s">`${n} notes`</span>),
|
||||
saved: (title, at) => <span class="tk-s">`« ${title} » enregistrée à ${formatTime(at)}`</span>,
|
||||
};
|
||||
|
||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) => {
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Translations</span>, { namespace: <span class="tk-s">"notes"</span>, locale: <span class="tk-s">"fr"</span>, messages: notesFr });
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Translations</span>, { namespace: <span class="tk-s">"app"</span>, locale: <span class="tk-s">"fr"</span>, messages: appFr });
|
||||
});</code></pre>
|
||||
<ul>
|
||||
<li><code>MessagesOf<typeof t></code> gives a translation plugin the exact keys and signatures to fill, from the plugin it
|
||||
translates (an imported type, erased at build).</li>
|
||||
<li>A catalog may be partial: a missing key falls back to the plugin's own catalog for that language's base (<code>pt-BR</code> to
|
||||
<code>pt</code>), then to the first catalog (<code>en</code>), key by key. A fallback is reported once in the logs, so gaps are visible.</li>
|
||||
<li>Two contributions for the same namespace and locale: the first by plugin name wins, the other is reported (the
|
||||
registry rule for conflicts).</li>
|
||||
<li>A plugin's own catalogs are its built-in contributions; a translation plugin can also override one of them (a
|
||||
better German), contributed explicitly with <code>override: true</code>.</li>
|
||||
</ul>
|
||||
<h3 id="one-file-every-host">One file, every host</h3>
|
||||
<p>Registries are per host: a contribution lives on the host whose facet made it. A French catalog contributed from the
|
||||
web facet exists in browser pages only. So a plugin keeps its messages in one shared file, <code>messages.ts</code>, which every
|
||||
facet that renders text imports (the web and TUI facets; the worker's too when it builds messages with <code>t.message</code>),
|
||||
and a translation plugin keeps its catalog in one shared file too and contributes it from each facet that renders:</p>
|
||||
<pre><code><span class="tk-c">// plugins/french/fr.ts: the catalogs, written once</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> notesFr: <span class="tk-t">MessagesOf</span><<span class="tk-k">typeof</span> notes> = { newNote: <span class="tk-s">"Nouvelle note"</span>, <span class="tk-c">/* ... */</span> };
|
||||
<span class="tk-c">// plugins/french/web.ts and plugins/french/tui.ts: the same two lines each</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) => self.<span class="tk-f">contribute</span>(<span class="tk-t">Translations</span>, { namespace: <span class="tk-s">"notes"</span>, locale: <span class="tk-s">"fr"</span>, messages: notesFr }));</code></pre>
|
||||
<p>The server and workers need catalogs only when they produce final text themselves (an email, a push notification);
|
||||
then their facets contribute them too, and the text is rendered in the recipient's language from their preference.
|
||||
Catalogs never travel over links: they contain functions.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="locale"><header class="opener"><div class="numeral">III</div><h2>Choosing a Language</h2><p class="lede">Per viewer, defaulted from the device, switched live.</p></header>
|
||||
<p class="dropcap">The language is a user preference, kept with the user's other preferences (the kit's per-principal document, like
|
||||
the theme choice), so it follows a person across devices:</p>
|
||||
<ul>
|
||||
<li><strong>Default:</strong> the browser's <code>navigator.languages</code> on the web, the system locale in the TUI, matched against the
|
||||
locales any catalog offers.</li>
|
||||
<li><strong>Switching:</strong> the app's settings show the locales available (the union of all catalogs); choosing one writes the
|
||||
preference. <code>t</code> reads the current locale from a watchable; React components re-render through a hook, the TUI
|
||||
redraws through its scope. Nothing reloads.</li>
|
||||
</ul>
|
||||
<pre><code><span class="tk-c">// any web component</span>
|
||||
<span class="tk-k">const</span> { t, locale } = <span class="tk-f">useMessages</span>(notesMessages); <span class="tk-c">// re-renders when the locale or a translation changes</span></code></pre>
|
||||
<p>When a translation plugin is edited and rolled out, the registry recomposes and the open pages show the new text, as
|
||||
with themes.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="server"><header class="opener"><div class="numeral">IV</div><h2>Text from the Server</h2><p class="lede">Why the server sends keys, not sentences.</p></header>
|
||||
<p class="dropcap">The server and the workers also produce text people read: a notice ("Worker failed to start"), a tool card's
|
||||
summary, a status line, an error. If the worker formatted "Notiz gespeichert" for Lena, Tom would read German too:
|
||||
both look at the same object state.</p>
|
||||
<p>So objects carry messages as data, and each client renders them:</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/contract.ts: a member of the Notes object's state holds a message as data</span>
|
||||
lastSaved: <span class="tk-f">opt</span>(<span class="tk-f">obj</span>({ namespace: <span class="tk-f">str</span>(), key: <span class="tk-f">str</span>(), params: <span class="tk-f">arr</span>(<span class="tk-k">unknown</span>()) })),
|
||||
|
||||
<span class="tk-c">// plugins/notes/worker.ts: the object writes the key and its parameters, never a sentence; the same `t` builds it</span>
|
||||
<span class="tk-k">import</span> { t } <span class="tk-k">from</span> <span class="tk-s">"./messages.ts"</span>;
|
||||
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => {
|
||||
(<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(status)).lastSaved = t.<span class="tk-f">message</span>(<span class="tk-s">"saved"</span>, note.title, note.at); <span class="tk-c">// typed: key and arguments checked</span>
|
||||
}, ctx);
|
||||
|
||||
<span class="tk-c">// plugins/notes/web.tsx: each viewer renders it in its own language</span>
|
||||
<span class="tk-k">const</span> notes = <span class="tk-f">useHold</span>(<span class="tk-t">Notes</span>, { session });
|
||||
<span class="tk-k">const</span> text = notes.lastSaved && t.<span class="tk-f">render</span>(notes.lastSaved); <span class="tk-c">// typed against the notes catalog</span></code></pre>
|
||||
<p><code>t.message</code> returns a <code>Message<"notes"></code>, plain data <code>{ namespace, key, params }</code>, typed against the first catalog:
|
||||
<code>t.message("saved", 42)</code> and <code>t.message("svaed", ...)</code> are type errors. <code>t.render</code> takes only <code>Message<"notes"></code>. Across a
|
||||
link the contract's member is a generic message schema, so <code>t.render</code> checks the namespace and key at runtime and
|
||||
shows the key for an unknown one. The writer and the reader are type-checked; the hop between them is checked at
|
||||
runtime.</p>
|
||||
<p>The same holds for anything else that reaches people: an app's notices object stores <code>{ namespace, key, params }</code>
|
||||
per notice, a tool card's summary carries one, an error shown to a user carries a code and parameters.</p>
|
||||
<p>Parameters are plain data (strings, numbers, ISO dates), so they cross links like any state. What is never
|
||||
translated: the model's prompts and the model's own answers (the conversation's language is the user's business with
|
||||
the model), log lines (for developers, in English), error codes. Error <em>messages</em> shown to people follow the same rule
|
||||
as notices: a code plus parameters, rendered by the client.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter epilogue" id="open-questions"><header class="opener"><div class="numeral">∴</div><h2>Open Questions</h2><p class="lede">To decide.</p></header>
|
||||
<ol>
|
||||
<li><strong>Keys or English text as keys.</strong> mini-lit keys by the English text (<code>"Copy"</code>), which reads well and needs no key
|
||||
names; named keys (<code>newNote</code>) survive rewording the English. Recommendation: named keys, with the English catalog
|
||||
as the source of truth.</li>
|
||||
<li><strong>A file format for translators.</strong> Code catalogs are best for developers and agents; human translators may want
|
||||
JSON or PO files. Recommendation: no files now; a small tool can export a plugin's catalogs to JSON and import them
|
||||
back into a translation plugin when someone needs it.</li>
|
||||
<li><strong>Right-to-left languages.</strong> The page sets <code>dir</code> from the locale; the app's CSS uses logical properties
|
||||
(<code>margin-inline-start</code>). Recommendation: the kit sets <code>lang</code> and <code>dir</code> on the root; components are the app's.</li>
|
||||
<li><strong>The TUI's locale.</strong> From the system locale, overridden by the user's preference, like the web.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="chapter appendix" id="api-in-brief"><header class="opener"><div class="numeral">A</div><h2>API in Brief</h2></header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Piece</th>
|
||||
<th>Where</th>
|
||||
<th>What</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td><code>defineMessages(namespace, catalogs)</code></td>
|
||||
<td>the app's API (shared)</td>
|
||||
<td>typed <code>t(key, ...args)</code>, <code>t.render(message)</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>MessagesOf<typeof t></code></td>
|
||||
<td>the app's API (type)</td>
|
||||
<td>the keys and signatures a translation must fill</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Translations</code></td>
|
||||
<td>the app's API (shared registry)</td>
|
||||
<td><code>{ namespace, locale, messages, override? }</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>useMessages(t)</code></td>
|
||||
<td>the app's web module</td>
|
||||
<td>the hook re-rendering on locale or translation changes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>formatTime</code>, <code>formatNumber</code></td>
|
||||
<td>the app's API</td>
|
||||
<td><code>Intl</code> helpers in the viewer's locale</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>the locale preference</td>
|
||||
<td>the kit's per-principal preferences</td>
|
||||
<td>default from the device, switched live</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{ namespace, key, params }</code></td>
|
||||
<td>object state and notices</td>
|
||||
<td>a message as data, rendered by each client</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<p class="colophon">Speaking Every Language · proposal · 2026-11-02</p>
|
||||
</div></div>
|
||||
|
||||
</body></html>
|
||||
|
|
@ -14,7 +14,7 @@ checked, what a principal key is, what an error is. Pure functions, types and we
|
|||
Source: `packages/chord/src/next/contracts/` (declarations) and `packages/chord/src/next/data/` (per-call checks,
|
||||
principals, errors), split into files by concept. Everything else under `src/next/` is the frozen old kernel: never
|
||||
imported, never edited. Reused: `src/context/` (`Context`, `BACKGROUND_CONTEXT`, `context.telemetry`) and
|
||||
`src/json.ts` (`JsonValue`), both unchanged. Schemas are TypeBox 1.3 (`typebox` in `node_modules`).
|
||||
`src/json.ts` (`JsonValue`), both unchanged. Schemas were TypeBox 1.3 when L1 was built; since the schema switch (2026-11-01) they are pi-schema's plain JSON Schema (design §3 "Values"). The TypeBox details below are historical.
|
||||
|
||||
## 1. Values
|
||||
|
||||
|
|
|
|||
6
packages/chord/docs/next/p3-features/README.md
Normal file
6
packages/chord/docs/next/p3-features/README.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# p3 features (meeting prep)
|
||||
|
||||
`book.md` references screenshots and clips as `{{img:<file>|caption}}` / `{{clip:<file>|caption}}`; they were captured
|
||||
from p2 on 2026-11-02 (kept outside the repository: `/tmp/p3-prep/shots/` on slayer). Build: inline them with
|
||||
`/tmp/p3-doc/inline.py book.md book.inlined.md`, then the explainer build. Published privately on Radius
|
||||
(01m6efr04nezms4zx32p7pp0w1).
|
||||
478
packages/chord/docs/next/p3-features/book.md
Normal file
478
packages/chord/docs/next/p3-features/book.md
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
---
|
||||
title: p3: Feature Set and Work Packages (prep for the design meeting)
|
||||
cover: p3 / Features
|
||||
kicker: Meeting prep · p3 on Chord v2 · 2026-11-03
|
||||
subtitle: What p2 does today (in pictures), what p3 should ship first, how hands, sync and the plugin API fit, and a first cut into work packages
|
||||
stats:
|
||||
- 28 | feature areas in p2 today
|
||||
- 21 | model-facing tools in p2
|
||||
- 78 | coding-agent extension examples checked against the p3 plugin API
|
||||
- 12 | proposed work packages
|
||||
note: |
|
||||
Written by the redesign session for the meeting on 2026-11-03, from three research passes over p2
|
||||
(`apps/p2` at `b7af55ace`), the coding agent's extension examples and p2's environments and Radius code
|
||||
(`/tmp/p3-prep/features.md`, `extensions.md`, `envs.md`), the owner's notes and Armin's summary of the 2026-11-02
|
||||
meeting. Chord v2's slices 1-13 are built (the app kit included); slice 11 (web and TUI) is building. Everything
|
||||
marked "proposed" is for the meeting to decide.
|
||||
colophon: p3 Feature Set and Work Packages · meeting prep · 2026-11-03
|
||||
---
|
||||
|
||||
# Before we begin {.preface}
|
||||
|
||||
p2 grew feature by feature on the phone: 33 plugins, about 65,000 lines, a web shell and a TUI. p3 is its rewrite on
|
||||
Chord v2, with an app kit that already gives hosts, workers, HTTP with sessions and pairing, plugins with versioned
|
||||
APIs, files and export. The meeting's job is to decide what p3 ships first, what waits, and how the work splits.
|
||||
|
||||
Some of it was settled yesterday (Armin's summary): sessions sync to R2 through Radius by commit log, the session index
|
||||
is a replicated Chord table, every host kind loads plugins through registries, hands connect proactively. The owner
|
||||
added: voice waits; environments turn around into hands; continuing a local session on Radius is core; subagents and
|
||||
handoff are in, artifacts are questionable; the plugin API stays small enough to evolve.
|
||||
|
||||
## Ten things to know
|
||||
|
||||
:::findings
|
||||
1. **Proposed base:** sessions (with delete), the agent and conversations, subagents, history and handoff, code mode,
|
||||
shell commands, models and login, prompt resources, workspace (read and review), file index, status and notices,
|
||||
search through the index, the web shell and the TUI, hands, session sync and continue-on-Radius.
|
||||
2. **Proposed later:** voice, terminals (need channels and hands), artifacts, port forwards, chat mode and guest
|
||||
sharing, MCP and `tool_search`.
|
||||
3. **Hands replace SSH-dialled daemons:** a long-lived daemon dials the server (or Radius), is enrolled once by a
|
||||
pairing code, and carries p2's existing daemon protocol over one multiplexed WebSocket.
|
||||
4. **The sync handshake needs a driver identity.** Two machines writing one session both push "from Radius's last
|
||||
seq" and silently diverge; an epoch or lease per session (Chord's rule S7) closes it.
|
||||
5. **The index has two writers** once a session continues on Radius; three options, one recommended (derive rows from
|
||||
session stores).
|
||||
6. **Tools in base:** `read`, `write`, `edit`, `bash`, `codemode`, `subagent`, `team`, `agent`, `conversations`,
|
||||
`history_*`, location tools, `rename_session`, `send_file`; artifacts, ports and `reload_plugins` wait or change.
|
||||
7. **One CLI, no environment variables:** `p3 serve`, `p3 tui`, `p3 hand`, `p3 owner-link`, `p3 install`; settings as
|
||||
arguments and files, per the app kit.
|
||||
8. **The plugin API v1 is seven clusters,** almost all already existing: Durable's extension, a `Conversation`
|
||||
contract, input transforms, environments and models, questions, UI registries, objects for state.
|
||||
9. **Litmus test:** all 78 coding-agent extension examples are expressible; Doom needs two small fixes (frame splitting
|
||||
in the TUI bridge, a path for large plugin assets); streaming frames from a worker and raw terminal ownership do
|
||||
not fit, by design.
|
||||
10. **Twelve work packages,** with p3's skeleton on the app kit and the session/index/sync core first.
|
||||
:::
|
||||
|
||||
## Words used throughout
|
||||
|
||||
A **hand** is a daemon on a machine that connects out to a p3 server or to Radius and offers execution (commands,
|
||||
files, watching, terminals). A **driver** is the single host allowed to write a session's store at a time; a
|
||||
**replica** follows its commits. An **epoch** numbers driver changes. The **index** is the table of session metadata
|
||||
(title, status, idle state) the session list reads. The **app kit** is Chord's `startApp`/`startWorker` layer.
|
||||
|
||||
# p2 in Pictures {#pictures lede="What each feature area looks like today, on the web, the phone and in the terminal."}
|
||||
|
||||
All content is invented (a small shop project, the owner Dana, a guest), the models are a fake provider's, tools ran
|
||||
for real on a demo repository. Stills are from the web on a desktop and a phone and from the TUI; the clips loop.
|
||||
|
||||
## Sessions
|
||||
|
||||
{{img:sessions-list-desktop.webp|The sidebar: sessions grouped by directory, subagent counts, footer buttons.}}
|
||||
{{img:sessions-drawer-phone.webp|The drawer on the phone, a subagent expanded.}} {{img:sessions-create-phone.webp|A new session on the phone.}}
|
||||
{{img:sessions-create-directory-picker.webp|The directory picker for a new session: environment, path, folders, recent.}}
|
||||
{{img:sessions-options-sheet.webp|The session sheet: title, share, working directory, pin, archive.}}
|
||||
{{img:tui-session-selector.webp|The TUI's session selector, subagents threaded under their session.}}
|
||||
|
||||
## Conversations and the agent
|
||||
|
||||
{{clip:clip-streaming.mp4|Sending a question: thinking, a read card, a table and code stream in.}}
|
||||
{{img:conversation-thinking.webp|A turn with thinking expanded and read, write and edit cards.}}
|
||||
{{img:conversation-edit-diff-card.webp|An edit card with its diff.}}
|
||||
{{img:conversation-bash-card.webp|A bash card with its output, then the answer.}}
|
||||
{{img:conversation-streaming.webp|While an answer streams: the busy dot, the Steer composer, the stop button.}}
|
||||
{{img:conversation-transcript-phone.webp|The transcript on the phone.}} {{img:conversation-model-picker-phone.webp|The model sheet on the phone.}}
|
||||
{{img:conversation-cost-sheet.webp|The cost sheet: totals, cost and cache hits per response.}}
|
||||
{{img:tui-transcript-tools.webp|The same agent in the TUI: thinking, tools, the answer, editor and footer.}}
|
||||
|
||||
## Subagents, history and handoff, code mode
|
||||
|
||||
{{img:subagents-spawn-and-report.webp|A spawned subagent in the parent's transcript, and its report.}}
|
||||
{{img:subagents-conversation.webp|The subagent's own conversation, selected in the sidebar.}}
|
||||
{{img:history-handoff-card.webp|A handoff: the continuation text, then a new context.}}
|
||||
{{img:history-search-card.webp|history_search with its hits.}}
|
||||
{{img:codemode-card.webp|A code mode script with three nested tool calls.}}
|
||||
|
||||
## Shell commands and chat mode
|
||||
|
||||
{{img:shell-command-tui.webp|A shell command run from the TUI with its output (the web can only show it).}}
|
||||
{{img:chatmode-two-people.webp|Chat mode: owner and guest talk, an @pi mention gets a digest and an answer.}}
|
||||
|
||||
## Sharing, devices, providers
|
||||
|
||||
{{img:sharing-sheet.webp|The share sheet: read-only and read/write links, a connected guest.}}
|
||||
{{img:sharing-join-page.webp|Joining through a share link.}} {{img:sharing-guest-view-phone.webp|The session as the guest sees it.}}
|
||||
{{img:devices-sheet.webp|The devices sheet (the one-time token masked).}}
|
||||
{{img:providers-login.webp|A provider's login methods.}}
|
||||
|
||||
## Workspace, @ completion, terminals
|
||||
|
||||
{{clip:clip-workspace-review.mp4|Changes, a diff, a line comment, Send all; the agent answers the review.}}
|
||||
{{img:workspace-files.webp|Files: tree, sizes, upload.}}
|
||||
{{img:workspace-changes.webp|Changes: branch, uncommitted files, commits.}}
|
||||
{{img:workspace-search.webp|Text search across the project.}}
|
||||
{{clip:clip-at-completion.mp4|@ completion in the composer.}}
|
||||
{{img:terminals-web.webp|A web terminal panel with tabs and a key row.}}
|
||||
{{img:tui-slash-changes-diff.webp|The TUI's /changes diff (read-only).}}
|
||||
|
||||
## Environments, artifacts, attachments, search
|
||||
|
||||
{{img:environments-panel.webp|The environments sheet: SSH environments and the ports entry.}}
|
||||
{{img:artifacts-preview.webp|An artifact in the sandboxed preview.}}
|
||||
{{img:attachments-message.webp|A message with an image attachment.}}
|
||||
{{img:search-results.webp|Sidebar search: message hits for "rounding".}}
|
||||
|
||||
## Status, notices, logs, voice, ports
|
||||
|
||||
{{img:status-footer-viewers.webp|The composer footer: model, viewers, cache hit rate, cost, context.}}
|
||||
{{img:notices-owner-notice.webp|An owner notice after a worker was killed, with Show logs.}}
|
||||
{{img:logs-sheet.webp|The logs sheet following the server's log.}}
|
||||
{{img:voice-sheet.webp|The voice sheet (later in p3).}}
|
||||
{{img:ports-panel.webp|The ports panel without Radius (later in p3).}}
|
||||
|
||||
## The web shell and the TUI
|
||||
|
||||
{{clip:clip-phone-sheets.mp4|The phone: drawer, switching sessions, the session sheet, the share sheet, Back.}}
|
||||
{{img:webshell-phone-sheet-2-directory.webp|The sheet stack on the phone: a second sheet pushed on the first.}}
|
||||
{{clip:clip-tui-palette.mp4|The TUI's F1 palette: filter, run a command, the model selector.}}
|
||||
{{img:tui-slash-commands.webp|/ command completion in the TUI.}}
|
||||
{{img:tui-theme-picker.webp|The TUI's theme picker with preview.}} {{img:tui-theme-light.webp|The light theme.}}
|
||||
|
||||
Not shown: clone and fork (TUI only, not run), SSH environments connected (no network in the capture), Radius and
|
||||
public ports (off), voice in use (no microphone), dev mode reload pills, provider OAuth flows.
|
||||
|
||||
# p2 Today, and the Proposed Cut {#features lede="Every feature area, what it is, and where it goes in p3."}
|
||||
|
||||
Sizes are non-test lines in p2. "Base" means p3's first release; "later" means designed for, built after; "drop" means
|
||||
not carried over.
|
||||
|
||||
| Area | What it does | p2 size | Proposed |
|
||||
| --- | --- | ---: | --- |
|
||||
| Sessions | list by machine and directory, create, rename, pin, archive, relocate, clone, fork, unread, automatic titles | 2,186 | **base**, plus delete and in-session branches (`/tree`); clone and fork in the web too |
|
||||
| Conversations and agent | transcript, send, steer, follow-up, outbox, stop, model and thinking, cost, compaction | 3,682 + web 2,700 | **base** |
|
||||
| Subagents, one-shot agents, cross-session messages | named background agents, `team`, `agent` from scripts, `conversations` | 2,182 | **base** (owner: "awesome") |
|
||||
| History and handoff | re-read earlier entries after compaction, hand off with a continuation | 533 | **base** |
|
||||
| Code mode | scripts calling tools as nested durable calls | 155 + Durable | **base** (small, Durable owns it) |
|
||||
| Shell commands | `!cmd` and `!!cmd` in the conversation's environment | in chat | **base**, web too (TUI only today) |
|
||||
| Chat mode | people talk without a turn, `@pi` mentions | 876 | later |
|
||||
| Sharing and guests | share links, guest names, revocation | 1,047 | later (the app kit's users and invites cover the start) |
|
||||
| Devices and owner login | owner links, device cookies | 684 | **base**, replaced by the app kit's sessions and pairing |
|
||||
| Models, credentials, provider login | picker, scope, API keys, OAuth flows | 3,828 | **base** |
|
||||
| Settings | `settings.json`, hot reload | 87 | **base** |
|
||||
| Prompt resources | AGENTS.md, skills, templates, read in the session's environment | 958 | **base** |
|
||||
| Workspace | files, git status, diffs, search, review comments | 2,655 + web 1,341 | **base** (read and review; uploads later) |
|
||||
| File index and `@` | ranked file completion | 242 | **base** |
|
||||
| Terminals | detached PTYs, ghostty-vt in the web | 3,518 | later (channels, hands) |
|
||||
| Environments and locations | SSH environments, directory picker, agents moving themselves | 2,546 | **base, redesigned as hands** |
|
||||
| Artifacts and files | attachments, `send_file`, versioned artifacts with previews | 1,713 | attachments and `send_file` base (app kit `Files`); artifacts later |
|
||||
| Search | sidebar search over titles and messages (FTS5) | 775 | **base** over the index; message text search through the same sync mechanism later |
|
||||
| Status and presence | busy, tasks, usage, viewers, worker failures | 566 | **base** (feeds the index) |
|
||||
| Logs and notices | owner log viewer, notices | 487 | **base** |
|
||||
| Voice | rooms, barge-in, speech service | 3,770 + Python | later (owner) |
|
||||
| Radius relay and port forwards | brain WebSocket, up to 50 forwards | 2,000 | relay **base** (as the hands/sync link), forwards later |
|
||||
| Web shell | PWA, sheets, navigation, reconnect | 9,252 | **base**, rebuilt on slice 11 ("look and feel great") |
|
||||
| TUI | launcher, sandbox, 40 commands, themes, keybindings | 6,720 + launcher | **base** on slice 11 |
|
||||
| Dev and reload | supervisor, dirty marks, reload tool | 468 + scripts | **base** as the app kit's "apply changes"; the supervisor goes |
|
||||
| Fake provider, `PI_MOBILE_*` aliases, `P2_DEV_RESUME` | test and legacy leftovers | 218 | drop |
|
||||
| `tool_search`, MCP | not installed in p2 | - | later |
|
||||
|
||||
Rough edges p3 should not inherit: features only in the TUI (shell commands, clone, fork, compact, new context), no
|
||||
session delete, terminals and voice without a TUI side, docs that drifted from code (voice routes, TUI notes), the
|
||||
plugin loader's module leak on reload (solved by Chord's plugin component).
|
||||
|
||||
# Hands {#hands lede="Environments turned around: the machine connects to the server, not the other way."}
|
||||
|
||||
## What p2 does
|
||||
|
||||
p2's server and workers start a daemon (`pi-env`) over SSH on demand, one connection per session and per use (four
|
||||
connection pools: each worker, the directory picker, the connection test, the terminal host). The daemon is
|
||||
stateless and kills its commands 30 seconds after its connection goes quiet. Its frame protocol (exec, files, watch,
|
||||
file listing, PTYs) does not depend on SSH.
|
||||
|
||||
## What a hand is (proposed)
|
||||
|
||||
| | p2's daemon | hand |
|
||||
| --- | --- | --- |
|
||||
| who dials | the server, over SSH | the hand, over WSS, to the server or to Radius |
|
||||
| needs | sshd, a key, a network path to the machine | outbound HTTPS only |
|
||||
| lifetime | per connection | a service (systemd, launchd, Termux:Boot), reconnecting |
|
||||
| install | deployed and verified at each start | installed once, signed updates |
|
||||
| machine identity | SSH host key | a hand key pair, enrolled once |
|
||||
| connections | one per session and use | one per hand and server, multiplexed |
|
||||
|
||||
- **Enrollment:** `p3 hand enroll <server>` shows a code; the owner approves it in the UI (the app kit's device
|
||||
pairing); the server pins the hand's public key; fingerprints shown on both sides. Revocation in a Hands panel.
|
||||
- **Reachability:** a hand reaches the owner's own server directly (LAN, own tunnel) or through Radius, which relays
|
||||
hands to whichever host drives a session (the upsell: no tunnel to set up; Radius can also offer sandboxed machines
|
||||
and hands of its own).
|
||||
- **Protocol:** p2's daemon protocol over the WebSocket, unchanged in shape; two changes: several sessions share one
|
||||
connection (cancellation, handles and backpressure per session), and a disconnect starts a grace period instead of
|
||||
killing everything (detached commands and PTYs reattach on reconnect; this moves the terminal host's job into the
|
||||
hand).
|
||||
- **Binding:** a session's location becomes `{ hand, cwd }`; one server object holds the hand connections and the
|
||||
`HarnessEnvironments` entry resolves a hand id to an execution environment; workers reach hands through the server's
|
||||
links. An SSH machine stays possible as a hand kind the server dials.
|
||||
- **Offline:** an operation needing an offline hand fails with a clear error the model sees; the hand's status shows
|
||||
in the session list.
|
||||
|
||||
# Session Sync and the Index {#sync lede="What was decided yesterday, and the three gaps it leaves."}
|
||||
|
||||
## Decided
|
||||
|
||||
The worker updates the session index from events and sends it to the server; sessions sync to R2 through Radius:
|
||||
Radius reports per session its last sequence number, pi uploads the commit log from there; R2 holds the SQLite file
|
||||
and the commit log, a SQLite Durable Object applies them; sessions have an owner id; the index is a Chord table
|
||||
replicated like a session, its row updated only on metadata, session idle and subagent idle changes.
|
||||
|
||||
Chord v2 has the pieces: a versioned outbox written in each commit's transaction, export archives, batch apply that
|
||||
refuses gaps and divergence, export and import per address (app kit). Missing (the sync layer): upload and trim,
|
||||
epochs, pull-back after a power loss, the split, starting from an archive.
|
||||
|
||||
## Gap 1: who is the driver
|
||||
|
||||
Two machines both write session X (the laptop resumed it while Radius continued it). Both ask Radius for its last
|
||||
seq, both upload from there; Radius applies whichever arrives first and refuses the other as "not following". The
|
||||
copies have now diverged and nobody knows.
|
||||
|
||||
**Proposed:** a driver epoch per session, handed out by Radius. An upload names its epoch; an old epoch is refused;
|
||||
the refused copy becomes a new session (Chord's rule S7: a split, never a rollback, because tools already ran).
|
||||
"Continue on Radius" is an epoch change: the local driver waits for idle, uploads the tail and the session's files,
|
||||
Radius bumps the epoch and starts the agent from its store, the local side becomes a replica or closes. Back is the
|
||||
reverse.
|
||||
|
||||
## Gap 2: two writers of the index
|
||||
|
||||
When a session continues on Radius, Radius writes its index row, and so does the local server.
|
||||
|
||||
| Option | How | Cost |
|
||||
| --- | --- | --- |
|
||||
| a. one index per writer | each host keeps its own index, Radius merges per owner, newest epoch wins per row | a second merge rule |
|
||||
| b. rows derived from session stores | every host computes a session's row from the session's own commits | no index sync at all; each host needs the session's commits |
|
||||
| c. rows tagged `{ epoch, seq }` | Radius keeps the highest per row | rows must carry their source position |
|
||||
|
||||
**Recommended:** (b) where the session's commits are present (Radius has them all), with (c)'s tag on the rows a local
|
||||
server writes for sessions it drives, so a list without the session store still sorts correctly.
|
||||
|
||||
## Gap 3: what else moves
|
||||
|
||||
Files outside the store (uploads, sent files, the app kit's `Files`) must sync as blobs before the batches naming
|
||||
them, R2 as their home. Model access and secrets for a session continued on Radius: Radius's own models, or the
|
||||
owner's provider keys stored on Radius (decide). Local prompt resources (the device's `AGENTS.md`, skills) must be read
|
||||
through a hand or synced. Closed sessions have no worker: the server reads a session's outbox without starting it, or
|
||||
starts it to sync.
|
||||
|
||||
## The sync example
|
||||
|
||||
`packages/chord/test/next/examples/60-session-sync/` (uncommitted, in `/home/badlogic/pi-sync-ex`) plays the decided
|
||||
protocol with Chord's real store pieces (the outbox, `applyBatch`, export archives) and a small in-memory stand-in for
|
||||
Radius. Its output, step by step:
|
||||
|
||||
```text
|
||||
1. laptop s1: lastSeq 4 outbox floor 0
|
||||
2. sync: uploaded 1..4, Radius at 4, trimmed through it
|
||||
3. laptop s1: lastSeq 6 - sync: uploaded 5..6, Radius at 6, trimmed through it
|
||||
4. laptop s2: lastSeq 3 outbox floor 3 - sync: archive uploaded (737 bytes), Radius imported it at 3
|
||||
laptop s2: lastSeq 4 - sync: uploaded 4..4
|
||||
5. Radius takes over, epoch 2; Radius writes seq 7
|
||||
stale laptop: lastSeq 7 - push: refused for an old epoch: this copy is now s1~split@6
|
||||
5b. no epoch check - stale push: refused: does not follow seq 7
|
||||
stale sync: nothing to send, Radius at 7 - outbox floor 7 (its own commit 7 trimmed)
|
||||
stale: ... 5 assistant: (stale laptop) added a different test
|
||||
Radius: ... 5 assistant: (on Radius) added a test (same seq, different data)
|
||||
6. laptop takes epoch 3, pulls 7..7: same values: true; its next commit syncs at epoch 3
|
||||
```
|
||||
|
||||
:::sequence animate | The decided handshake (steps 1-3), then continuing on Radius (5) and back (6).
|
||||
actor laptop "laptop" "driver, epoch 1" accent
|
||||
actor radius "Radius" "replica per session"
|
||||
laptop -> radius: which sessions do you have?
|
||||
radius --> laptop: s1 at seq 4, epoch 1
|
||||
laptop => radius: commits 5..6 (epoch 1)
|
||||
radius --> laptop: s1 at seq 6 (acknowledged: trim through 6)
|
||||
divider "continue on Radius"
|
||||
radius -> radius: epoch 2, Radius drives, writes seq 7
|
||||
laptop -> radius: commit 7 (epoch 1)
|
||||
radius --> laptop: refused: old epoch (the copy splits)
|
||||
divider "back to the laptop"
|
||||
laptop -> radius: take epoch 3
|
||||
laptop => radius: pull 7..7, apply
|
||||
:::
|
||||
|
||||
What it shows: the handshake works and only the tail moves; a session Radius lacks starts from an archive; without
|
||||
epochs a second writer's commit is lost silently (5b), with them it splits; taking a session back is a pull plus an
|
||||
epoch. One more finding: `applyBatch` refuses while a host holds the store open, so the sync layer stops the session's
|
||||
worker, applies, restarts it (decided 2026-11-02: usually nothing runs at that moment). The example lists nine things
|
||||
its stand-ins fake that the sync layer must build (authenticated handshake, persisted epochs, the batch wire format,
|
||||
retries, streaming bootstrap with files, the DO and R2 side, the continue fence, the split's new id, pull-back after a
|
||||
power loss).
|
||||
|
||||
# Branches and Subagent Context {#branches lede="pi's /tree, fork and clone, and subagents that start from the parent's context."}
|
||||
|
||||
pi branches three ways; p2 has two, in the TUI only:
|
||||
|
||||
| pi | What it does | p2 |
|
||||
| --- | --- | --- |
|
||||
| `/tree` | branch inside the session: pick an earlier message, edit and resubmit; the old branch stays; leaving a branch can summarize it into the one entered | missing |
|
||||
| `/fork` | a new session from an earlier user message | TUI only, main conversation only |
|
||||
| `/clone` | a copy of the active branch as a new session | TUI only |
|
||||
|
||||
Durable already has the building block: `conversation.fork(at)` creates a conversation in the same session store that
|
||||
shares every entry up to `at`, without copying. Proposed for p3:
|
||||
|
||||
- **Base:** editing an earlier message forks the conversation there (an in-session branch); a branch list switches
|
||||
between them; fork and clone to a new session; all in the web and the TUI.
|
||||
- **Later:** a full tree navigator and branch summaries.
|
||||
- **Index and sync:** branches are conversations inside one session store, so sync carries them for free; the index
|
||||
marks the active branch and tells branches from subagents.
|
||||
|
||||
**Subagents with the parent's context** (owner, 2026-11-02): `subagent({ action: "spawn", ..., context: "inherit" })`
|
||||
forks the parent conversation at the last entry before the assistant message holding the spawning call. The child
|
||||
knows everything the parent knew; its model, tools and location may differ; with the same model the shared prefix is
|
||||
usually a prompt-cache hit, otherwise it is paid in full (the tool's description says so). Base in p3.
|
||||
|
||||
# Tools Built In {#tools lede="p2's 21 model-facing tools, and which ship with p3."}
|
||||
|
||||
| Tool | p2 plugin | Proposed |
|
||||
| --- | --- | --- |
|
||||
| `read`, `write`, `edit`, `bash` | coding-tools (Durable) | base |
|
||||
| `powershell` | powershell | base (deferred tool, Windows hands) |
|
||||
| `codemode` | codemode | base |
|
||||
| `agent` (from scripts) | agent | base |
|
||||
| `subagent`, `team` | subagents | base |
|
||||
| `conversations` | conversations | base |
|
||||
| `history_search`, `history_read`, `history_handoff` | history | base |
|
||||
| `location_envs`, `location_dirs`, `location_move` | locations | base, renamed around hands (`hands`, `hand_dirs`, `move`) |
|
||||
| `rename_session` | sessions | base |
|
||||
| `send_file` | send-file | base (on the app kit's `Files`) |
|
||||
| `artifact` | artifacts | later (owner: maybe not as useful) |
|
||||
| `ports` | ports | later (with forwards) |
|
||||
| `reload_plugins` | dev-tools | base as an "apply changes" tool for the owner, not tied to a checkout |
|
||||
| `tool_search` | Durable | later (with MCP) |
|
||||
|
||||
Every tool is a plugin in the app's own root, so any of them can be removed, replaced or added without touching base
|
||||
code (the app kit's rule: base code is the API, bootstrap and a start-data facet).
|
||||
|
||||
# Running p3 {#running lede="Starting the server, the CLI, themes and keybindings."}
|
||||
|
||||
## The CLI (proposed)
|
||||
|
||||
One binary, `p3`, and no environment variables (app kit decision); settings are arguments or files in the data
|
||||
directory.
|
||||
|
||||
| Command | Does |
|
||||
| --- | --- |
|
||||
| `p3 serve [--data-dir <dir>] [--host <addr>] [--port <n>]` | starts the server (`startServer`); prints the one-time owner link |
|
||||
| `p3 owner-link` | prints a new owner link for a running server (through the owner token file) |
|
||||
| `p3 tui [url] [--pair]` | the sandboxed TUI; on the same machine it uses the owner token file, elsewhere device pairing |
|
||||
| `p3 hand enroll <url>`, `p3 hand run` | enrolls this machine as a hand, runs it (or installs a service) |
|
||||
| `p3 radius login` | signs the server into Radius (device code) |
|
||||
| `p3 install <git:...|npm:...>` | installs a plugin into the user's root |
|
||||
| `p3 export <session> <file>`, `p3 import <file>` | the app kit's `exportData`/`importData` (a running session is not stopped) |
|
||||
|
||||
The data directory defaults to `~/.p3`. Its files: `settings.json`, `models.json`, `auth.json` (locked),
|
||||
`AGENTS.md`, `skills/`, `prompts/`, `plugins/` (the user's root), the server store, a directory per session worker,
|
||||
`owner-token`, `logs/`. Plugin-specific configuration moves into plugin objects (documents) instead of more files.
|
||||
|
||||
## Themes and keybindings
|
||||
|
||||
Themes follow the looks-and-themes proposal (decided 2026-11-02): design tokens as CSS variables on the web, roles in
|
||||
the TUI, one `Themes` registry plugins contribute to, one dark and one light choice per user. Keybindings: commands are
|
||||
registry entries with default keys (p2's `TuiCommands` with `when` conditions, 78 binding ids); the user's overrides
|
||||
live in a preferences document (per user, synced like any data), not in files on each client; the web gets the same
|
||||
command registry with its own key handling.
|
||||
|
||||
# The Plugin API {#api lede="The smallest surface that still expresses everything the coding agent's extensions do."}
|
||||
|
||||
## The rule
|
||||
|
||||
Offer few things, each stable: what an API offers, it must keep (versioned in the import path, `@p3/app/v1`). Every
|
||||
cluster below is a registry or a contract that already exists in Chord, Durable or p2; p3 v1 publishes those and
|
||||
nothing else. Legacy concepts of the old extension API are not carried: CLI flags, an event bus, RPC-mode UI, `hasUI`
|
||||
checks, "destructive switch" gates, `pi.exec` running locally regardless of the environment.
|
||||
|
||||
## Seven clusters
|
||||
|
||||
| Cluster | Host | Mechanism | Covers |
|
||||
| --- | --- | --- | --- |
|
||||
| A. The agent's turn | worker | Durable's `Extension` in `HarnessExtensions`: tools, `beforeTool`/`afterTool`, `beforeRequest`, sections, wraps, `onYield`, `afterRun`, `beforeCompact` | 20 examples: hello, permission-gate, protected-paths, tool-override, plan-mode, todo, custom-compaction, ... |
|
||||
| B. Conversation control | worker object | a `Conversation` contract: `submit`, `configure`, `append` (typed entries), `compact`, `fork`, `rename`; state: busy, agent, usage | send-user-message, file-trigger, session-name, preset, handoff, qna, ... |
|
||||
| C. Input | worker | `InputTransforms` registry (rewrite or answer input before the model) | input-transform, inline-bash |
|
||||
| D. Environments and models | worker, server | `HarnessEnvironments` (ssh, gondolin, hands as kinds), `HarnessModels` (providers, virtual models), `LoginProviders` | ssh, gondolin, custom providers, jev-router |
|
||||
| E. Asking the user | server object, UI renderers | `Questions.ask` from worker code; `QuestionKinds` per UI kind | permission-gate, question, questionnaire, timed-confirm, project-trust |
|
||||
| F. UI | tui, web | registries: `Commands` (slash, palette, keys), `ToolRenderers`, `EntryRenderers`, `Slots` (header, footer, status, widgets), overlays through the UI context, editor extension points | every UI-only example, the games |
|
||||
| G. State and standing behaviour | any | `local` objects owning natives, documents for state | file-trigger's watcher, gondolin's VM, game saves |
|
||||
|
||||
```ts
|
||||
// a plugin with a tool, a gate and a renderer: plugins/todo/
|
||||
// worker.ts
|
||||
self.contribute(HarnessExtensions, { name: "todo", tools: [todoTool], hooks: [hook(ToolTask, { beforeTool: guard })] });
|
||||
// tui.ts
|
||||
self.contribute(TuiToolRenderers, { todo: { call: (args) => todoCall(args), result: (r) => todoList(r.details) } });
|
||||
self.contribute(TuiCommands, { "todo.show": { slash: { name: "todos" }, run: (ui) => ui.overlay("todos", new TodoView(ui)) } });
|
||||
// web.tsx: the same registries with React components
|
||||
```
|
||||
|
||||
## The litmus test
|
||||
|
||||
All 78 examples map onto the seven clusters (table in `/tmp/p3-prep/extensions.md`). The examples that mix agent and
|
||||
UI split into a worker facet and a tui/web facet sharing a contract file: a tool that wants a dialog asks the
|
||||
`Questions` object, which works from any client, several clients or none.
|
||||
|
||||
- **Snake, Space Invaders, tic-tac-toe:** a command plus an overlay in `tui.ts` and a canvas in `web.tsx`, engine in a
|
||||
shared file, saves in a user-keyed document. Fit today.
|
||||
- **Doom:** fits with two fixes in the TUI: the sandbox bridge caps a frame at 64 KiB and a Doom frame is about 160 KB
|
||||
(split writes at escape boundaries), and the sandbox has no files or network, so large plugin assets (a 4 MB WAD)
|
||||
need a documented "asset of my plugin" path (chunked reads through the plugin's server facet).
|
||||
- **Does not fit, by design:** streaming 35 FPS frames from a worker to clients (objects carry state; needs channels),
|
||||
engine state surviving an in-place reload (needs an explicit save), raw terminal ownership (`interactive-shell`
|
||||
becomes a terminal view; terminal notifications need a launcher action).
|
||||
|
||||
## What v1 leaves out
|
||||
|
||||
Editor replacement (extension points only), raw provider stream events and response headers, a fork hook, a
|
||||
`turn_start` hook, an event bus. Each has one or two examples; add when a real plugin needs it.
|
||||
|
||||
# Work Packages {#packages lede="A first cut, in dependency order, for the meeting to rearrange."}
|
||||
|
||||
| # | Package | Contains | Depends on |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | p3 skeleton | `startServer` with p3's API v1 modules (shared, web, tui, worker), core plugins in the app's root, `p3` CLI | Chord slices 11-13 |
|
||||
| 2 | Sessions and conversations | Durable on the Chord store, session lifecycle with delete, the conversation contract, subagents, history and handoff, code mode, shell commands | 1 |
|
||||
| 3 | Session index | the index table, worker-to-server updates on metadata and idle changes, the session list, search over it | 2 |
|
||||
| 4 | Session sync to Radius | outbox upload and trim, epochs, pull-back, split, files as blobs, continue on Radius and back | 2, 3, Radius side |
|
||||
| 5 | Radius side | per-session SQLite DO, commit log and SQLite on R2, epochs, the sync handshake, hand relay | with 4 |
|
||||
| 6 | Hands | daemon as an outbound service, enrollment by pairing, multiplexed protocol, reattach after disconnects, server hand pool, `HarnessEnvironments` kind, SSH as a hand kind | 1 |
|
||||
| 7 | Web shell | p2's layout and flows rebuilt on slice 11 ("look and feel great"), themes | 1, slice 11 |
|
||||
| 8 | TUI | p2's TUI on slice 11's sandbox, frame splitting, plugin assets path, keybindings and themes | 1, slice 11 |
|
||||
| 9 | Models and settings | catalog, credentials, provider login flows, settings, prompt resources | 1 |
|
||||
| 10 | Workspace | files, git, diffs, search, review comments, file index and `@`, through hands | 6 |
|
||||
| 11 | Plugin API v1 and docs | the seven clusters published in `@p3/app/v1`, authoring guide, ported examples as the litmus (hello, permission-gate, todo, plan-mode, snake, doom) | 1, 2, 7, 8 |
|
||||
| 12 | Access and status | owner, devices and pairing (app kit), notices, logs, status and presence | 1 |
|
||||
|
||||
Later: voice, terminals (channels and hands), artifacts, port forwards, chat mode and guest sharing, MCP and
|
||||
`tool_search`.
|
||||
|
||||
# Open Questions {.epilogue lede="To decide in the meeting."}
|
||||
|
||||
1. The proposed cut: base versus later per area (first table).
|
||||
2. Driver identity: epochs per session, handed out by Radius, with splits on conflict?
|
||||
3. The index with two writers: derived rows plus `{ epoch, seq }` tags, or one of the other options?
|
||||
4. Who syncs a closed session (the server reading its outbox, or starting its worker)?
|
||||
5. Files outside the store as blobs on R2, uploaded before their batch?
|
||||
6. Models and secrets for sessions continued on Radius: Radius's own models, or the owner's keys stored there?
|
||||
7. Hands: through Radius as a relay to the driver, and may one hand serve the local server and Radius at once?
|
||||
8. Do hands keep commands and terminals alive across disconnects (absorbing p2's terminal host)?
|
||||
9. Hand install and updates: signed binary, service install, version negotiation.
|
||||
10. Is SSH kept as a hand kind for machines without a service?
|
||||
11. Plugin API v1: the seven clusters as listed, and what is explicitly left out?
|
||||
12. Work package owners and order.
|
||||
|
||||
# Sources {.appendix}
|
||||
|
||||
| Source | What |
|
||||
| --- | --- |
|
||||
| `/tmp/p3-prep/features.md` | p2's feature areas with files, sizes, tools, rough edges; the CLI, environment variables and data files |
|
||||
| `/tmp/p3-prep/extensions.md` | all 78 extension examples, the capability matrix, the clusters with sketches, the hard cases |
|
||||
| `/tmp/p3-prep/envs.md` | p2's environments and Radius link, Chord's sync pieces, the sync and hands sketches, open questions |
|
||||
| `apps/p2/docs/p3-functions/` | the functionality map (partly outdated) |
|
||||
| `packages/chord/docs/next/` | design, decisions (2026-11-02 meeting summary), app kit and plugin model, theming |
|
||||
176
packages/chord/docs/next/plugin-authoring.md
Normal file
176
packages/chord/docs/next/plugin-authoring.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# Writing a Chord plugin
|
||||
|
||||
For agents and people writing plugins for an app built on Chord v2. Self-contained: everything a plugin author needs
|
||||
is here. In the examples the app is "Acme", its API package `@acme/app`; replace it with your app's (its docs name the
|
||||
package and the versions it provides).
|
||||
|
||||
## What a plugin is
|
||||
|
||||
A plugin is a feature of a running app: it serves objects (state plus methods) and contributes to the app's
|
||||
registries (routes, panels, tools). The app loads it on its hosts (the server, its workers, browser pages, terminal
|
||||
clients), reloads it when it is edited, and leaves it out alone when it is broken. Plugins talk to each other only
|
||||
through object contracts and registries.
|
||||
|
||||
## Layout
|
||||
|
||||
A **directory plugin**, in one of the app's plugin roots:
|
||||
|
||||
```text
|
||||
plugins/stats/
|
||||
package.json { "name": "@bob/stats", "dependencies": { ... } } required; the name is the plugin's
|
||||
server.ts the server facet one file per host kind the app has, each optional:
|
||||
worker.ts the worker facet <kind>.ts, .tsx, .js or .mjs; default export: a facet
|
||||
web.tsx the web (page) facet
|
||||
tui.ts the terminal client facet
|
||||
contract.ts contracts every kind may import (data only)
|
||||
contract.web.tsx contracts and tokens for web facets only (`contract.<kind>.ts(x)` per kind)
|
||||
*.ts, *.css, assets/ plain files of the plugin
|
||||
```
|
||||
|
||||
- `package.json`: `name` (an npm package name, scoped or not) is the plugin's name; `dependencies` are bundled into
|
||||
its scripts; `"chord": { "external": ["better-sqlite3"] }` keeps named packages external in Node facets (native
|
||||
addons).
|
||||
- A **package of several plugins** (a repository holding them) lists their folders in its own `package.json`:
|
||||
`"chord": { "plugins": ["./notes", "plugins/*", "!plugins/draft"] }` (paths, globs and `!` exclusions; `.` is the
|
||||
package itself; a listed folder inside another listed one is refused). Without the field, the package is one plugin
|
||||
when its top has facet files, else **every** subfolder with a `package.json` is a plugin: a repository with nested
|
||||
packages that are no plugins (a helper library, examples, fixtures) must list its plugins in `chord.plugins`.
|
||||
- A plugin with no facet file holds contracts others import.
|
||||
- A **single-file plugin** is a file at a root's top named `<name>.<kind>.ts` (or `.tsx`, `.js`, `.mjs`):
|
||||
`hello.server.ts` and `hello.web.tsx` are the plugin `hello` with two facets. No `package.json`, so no
|
||||
dependencies; it imports only the app's API and defines its contracts inline. When it needs more, make it the
|
||||
directory `hello/` with a `package.json` named `hello`: the app treats it as the same plugin.
|
||||
|
||||
## A facet
|
||||
|
||||
```ts
|
||||
// plugins/stats/worker.ts
|
||||
import { defineFacet, int, local, str } from "@acme/app/v3";
|
||||
import { Indexers } from "@acme/app/v3/worker";
|
||||
import { Notes } from "../notes/contract.ts";
|
||||
|
||||
const Stats = local("bob.stats", { version: 1, params: { project: str() }, members: { words: int() } });
|
||||
|
||||
export default defineFacet((host, self) => {
|
||||
self.contribute(Indexers, { name: "word count" }); // a registry entry while the plugin is loaded
|
||||
host.serve(Stats, ({ project }, self) => { // a factory, run per object
|
||||
const notes = self.get(Notes, { project }); // a dependency: Stats waits until Notes is live
|
||||
return { words: self.derive([notes.text], ([text]) => text.split(/\s+/).filter(Boolean).length) };
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
A facet only declares (`host.serve`, `self.contribute`, `host.references`), synchronously. Everything at runtime
|
||||
happens in objects: state in documents and tables, standing behaviour in a `local` object with empty params (it lives
|
||||
as long as the host). Names are prefixed by the plugin (`bob.stats`).
|
||||
|
||||
## What a plugin may import
|
||||
|
||||
| Import | Resolves to |
|
||||
| --- | --- |
|
||||
| its own files (`./label.ts`, `./icon.svg?url` in web facets) | inlined into its script |
|
||||
| the app's API, a version in the path: `@acme/app/v3`, its kind's module `@acme/app/v3/worker` | the app's own instance (a host package), never copied |
|
||||
| another plugin of the same root (one repository): `../notes/contract.ts` | inlined (a copy, frozen at this build) |
|
||||
| a package in `dependencies`: `@bob/notes`, `@bob/notes/web` | inlined from `node_modules` at the installed version, through the package's `exports` |
|
||||
| a plugin inside a dependency on a whole repository of several: `@bob/plugins/notes/contract.ts` | inlined, as any package file |
|
||||
| a `chord.external` package, a Node builtin (Node facets) | loaded when the script runs |
|
||||
| `pluginFile` from `@earendil-works/chord/next/plugins` | the absolute path of a file of this plugin |
|
||||
|
||||
Refused at build, each with the file, line and rule:
|
||||
|
||||
- `@acme/app/v1` when the app does not list v1 ("@acme/app/v1 is not available: this app provides v3, v2");
|
||||
another kind's module (`@acme/app/v3/web` from a worker facet); two API versions in one facet.
|
||||
- Chord itself (`@earendil-works/chord/next`): the app's API re-exports what plugins may use.
|
||||
- A relative path leaving the plugin's root (base code, another root); a file of a directory that is no plugin.
|
||||
- A file copied from another plugin importing anything but the API and its own plugin's files.
|
||||
- A per-kind file (`contract.web.tsx`, `contract.Web.ts` alike) or another kind's facet file (`web.tsx`) reached from
|
||||
a facet of another kind, directly or through other files (the error names the chain). Files inside npm packages are
|
||||
checked only when the package is plugin code (its `package.json`, or one above it in the package, has a `chord`
|
||||
field); ordinary libraries name files as they like (`pdf.worker.mjs`).
|
||||
- A package file whose real path is outside the plugin's own `node_modules` (a `file:` dependency linked elsewhere, a
|
||||
package importing `../../src`).
|
||||
- A copied file importing a Node builtin or a package.
|
||||
- A package not in `dependencies`; `import.meta`, `__dirname`, `__filename`, a literal `import("x")`, top-level
|
||||
`await` (read files through `pluginFile`).
|
||||
- In a single-file plugin, anything but host packages.
|
||||
|
||||
Contract files mention native things (React components, functions) only through `import type`; at runtime they import
|
||||
only the API and the plugin's own data-only files.
|
||||
|
||||
## Contracts and versions
|
||||
|
||||
A plugin carries the contract it was written against. Plugins changing together (one root, one repository) import
|
||||
each other's contract files by relative path. A plugin from another repository takes the contracts as a dependency
|
||||
pinned to a version: an npm version, or a git ref (`"@bob/notes": "github:bob/notes#<commit>"`, `"git+https://...#v2"`).
|
||||
The app's own contracts for third parties come with its API (`@acme/app/v3` exports `Notes`). When a contract
|
||||
changes shape, bump its `version`; the provider serves older consumers through `accepts`, else they wait "contract
|
||||
mismatch" (visible, never wrong data).
|
||||
|
||||
## How the app builds it
|
||||
|
||||
The server builds each facet with one esbuild call from source: TypeScript and TSX (JSX with the automatic React
|
||||
runtime), JS, JSON, CSS (web facets: one stylesheet per facet), assets and WASM by URL (`import url from "./x.svg?url"`).
|
||||
No type check, no build hooks, no `dist` needed; generated files are committed. The script is evaluated in each
|
||||
process of its kind with that process's API modules. A failed build or evaluation leaves the plugin out (at startup)
|
||||
or keeps its old code (at a reload), reported with the error.
|
||||
|
||||
## Styles and themes (web and terminal facets)
|
||||
|
||||
- A web facet's CSS is served with its script and swapped when the plugin reloads. Scope it with a **CSS module**: a
|
||||
file named `*.module.css` has its class names made unique to the plugin and the file, and its import is the mapping
|
||||
(`import styles from "./notes.module.css"; <div className={styles.card}>`). Plain CSS files still work; their class
|
||||
names should then start with the plugin's name.
|
||||
- Read the app's look from its CSS variables (`var(--accent)`, `var(--bg)`, `var(--fg)`, `var(--muted)`, `var(--error)`,
|
||||
`var(--warning)`, `var(--success)`, and the app's own roles), never fixed colours: the active theme sets them.
|
||||
- In a terminal facet, ask the app's theme lookup for a role (`theme.fg("accent", "Notes")`); never write raw colours.
|
||||
- A theme is data: contribute a `Theme` (`{ id, name, mode, colors, web?, tui? }`) to the kit's `Themes` registry,
|
||||
from a web facet and a terminal facet to reach both. Users pick one dark and one light theme.
|
||||
|
||||
## Installing
|
||||
|
||||
- `plugins.installPlugin({ git: "https://github.com/bob/stats.git", ref: "v1.2.0" }, root, ctx)`: a shallow clone of
|
||||
the branch, tag or commit into `<root>/stats`; its plugins are the folders its `chord.plugins` lists, else by
|
||||
convention. `{ npm: "@bob/stats", version: "1.2.0" }`: the package extracted into `<root>/@bob/stats`. Then their
|
||||
dependencies are installed (`npm install --omit=dev --ignore-scripts`; no lifecycle scripts run). Refused: a plugin
|
||||
name the app has already, an existing directory, a ref or name starting with `-`.
|
||||
- Removing is deleting the directory. To update, install another ref (a new directory) or delete and install again.
|
||||
|
||||
## Testing a plugin alone
|
||||
|
||||
```ts
|
||||
import { testPlugin } from "@earendil-works/chord/next/plugins";
|
||||
import * as apiV3 from "@acme/app/v3";
|
||||
import * as apiV3Worker from "@acme/app/v3/worker";
|
||||
|
||||
const Notes = apiV3.local("acme.notes", { version: 1, params: { project: apiV3.str() }, members: { text: apiV3.str() } });
|
||||
const Stats = apiV3.local("bob.stats", { version: 1, params: { project: apiV3.str() }, members: { words: apiV3.int() } });
|
||||
const notesStub = apiV3.defineFacet((host) => host.serve(Notes, () => ({ text: apiV3.constant("a b c") })));
|
||||
|
||||
const t = await testPlugin("./plugins/stats", {
|
||||
kind: "worker",
|
||||
kinds: ["server", "worker", "web"], // the app's kinds (by default: those its files name)
|
||||
api: { name: "@acme/app", versions: ["v3"] },
|
||||
modules: { "@acme/app/v3": apiV3, "@acme/app/v3/worker": apiV3Worker }, // the real API modules
|
||||
stubs: [{ name: "@acme/notes", facet: notesStub }], // facets serving what it uses
|
||||
principal: { kind: "worker", project: "p1" }, // the host's principal (optional)
|
||||
}, ctx);
|
||||
const stats = await t.host.get(Stats, { project: "p1" }, ctx); // stats.words.value: 3
|
||||
t.contributions(apiV3Worker.Indexers); // its entries in a registry nobody serves (the helper collects them)
|
||||
t.unserved(); // contracts its requests wait for: stub these
|
||||
await t.root(async (ctx) => ..., ctx, { principal: { kind: "user", id: "u1" } });
|
||||
await t.close(ctx);
|
||||
```
|
||||
|
||||
The plugin is built as production builds it and loaded on an in-memory host (a `:memory:` store). Stubs are ordinary
|
||||
facets with fake data. A test declares the contracts it reads again (contracts are data: the same name, version and
|
||||
schema bind).
|
||||
|
||||
## Checklist
|
||||
|
||||
1. `package.json` with a valid name (or a single file `<name>.<kind>.ts`).
|
||||
2. One facet file per kind it runs on; each default-exports `defineFacet(...)`.
|
||||
3. Imports: the app's API at one version, own files, same-root contract files by relative path, other repositories'
|
||||
by package name pinned in `dependencies`.
|
||||
4. Contracts prefixed with the plugin's name; a version bump for every change of shape.
|
||||
5. Files of one kind named `*.<kind>.ts(x)`.
|
||||
6. A test with `testPlugin` and stubs for every name `t.unserved()` lists.
|
||||
186
packages/chord/docs/next/plugin-model/book.md
Normal file
186
packages/chord/docs/next/plugin-model/book.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
---
|
||||
title: The Plugin Model: what a plugin is, what it may import, and how versions meet
|
||||
cover: The Plugin / Model
|
||||
kicker: A design proposal · Chord v2 · 2026-10-31
|
||||
subtitle: Plugins, base code, versioned APIs and contracts, in one place, as discussed with the owner on 2026-10-31
|
||||
stats:
|
||||
- 4 | kinds of import a plugin may write
|
||||
- 1 | integer per API version, in the import path
|
||||
- 0 | package.json fields Chord reads besides name and dependencies
|
||||
- 0 | type checks in the build
|
||||
note: |
|
||||
A proposal written by the redesign session after a day of discussion with the owner. It collects what was decided
|
||||
and what is proposed into one model. Decided points cite their decision in `decisions.md`; proposed ones are marked.
|
||||
Nothing here is built yet beyond slices 7c and 10.
|
||||
colophon: The Plugin Model · proposal · 2026-10-31
|
||||
---
|
||||
|
||||
# Before we begin {.preface}
|
||||
|
||||
An app built on Chord is mostly plugins. Its own features are plugins, users add plugins, and agents write plugins
|
||||
while the app runs. A plugin uses the app (where things live, who calls, how to declare objects), uses other plugins
|
||||
(their objects and extension points), and uses npm packages. Each of those can change on its own schedule: the app
|
||||
gets a new version, a plugin it depends on changes its contract, a package publishes a fix.
|
||||
|
||||
The questions the owner kept asking were all versions of one: when a plugin is written against one state of the world
|
||||
and runs against another, what does it get, and who notices a mismatch? This model answers it with one rule for each
|
||||
kind of import, and versions that sit where the dependency is written: in the code.
|
||||
|
||||
## Eight things to know
|
||||
|
||||
:::findings
|
||||
1. **A plugin is a directory, or a single file.** A directory has a `package.json` (its name, its npm dependencies)
|
||||
and one file per host kind; a single file `hello.worker.ts` is a plugin named `hello` with no dependencies.
|
||||
2. **Base code is internal unless it is an API.** The app exposes only versioned API modules to plugins; everything
|
||||
else is unreachable.
|
||||
3. **API versions are in the import path.** `import { Session } from "@acme/app/v3"`: the plugin says what it was
|
||||
written for where it imports it; an unavailable version is a build error.
|
||||
4. **APIs are split by host kind.** A shared module holds what every host may use (contracts, registry tokens, host
|
||||
kinds); `/v3/web` and `/v3/tui` hold UI pieces; a worker never loads React.
|
||||
5. **Contracts carry their own versions.** A plugin carries the contract version it was written for; the provider
|
||||
binds it if it accepts that version, else the plugin waits with a clear reason.
|
||||
6. **A plugin imports four things:** its own files, host packages, its npm dependencies, and the app's own plugins by
|
||||
relative path. Nothing else.
|
||||
7. **Settings are arguments, extensions are registries.** What the app decides at startup is passed to its components;
|
||||
what plugins add is contributed.
|
||||
8. **No type checking in the build.** Types come from where imports come from; editors and agents check, the build
|
||||
only builds.
|
||||
:::
|
||||
|
||||
## Words used throughout
|
||||
|
||||
**Base code** is the app's own program; its **API** is the part plugins may import, published as **API modules**,
|
||||
one per version and host kind. A **host package** is a module the running process provides to every plugin (Chord,
|
||||
the app's API modules). A **contract** is an object type's declaration; a **registry token** an extension point's.
|
||||
A **facet** is a plugin's declarations for one host kind.
|
||||
|
||||
# What a Plugin Is {#plugin lede="A directory or a single file, named once, with one file per host kind."}
|
||||
|
||||
```text
|
||||
plugins/notes/ a directory plugin
|
||||
package.json { "name": "@acme/notes", "dependencies": { "slugify": "1.6.6" } }
|
||||
contract.ts the object types and registry tokens it offers
|
||||
worker.ts server.ts web.tsx one facet per host kind it needs
|
||||
plugins/hello.worker.ts a single-file plugin "hello" with one worker facet
|
||||
plugins/hello.web.tsx the same plugin's web facet
|
||||
```
|
||||
|
||||
A directory plugin's name is its `package.json` name; Chord reads only `name` and `dependencies` from it. A
|
||||
single-file plugin's name is its file's base name; it has no dependencies and imports only host packages. When a
|
||||
single file needs a package or a second file, it becomes a directory. Two roots with the same name: the first root
|
||||
wins, the other is reported (slice 7c).
|
||||
|
||||
# Base Code: Internal and External {#base lede="What the app keeps to itself, what it exposes, and how exposure is versioned."}
|
||||
|
||||
The app's base code is everything that starts the process: settings, startup, core objects' implementations, the
|
||||
session index, migrations. Plugins must not depend on any of it, or every refactoring of the app would break them.
|
||||
So the app draws a line and exposes only its **API**:
|
||||
|
||||
```text
|
||||
src/api/v3/index.ts shared: host kinds, the principal type, defineFacet per kind, core contracts, registry tokens
|
||||
src/api/v3/web.ts web only: shared UI pieces (a Sheet, theme variables), React-typed registry tokens
|
||||
src/api/v3/tui.ts TUI only: its UI pieces
|
||||
src/… internal: everything else
|
||||
```
|
||||
|
||||
Each API module is a host package of the kinds that may use it: `@acme/app/v3` for every kind, `@acme/app/v3/web` for
|
||||
the web kind only. A worker plugin importing `@acme/app/v3/web` is a build error, and a worker process never loads
|
||||
React. The shared module stays free of runtime imports of UI libraries: a registry token that needs a React type names
|
||||
it with `import type`, which the build erases.
|
||||
|
||||
:::note | Proposed
|
||||
The split of the external API into a shared module and per-kind modules answers the owner's worry about "web, TUI,
|
||||
worker, server things all in the same index.ts". Proposed, not yet decided.
|
||||
:::
|
||||
|
||||
# Versions in the Import Path {#versions lede="A plugin says what it was written for where it imports it."}
|
||||
|
||||
The app's API changes over time: a host kind is renamed, a principal variant changes. A plugin written for an older
|
||||
API must not silently get the new one. So every API module has a version in its path, and the app lists which versions
|
||||
it still provides:
|
||||
|
||||
```ts
|
||||
// plugins/stats/worker.ts
|
||||
import { defineFacet, Session, Panels } from "@acme/app/v3";
|
||||
import { remote, str } from "@earendil-works/chord/next/v2";
|
||||
```
|
||||
|
||||
- The app provides `/v3`, its current API. If version 2 plugins still work unchanged against it, `/v2` is listed too,
|
||||
pointing to the same module. An app that needs a compatibility layer makes `/v2` a thin module over `/v3`.
|
||||
- An import of a version the app no longer lists is a build error: "@acme/app/v1 is not available: this app provides
|
||||
v3, v2". The plugin keeps its old code, and the message tells an agent exactly what to change.
|
||||
- Nothing to fill in elsewhere: no `package.json` field, no npm ranges. Skeletons and examples import the current
|
||||
version, so an agent copies it without thinking.
|
||||
- Chord's own surface is versioned the same way (`@earendil-works/chord/next/v2`).
|
||||
|
||||
Decided: "the API version must be declared by the plugin in code" (owner, 2026-10-31); the import path is how.
|
||||
|
||||
# Contracts and Registries {#contracts lede="Finer versions inside the API, and what crosses between plugins."}
|
||||
|
||||
Contracts and registry tokens keep their own versions inside the API, because they evolve independently of the API as
|
||||
a whole:
|
||||
|
||||
- **An object type**: the consumer carries the contract version it was written for; the provider binds it at that
|
||||
version if it accepts it (`accepts` maps arguments forward and results and state back), otherwise the consumer waits
|
||||
"contract mismatch: v1, served v3". Shapes are fingerprinted, so a changed shape without a version bump is caught.
|
||||
- **A registry token**: a name, a version and TypeScript types for what is contributed and read. Values are in-process
|
||||
things (functions, components), which cannot be fingerprinted: bumping the version when they change is the defining
|
||||
plugin's rule, and its `accepts` adapts older contributions.
|
||||
- **Host kinds on registries** (proposed): a token names the kinds that serve it (`hosts: ["web"]`), typed, so a worker
|
||||
facet contributing to a web registry is a type error and a load error, not a silent no-op.
|
||||
|
||||
A contract file imports only host packages at runtime (Chord, the app's API); native types it mentions are type-only.
|
||||
|
||||
# What a Plugin May Import {#imports lede="Four kinds of import, each with one rule."}
|
||||
|
||||
| Import | Resolved to | Versioned by |
|
||||
| --- | --- | --- |
|
||||
| its own files (`./store.ts`) | copied into its script | the plugin itself |
|
||||
| host packages (`@acme/app/v3`, `@earendil-works/chord/next/v2`, `react`) | the running process's instance, shared | the path's version; the app lists what it provides |
|
||||
| npm packages in its `dependencies` (`slugify`, a third party's contracts package) | its own `node_modules`, bundled | the installed version |
|
||||
| the app's own plugins (`../notes/contract.ts`), in the same root | copied into its script | the same revision: one repository |
|
||||
|
||||
A third-party plugin that wants the app's plugins' contracts gets them through the app's API (core contracts) or the
|
||||
app's published package, as an npm dependency at the version it was written for, never from the running plugin's
|
||||
files. Anything else (a path into base code, an unlisted version, a package not in its dependencies) is a build error.
|
||||
|
||||
# Settings and Extensions {#settings lede="How the app configures its components without letting plugins override it."}
|
||||
|
||||
Decided ("Settings are arguments, extensions are registries"): what the app decides at startup is an argument to the
|
||||
component's facet, which base code constructs, and plugins cannot change it:
|
||||
|
||||
```ts
|
||||
const http = createHttp(server, { listen: { port: 8788 }, links: { path: "/chord", maxPayload: MiB }, closeTimeout: 10_000, onError }, ctx);
|
||||
```
|
||||
|
||||
What plugins add while the app runs is a registry: routes, authenticators, tools, panels. A component may offer both
|
||||
for one thing: the session index takes its indexer as an argument, and may also follow a registry of indexers if
|
||||
plugins should supply them.
|
||||
|
||||
# Building and Evaluating {#build lede="Unchanged from slices 7c and 10: one build on the server, evaluation everywhere."}
|
||||
|
||||
Only the server builds, because only the server has the sources. `createPlugins` builds every kind and needs only
|
||||
names: per kind its platform and its host package names. Each process evaluates the scripts of its kind with its own
|
||||
module objects (the server's at `plugins.load`, a worker's in `workerMain`, a page's in its base bundle), so a plugin
|
||||
shares that process's Chord and API. Base-code facets are passed beside the plugins; they are not plugins, because
|
||||
they close over the process's own values and never reload.
|
||||
|
||||
# Types and Tests {#tools lede="Where types come from, and how a plugin is tested alone."}
|
||||
|
||||
Types come from where imports come from: relative files and npm packages resolve natively in TypeScript; the app's API
|
||||
modules ship `.d.ts` per version in the app's package. There is no type checking in the build (the owner: seconds per
|
||||
build is too slow); editors check as an author types, and an agent may run `tsc` as a tool.
|
||||
|
||||
Testing a plugin alone (proposed): `testPlugin(dir, { kind, stubs })` builds the plugin like production, loads it on an
|
||||
in-memory host with the kind's real host packages, and loads stub facets that serve the contracts it depends on. A stub
|
||||
is an ordinary facet; there is no mocking framework.
|
||||
|
||||
# Open Questions {.epilogue lede="What is proposed here and still to decide."}
|
||||
|
||||
1. The external API split by host kind (`/v3`, `/v3/web`, `/v3/tui`).
|
||||
2. Host kinds on registry tokens, typed and checked at load.
|
||||
3. Single-file plugins (`hello.worker.ts`, named by the file).
|
||||
4. Contracts of the app's plugins for third parties: through the API modules only, or also the app's npm package.
|
||||
5. `testPlugin` with stub facets.
|
||||
6. Reversing "Plugins import each other by name" (2026-10-31, morning) in favour of relative paths within the app
|
||||
and npm dependencies across it.
|
||||
310
packages/chord/docs/next/plugin-model/index.html
Normal file
310
packages/chord/docs/next/plugin-model/index.html
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>The Plugin Model: what a plugin is, what it may import, and how versions meet</title>
|
||||
<style>
|
||||
|
||||
:root{--paper:#fbf8f2;--ink:#2a2723;--muted:#7b7468;--rule:#e3dbcc;--accent:#b4472f;--blue:#2e6a8a;--sand:#c2b18c;--soft:#f4eee2}
|
||||
*{box-sizing:border-box}
|
||||
html{background:#efe9dd}
|
||||
body{margin:0;color:var(--ink);font:19px/1.68 'Iowan Old Style','Palatino Linotype',Palatino,'Book Antiqua',Georgia,serif;
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
||||
.book{max-width:860px;margin:0 auto;background:var(--paper);box-shadow:0 0 0 1px #e1d9c8,0 30px 80px -40px rgba(60,40,10,.35);padding:0 0 4rem}
|
||||
.inner{padding:0 90px}
|
||||
h1,h2,h3{font-weight:400;line-height:1.15}
|
||||
h2{font-size:2.1rem;margin:.2em 0 .3em;letter-spacing:-.01em}
|
||||
h3{font-size:1.05rem;text-transform:uppercase;letter-spacing:.14em;margin:2.4em 0 .6em;color:var(--ink);font-weight:600;
|
||||
font-family:'Avenir Next','Helvetica Neue',sans-serif}
|
||||
p{margin:0 0 1.05em;hyphens:auto;text-align:left}
|
||||
code{font:.8em/1 'SF Mono',Menlo,Consolas,monospace;background:#f1ebde;padding:.1em .35em;border-radius:3px}
|
||||
pre{font:.74rem/1.55 'SF Mono',Menlo,Consolas,monospace;background:#f4eee2;border-left:3px solid var(--sand);padding:1rem 1.2rem;margin:1.4rem 0 1.8rem;
|
||||
overflow-x:auto;border-radius:0 4px 4px 0;white-space:pre;tab-size:2}
|
||||
pre code{background:none;padding:0;font-size:1em}
|
||||
em{font-style:italic}
|
||||
a{color:var(--blue)}
|
||||
.cover{min-height:92vh;display:flex;flex-direction:column;justify-content:center;padding:90px;background:
|
||||
radial-gradient(circle at 85% 15%,rgba(180,71,47,.13),transparent 40%),radial-gradient(circle at 10% 90%,rgba(46,106,138,.12),transparent 45%),var(--paper);
|
||||
border-bottom:1px solid var(--rule)}
|
||||
.kicker{font:600 .78rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.22em;text-transform:uppercase;color:var(--accent)}
|
||||
.cover h1{font-size:5.2rem;line-height:.98;margin:.35em 0 .25em;letter-spacing:-.025em}
|
||||
.subtitle{font-size:1.45rem;font-style:italic;color:#4a453e;max-width:30em;text-align:left}
|
||||
.cover-stats,.stats{display:flex;flex-wrap:wrap;gap:1.2rem 2.4rem;margin:2.2rem 0}
|
||||
.stats{margin:1.6rem 0 1.8rem;padding:1.1rem 0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
|
||||
.stat b{display:block;font-size:2.1rem;font-weight:400;line-height:1.1;font-variant-numeric:lining-nums}
|
||||
.stat span{font:.78rem/1.3 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);letter-spacing:.03em;display:block;max-width:14em}
|
||||
.cover-note{font-size:.9rem;color:var(--muted);max-width:34em;text-align:left;margin-top:2rem}
|
||||
.toc{padding:4rem 0 2rem}
|
||||
.toc ol{list-style:none;padding:0;margin:1rem 0;columns:2;column-gap:3rem}
|
||||
.toc li{padding:.3rem 0;border-bottom:1px dotted var(--rule);break-inside:avoid}
|
||||
.toc a{color:var(--ink);text-decoration:none}
|
||||
.toc a:hover{color:var(--accent)}
|
||||
.preface{padding-top:2rem}
|
||||
.findings{padding-left:1.4em}
|
||||
.findings li{margin:.55em 0;padding-left:.3em}
|
||||
.findings li::marker{color:var(--accent);font-weight:600}
|
||||
.chapter{padding-top:4.5rem;margin-top:3rem;border-top:1px solid var(--rule)}
|
||||
.opener{text-align:center;margin-bottom:2.6rem}
|
||||
.numeral{font-size:4.2rem;color:var(--accent);line-height:1;font-variant:small-caps;letter-spacing:.05em}
|
||||
.opener h2{font-size:2.5rem;margin:.25em 0}
|
||||
.lede{font-style:italic;color:#5a544a;font-size:1.12rem;max-width:30em;margin:0 auto;text-align:center}
|
||||
.dropcap::first-letter{float:left;font-size:4.1em;line-height:.82;padding:.06em .09em 0 0;color:var(--accent)}
|
||||
.fig{margin:2rem 0 2.2rem}
|
||||
.fig.wide{margin-left:-50px;margin-right:-50px}
|
||||
.fig svg{display:block;margin:0 auto}
|
||||
figcaption{font:.8rem/1.45 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);margin-top:.7rem;padding-left:.8rem;border-left:2px solid var(--rule)}
|
||||
.fignum{color:var(--ink);font-weight:600}
|
||||
.note{font:.86rem/1.55 'Avenir Next','Helvetica Neue',sans-serif;color:#5b5549;background:#f4eee2;border-left:3px solid var(--sand);
|
||||
padding:.9rem 1.1rem;margin:1.8rem 0;border-radius:0 4px 4px 0}
|
||||
.note.review{border-left-color:var(--accent)}
|
||||
.note b.tag{font-weight:600;letter-spacing:.12em;text-transform:uppercase;font-size:.72rem;color:var(--accent);display:block;margin-bottom:.3rem}
|
||||
.note code{font-size:.85em}
|
||||
table{width:100%;border-collapse:collapse;margin:1.4rem 0 1.8rem;font:.8rem/1.4 'Avenir Next','Helvetica Neue',sans-serif}
|
||||
th{text-align:left;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-size:.68rem;border-bottom:1.5px solid var(--ink);padding:.45rem .5rem}
|
||||
td{padding:.42rem .5rem;border-bottom:1px solid var(--rule);vertical-align:top}
|
||||
td code{font-size:.92em}
|
||||
table.num td:not(:first-child),table.num th:not(:first-child){text-align:right}
|
||||
.epilogue p{font-size:1.04em}
|
||||
.appendix{font-size:.92em}
|
||||
.appendix ul,.chapter ul{padding-left:1.2em}
|
||||
.appendix li,.chapter li{margin:.4em 0}
|
||||
.colophon{text-align:center;font:.78rem 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);margin-top:4rem;letter-spacing:.05em}
|
||||
@media (max-width:760px){.inner{padding:0 22px}.cover{padding:40px 22px}.cover h1{font-size:3.2rem}.fig.wide{margin-left:0;margin-right:0}
|
||||
.toc ol{columns:1}p{text-align:left}body{font-size:17px}}
|
||||
@media print{html{background:#fff}.book{box-shadow:none}.chapter{break-before:page}.fig{break-inside:avoid}}
|
||||
.transcript p b{font:600 .78rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-right:.3em}
|
||||
pre .tk-c{color:#8d8576;font-style:italic}pre .tk-s{color:#5f7a3d}pre .tk-n{color:#a0662a}pre .tk-k{color:#b4472f}pre .tk-t{color:#2e6a8a}pre .tk-f{color:#7a3f6a}
|
||||
h4{font:600 .82rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.06em;margin:1.8em 0 .5em;color:var(--muted)}
|
||||
.inner ul,.inner ol:not(.findings){padding-left:1.2em}
|
||||
.inner li{margin:.35em 0}
|
||||
blockquote{margin:1.4rem 0;padding:.2rem 0 .2rem 1.2rem;border-left:3px solid var(--rule);color:#5a544a;font-style:italic}
|
||||
.fig svg text{dominant-baseline:auto}
|
||||
.fig .replay{font:600 .7rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);
|
||||
background:none;border:1px solid var(--rule);border-radius:3px;padding:.2rem .55rem;margin:0 0 0 1rem;cursor:pointer;float:right}
|
||||
.fig .replay:hover{border-color:var(--accent)}
|
||||
.anim.armed .step{opacity:0}
|
||||
.anim.armed.play .step{animation:stepIn .45s ease-out both;animation-delay:calc(var(--i) * var(--pace, .45s))}
|
||||
.anim.armed.play .step.edge{animation-name:edgeIn}
|
||||
@keyframes stepIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||
@keyframes edgeIn{from{opacity:0}to{opacity:1}}
|
||||
.steps{margin:2rem 0 2.2rem;border:1px solid var(--rule);border-radius:6px;background:#fffdf8}
|
||||
.steps-head{display:flex;align-items:center;gap:.8rem;padding:.6rem 1rem;border-bottom:1px solid var(--rule);
|
||||
font:600 .72rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
|
||||
.steps-head .title{flex:1}
|
||||
.steps-head .count{color:var(--muted);letter-spacing:.06em}
|
||||
.steps-head button{font:600 .72rem 'Avenir Next','Helvetica Neue',sans-serif;color:var(--ink);background:var(--soft);border:1px solid var(--rule);
|
||||
border-radius:3px;padding:.25rem .65rem;cursor:pointer}
|
||||
.steps-head button:disabled{opacity:.35;cursor:default}
|
||||
.step-panel{padding:1rem 1.2rem .4rem}
|
||||
.step-panel>h4:first-child{margin-top:.2rem;color:var(--ink)}
|
||||
.steps.js .step-panel{display:none}
|
||||
.steps.js .step-panel.on{display:block}
|
||||
.steps:not(.js) .steps-head button,.steps:not(.js) .steps-head .count{display:none}
|
||||
.steps:not(.js) .step-panel+.step-panel{border-top:1px dashed var(--rule)}
|
||||
.fig figcaption{display:flow-root}
|
||||
.fig svg{max-width:100%;height:auto}
|
||||
table{max-width:100%}
|
||||
@media (max-width:760px){.inner{padding:0 20px}.cover{padding:48px 22px}.cover h1{font-size:3.2rem}.fig.wide{margin-left:0;margin-right:0}table{display:block;overflow-x:auto}.toc ol{columns:1}}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body><div class="book">
|
||||
<section class="cover">
|
||||
<div class="kicker">A design proposal · Chord v2 · 2026-10-31</div>
|
||||
<h1>The Plugin<br>Model</h1>
|
||||
<p class="subtitle">Plugins, base code, versioned APIs and contracts, in one place, as discussed with the owner on 2026-10-31</p>
|
||||
<div class="cover-stats"><div class="stat"><b>4</b><span>kinds of import a plugin may write</span></div><div class="stat"><b>1</b><span>integer per API version, in the import path</span></div><div class="stat"><b>0</b><span>package.json fields Chord reads besides name and dependencies</span></div><div class="stat"><b>0</b><span>type checks in the build</span></div></div>
|
||||
<div class="cover-note"><p>A proposal written by the redesign session after a day of discussion with the owner. It collects what was decided
|
||||
and what is proposed into one model. Decided points cite their decision in <code>decisions.md</code>; proposed ones are marked.
|
||||
Nothing here is built yet beyond slices 7c and 10.</p>
|
||||
</div>
|
||||
</section><div class="inner">
|
||||
<nav class="toc"><h2>Contents</h2><ol>
|
||||
<li><a href="#before-we-begin">Before we begin</a></li>
|
||||
<li><a href="#plugin">I. What a Plugin Is</a></li>
|
||||
<li><a href="#base">II. Base Code: Internal and External</a></li>
|
||||
<li><a href="#versions">III. Versions in the Import Path</a></li>
|
||||
<li><a href="#contracts">IV. Contracts and Registries</a></li>
|
||||
<li><a href="#imports">V. What a Plugin May Import</a></li>
|
||||
<li><a href="#settings">VI. Settings and Extensions</a></li>
|
||||
<li><a href="#build">VII. Building and Evaluating</a></li>
|
||||
<li><a href="#tools">VIII. Types and Tests</a></li>
|
||||
<li><a href="#open-questions">Open Questions</a></li>
|
||||
</ol></nav>
|
||||
|
||||
<section class="preface" id="before-we-begin">
|
||||
<h2>Before we begin</h2>
|
||||
<p class="dropcap">An app built on Chord is mostly plugins. Its own features are plugins, users add plugins, and agents write plugins
|
||||
while the app runs. A plugin uses the app (where things live, who calls, how to declare objects), uses other plugins
|
||||
(their objects and extension points), and uses npm packages. Each of those can change on its own schedule: the app
|
||||
gets a new version, a plugin it depends on changes its contract, a package publishes a fix.</p>
|
||||
<p>The questions the owner kept asking were all versions of one: when a plugin is written against one state of the world
|
||||
and runs against another, what does it get, and who notices a mismatch? This model answers it with one rule for each
|
||||
kind of import, and versions that sit where the dependency is written: in the code.</p>
|
||||
<h3 id="eight-things-to-know">Eight things to know</h3>
|
||||
<ol class="findings">
|
||||
<li><strong>A plugin is a directory, or a single file.</strong> A directory has a <code>package.json</code> (its name, its npm dependencies)
|
||||
and one file per host kind; a single file <code>hello.worker.ts</code> is a plugin named <code>hello</code> with no dependencies.</li>
|
||||
<li><strong>Base code is internal unless it is an API.</strong> The app exposes only versioned API modules to plugins; everything
|
||||
else is unreachable.</li>
|
||||
<li><strong>API versions are in the import path.</strong> <code>import { Session } from "@acme/app/v3"</code>: the plugin says what it was
|
||||
written for where it imports it; an unavailable version is a build error.</li>
|
||||
<li><strong>APIs are split by host kind.</strong> A shared module holds what every host may use (contracts, registry tokens, host
|
||||
kinds); <code>/v3/web</code> and <code>/v3/tui</code> hold UI pieces; a worker never loads React.</li>
|
||||
<li><strong>Contracts carry their own versions.</strong> A plugin carries the contract version it was written for; the provider
|
||||
binds it if it accepts that version, else the plugin waits with a clear reason.</li>
|
||||
<li><strong>A plugin imports four things:</strong> its own files, host packages, its npm dependencies, and the app's own plugins by
|
||||
relative path. Nothing else.</li>
|
||||
<li><strong>Settings are arguments, extensions are registries.</strong> What the app decides at startup is passed to its components;
|
||||
what plugins add is contributed.</li>
|
||||
<li><strong>No type checking in the build.</strong> Types come from where imports come from; editors and agents check, the build
|
||||
only builds.</li>
|
||||
</ol>
|
||||
<h3 id="words-used-throughout">Words used throughout</h3>
|
||||
<p><strong>Base code</strong> is the app's own program; its <strong>API</strong> is the part plugins may import, published as <strong>API modules</strong>,
|
||||
one per version and host kind. A <strong>host package</strong> is a module the running process provides to every plugin (Chord,
|
||||
the app's API modules). A <strong>contract</strong> is an object type's declaration; a <strong>registry token</strong> an extension point's.
|
||||
A <strong>facet</strong> is a plugin's declarations for one host kind.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="plugin"><header class="opener"><div class="numeral">I</div><h2>What a Plugin Is</h2><p class="lede">A directory or a single file, named once, with one file per host kind.</p></header>
|
||||
<pre class="plain"><code>plugins/notes/ a directory plugin
|
||||
package.json { "name": "@acme/notes", "dependencies": { "slugify": "1.6.6" } }
|
||||
contract.ts the object types and registry tokens it offers
|
||||
worker.ts server.ts web.tsx one facet per host kind it needs
|
||||
plugins/hello.worker.ts a single-file plugin "hello" with one worker facet
|
||||
plugins/hello.web.tsx the same plugin's web facet</code></pre>
|
||||
<p>A directory plugin's name is its <code>package.json</code> name; Chord reads only <code>name</code> and <code>dependencies</code> from it. A
|
||||
single-file plugin's name is its file's base name; it has no dependencies and imports only host packages. When a
|
||||
single file needs a package or a second file, it becomes a directory. Two roots with the same name: the first root
|
||||
wins, the other is reported (slice 7c).</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="base"><header class="opener"><div class="numeral">II</div><h2>Base Code: Internal and External</h2><p class="lede">What the app keeps to itself, what it exposes, and how exposure is versioned.</p></header>
|
||||
<p class="dropcap">The app's base code is everything that starts the process: settings, startup, core objects' implementations, the
|
||||
session index, migrations. Plugins must not depend on any of it, or every refactoring of the app would break them.
|
||||
So the app draws a line and exposes only its <strong>API</strong>:</p>
|
||||
<pre class="plain"><code>src/api/v3/index.ts shared: host kinds, the principal type, defineFacet per kind, core contracts, registry tokens
|
||||
src/api/v3/web.ts web only: shared UI pieces (a Sheet, theme variables), React-typed registry tokens
|
||||
src/api/v3/tui.ts TUI only: its UI pieces
|
||||
src/… internal: everything else</code></pre>
|
||||
<p>Each API module is a host package of the kinds that may use it: <code>@acme/app/v3</code> for every kind, <code>@acme/app/v3/web</code> for
|
||||
the web kind only. A worker plugin importing <code>@acme/app/v3/web</code> is a build error, and a worker process never loads
|
||||
React. The shared module stays free of runtime imports of UI libraries: a registry token that needs a React type names
|
||||
it with <code>import type</code>, which the build erases.</p>
|
||||
<div class="note"><b class="tag">Proposed</b><p>The split of the external API into a shared module and per-kind modules answers the owner's worry about "web, TUI,
|
||||
worker, server things all in the same index.ts". Proposed, not yet decided.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="versions"><header class="opener"><div class="numeral">III</div><h2>Versions in the Import Path</h2><p class="lede">A plugin says what it was written for where it imports it.</p></header>
|
||||
<p class="dropcap">The app's API changes over time: a host kind is renamed, a principal variant changes. A plugin written for an older
|
||||
API must not silently get the new one. So every API module has a version in its path, and the app lists which versions
|
||||
it still provides:</p>
|
||||
<pre><code><span class="tk-c">// plugins/stats/worker.ts</span>
|
||||
<span class="tk-k">import</span> { defineFacet, <span class="tk-t">Session</span>, <span class="tk-t">Panels</span> } <span class="tk-k">from</span> <span class="tk-s">"@acme/app/v3"</span>;
|
||||
<span class="tk-k">import</span> { remote, str } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/v2"</span>;</code></pre>
|
||||
<ul>
|
||||
<li>The app provides <code>/v3</code>, its current API. If version 2 plugins still work unchanged against it, <code>/v2</code> is listed too,
|
||||
pointing to the same module. An app that needs a compatibility layer makes <code>/v2</code> a thin module over <code>/v3</code>.</li>
|
||||
<li>An import of a version the app no longer lists is a build error: "@acme/app/v1 is not available: this app provides
|
||||
v3, v2". The plugin keeps its old code, and the message tells an agent exactly what to change.</li>
|
||||
<li>Nothing to fill in elsewhere: no <code>package.json</code> field, no npm ranges. Skeletons and examples import the current
|
||||
version, so an agent copies it without thinking.</li>
|
||||
<li>Chord's own surface is versioned the same way (<code>@earendil-works/chord/next/v2</code>).</li>
|
||||
</ul>
|
||||
<p>Decided: "the API version must be declared by the plugin in code" (owner, 2026-10-31); the import path is how.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="contracts"><header class="opener"><div class="numeral">IV</div><h2>Contracts and Registries</h2><p class="lede">Finer versions inside the API, and what crosses between plugins.</p></header>
|
||||
<p class="dropcap">Contracts and registry tokens keep their own versions inside the API, because they evolve independently of the API as
|
||||
a whole:</p>
|
||||
<ul>
|
||||
<li><strong>An object type</strong>: the consumer carries the contract version it was written for; the provider binds it at that
|
||||
version if it accepts it (<code>accepts</code> maps arguments forward and results and state back), otherwise the consumer waits
|
||||
"contract mismatch: v1, served v3". Shapes are fingerprinted, so a changed shape without a version bump is caught.</li>
|
||||
<li><strong>A registry token</strong>: a name, a version and TypeScript types for what is contributed and read. Values are in-process
|
||||
things (functions, components), which cannot be fingerprinted: bumping the version when they change is the defining
|
||||
plugin's rule, and its <code>accepts</code> adapts older contributions.</li>
|
||||
<li><strong>Host kinds on registries</strong> (proposed): a token names the kinds that serve it (<code>hosts: ["web"]</code>), typed, so a worker
|
||||
facet contributing to a web registry is a type error and a load error, not a silent no-op.</li>
|
||||
</ul>
|
||||
<p>A contract file imports only host packages at runtime (Chord, the app's API); native types it mentions are type-only.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="imports"><header class="opener"><div class="numeral">V</div><h2>What a Plugin May Import</h2><p class="lede">Four kinds of import, each with one rule.</p></header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Import</th>
|
||||
<th>Resolved to</th>
|
||||
<th>Versioned by</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td>its own files (<code>./store.ts</code>)</td>
|
||||
<td>copied into its script</td>
|
||||
<td>the plugin itself</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>host packages (<code>@acme/app/v3</code>, <code>@earendil-works/chord/next/v2</code>, <code>react</code>)</td>
|
||||
<td>the running process's instance, shared</td>
|
||||
<td>the path's version; the app lists what it provides</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>npm packages in its <code>dependencies</code> (<code>slugify</code>, a third party's contracts package)</td>
|
||||
<td>its own <code>node_modules</code>, bundled</td>
|
||||
<td>the installed version</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>the app's own plugins (<code>../notes/contract.ts</code>), in the same root</td>
|
||||
<td>copied into its script</td>
|
||||
<td>the same revision: one repository</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<p>A third-party plugin that wants the app's plugins' contracts gets them through the app's API (core contracts) or the
|
||||
app's published package, as an npm dependency at the version it was written for, never from the running plugin's
|
||||
files. Anything else (a path into base code, an unlisted version, a package not in its dependencies) is a build error.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="settings"><header class="opener"><div class="numeral">VI</div><h2>Settings and Extensions</h2><p class="lede">How the app configures its components without letting plugins override it.</p></header>
|
||||
<p class="dropcap">Decided ("Settings are arguments, extensions are registries"): what the app decides at startup is an argument to the
|
||||
component's facet, which base code constructs, and plugins cannot change it:</p>
|
||||
<pre><code><span class="tk-k">const</span> http = <span class="tk-f">createHttp</span>(server, { listen: { port: <span class="tk-n">8788</span> }, links: { path: <span class="tk-s">"/chord"</span>, maxPayload: <span class="tk-t">MiB</span> }, closeTimeout: <span class="tk-n">10_000</span>, onError }, ctx);</code></pre>
|
||||
<p>What plugins add while the app runs is a registry: routes, authenticators, tools, panels. A component may offer both
|
||||
for one thing: the session index takes its indexer as an argument, and may also follow a registry of indexers if
|
||||
plugins should supply them.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="build"><header class="opener"><div class="numeral">VII</div><h2>Building and Evaluating</h2><p class="lede">Unchanged from slices 7c and 10: one build on the server, evaluation everywhere.</p></header>
|
||||
<p class="dropcap">Only the server builds, because only the server has the sources. <code>createPlugins</code> builds every kind and needs only
|
||||
names: per kind its platform and its host package names. Each process evaluates the scripts of its kind with its own
|
||||
module objects (the server's at <code>plugins.load</code>, a worker's in <code>workerMain</code>, a page's in its base bundle), so a plugin
|
||||
shares that process's Chord and API. Base-code facets are passed beside the plugins; they are not plugins, because
|
||||
they close over the process's own values and never reload.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="tools"><header class="opener"><div class="numeral">VIII</div><h2>Types and Tests</h2><p class="lede">Where types come from, and how a plugin is tested alone.</p></header>
|
||||
<p class="dropcap">Types come from where imports come from: relative files and npm packages resolve natively in TypeScript; the app's API
|
||||
modules ship <code>.d.ts</code> per version in the app's package. There is no type checking in the build (the owner: seconds per
|
||||
build is too slow); editors check as an author types, and an agent may run <code>tsc</code> as a tool.</p>
|
||||
<p>Testing a plugin alone (proposed): <code>testPlugin(dir, { kind, stubs })</code> builds the plugin like production, loads it on an
|
||||
in-memory host with the kind's real host packages, and loads stub facets that serve the contracts it depends on. A stub
|
||||
is an ordinary facet; there is no mocking framework.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter epilogue" id="open-questions"><header class="opener"><div class="numeral">∴</div><h2>Open Questions</h2><p class="lede">What is proposed here and still to decide.</p></header>
|
||||
<ol>
|
||||
<li>The external API split by host kind (<code>/v3</code>, <code>/v3/web</code>, <code>/v3/tui</code>).</li>
|
||||
<li>Host kinds on registry tokens, typed and checked at load.</li>
|
||||
<li>Single-file plugins (<code>hello.worker.ts</code>, named by the file).</li>
|
||||
<li>Contracts of the app's plugins for third parties: through the API modules only, or also the app's npm package.</li>
|
||||
<li><code>testPlugin</code> with stub facets.</li>
|
||||
<li>Reversing "Plugins import each other by name" (2026-10-31, morning) in favour of relative paths within the app
|
||||
and npm dependencies across it.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<p class="colophon">The Plugin Model · proposal · 2026-10-31</p>
|
||||
</div></div>
|
||||
|
||||
</body></html>
|
||||
273
packages/chord/docs/next/plugin-primer/book.md
Normal file
273
packages/chord/docs/next/plugin-primer/book.md
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
---
|
||||
title: The Plugin Model, a Primer: what a plugin depends on, and how mismatches are caught
|
||||
cover: Plugins / from the Ground Up
|
||||
kicker: A primer · Chord v2 · 2026-10-31
|
||||
subtitle: One app, its own plugins and a stranger's plugin, followed through a year of changes, to show what the open decisions are about
|
||||
stats:
|
||||
- 4 | things a plugin depends on
|
||||
- 3 | of them change without the plugin knowing
|
||||
- 8 | points still to decide
|
||||
- 1 | rule decided for base code: the version is in the import path
|
||||
note: |
|
||||
Written by the redesign session for the owner, after the proposal `docs/next/plugin-model/` did not explain enough.
|
||||
Everything decided cites its decision in `decisions.md`; everything open is marked and ends with a recommendation.
|
||||
Built behaviour is slice 7c and slice 10 (`src/next-node/plugins/`).
|
||||
colophon: The Plugin Model, a Primer · first edition · 2026-10-31
|
||||
---
|
||||
|
||||
# Before we begin {.preface}
|
||||
|
||||
Acme builds a notes app on Chord. The app is a server, a worker per project and browser tabs. Most features are
|
||||
plugins: Acme's own `notes` plugin keeps the notes, its `search` plugin searches them. Bob, who does not work at
|
||||
Acme, writes a `stats` plugin that counts words per project and shows a panel. Bob wrote it in March. It is now
|
||||
November.
|
||||
|
||||
Between March and November, three things changed without Bob: Acme released a new version of the app, Acme's `notes`
|
||||
plugin changed what its notes look like, and Chord got a new release. Bob's plugin is still the March code. The
|
||||
question this primer answers is the only question the plugin model has to answer:
|
||||
|
||||
**When a plugin written against one state of the world runs against another, what does it get, and who notices the
|
||||
mismatch?**
|
||||
|
||||
The answer is different for each thing a plugin depends on, so the primer takes them one at a time.
|
||||
|
||||
## What a plugin is, in one picture
|
||||
|
||||
```text
|
||||
plugins/stats/ Bob's plugin
|
||||
package.json { "name": "@bob/stats", "dependencies": { "words-count": "2.1.0" } }
|
||||
worker.ts runs in each project's worker: counts words
|
||||
web.tsx runs in each tab: the panel
|
||||
```
|
||||
|
||||
The server builds every plugin's files into one script per host kind (esbuild, a few milliseconds each). Each
|
||||
process evaluates the scripts of its kind: the worker runs `worker.ts`'s script, a tab runs `web.tsx`'s. That much
|
||||
is built (slices 7c and 10).
|
||||
|
||||
# Four Things a Plugin Depends On {#four lede="Who changes each one, and when the plugin finds out."}
|
||||
|
||||
Look at the top of Bob's `worker.ts`:
|
||||
|
||||
```ts
|
||||
import countWords from "words-count"; // 1. an npm package
|
||||
import { defineFacet, Project } from "@acme/app"; // 2. the app's base code
|
||||
import { obj, num, remote } from "@earendil-works/chord/next"; // 3. Chord
|
||||
import { Notes } from "@acme/notes/contract"; // 4. another plugin
|
||||
```
|
||||
|
||||
Each import is a different kind of dependency, and they behave differently over time:
|
||||
|
||||
| Import | Who changes it | What Bob's plugin gets today |
|
||||
| --- | --- | --- |
|
||||
| `words-count` | its npm author | the version installed in Bob's plugin directory, copied into his script: frozen until Bob updates it |
|
||||
| `@acme/app` | Acme, when it releases the app | whatever the running app is: always the newest |
|
||||
| `@earendil-works/chord/next` | Chord, through Acme's upgrade | whatever the running app ships: always the newest |
|
||||
| `@acme/notes/contract` | Acme, whenever it edits `notes` | the running `notes` plugin's file, copied at Bob's next build: always the newest |
|
||||
|
||||
The first row is safe: npm packages are copied into the script at the version Bob installed, so they never change
|
||||
under him. The other three rows always give the newest, and Bob's March code meets November's app. Whether that
|
||||
works depends on what changed. The rest of the primer is about those three rows.
|
||||
|
||||
:::note | Why not copy base code and Chord into the script too
|
||||
Because they must be shared. If Bob's script carried its own copy of Chord, its `defineFacet` would make facets the
|
||||
worker's host does not recognize, and its `Project` host kind would be a different object from the app's. Base code
|
||||
and Chord are **host packages**: the process provides one instance and every plugin uses it. That is decided and
|
||||
built. It is also why they can change under a plugin.
|
||||
:::
|
||||
|
||||
# Base Code: Versions in the Import Path {#base lede="Decided: a plugin says which app API it was written for, where it imports it."}
|
||||
|
||||
In June, Acme renames the host kind `Project` to `Workspace`. Bob's March plugin imports `Project`; it no longer
|
||||
exists. Without any versioning, Bob's plugin builds (the build does not type check), then fails when it runs with
|
||||
something like "Cannot read properties of undefined", deep inside his code.
|
||||
|
||||
Decided on 2026-10-31 (decision "API versions in the import path"): the app exposes only **API modules**, each with a
|
||||
version in its path, and lists the versions it still provides.
|
||||
|
||||
```ts
|
||||
// Bob, in March
|
||||
import { defineFacet, Project } from "@acme/app/v2";
|
||||
|
||||
// Acme's app, after June
|
||||
apiVersions: { "@acme/app/v3": apiV3, "@acme/app/v2": apiV2 } // still provides v2, through a thin layer over v3
|
||||
```
|
||||
|
||||
Now there are two cases, both visible:
|
||||
|
||||
- Acme still provides `/v2`: Bob's plugin gets the v2 module, in which `Project` still exists (Acme's thin layer maps
|
||||
it to `Workspace`). It works.
|
||||
- Acme dropped `/v2`: Bob's plugin fails to build with "@acme/app/v2 is not available: this app provides v3". The
|
||||
plugin is left out (per plugin: nothing else breaks), and the message tells Bob, or an agent, exactly what to
|
||||
change.
|
||||
|
||||
Nothing outside the code says what Bob depends on: no `package.json` field, no version ranges. The import says it.
|
||||
|
||||
**What is still open here** is what goes into the API and how Chord relates to it. That is point 2 below.
|
||||
|
||||
# Other Plugins: the Contract Problem {#contracts lede="Why importing another plugin by name gives the wrong answer for a stranger's plugin."}
|
||||
|
||||
Bob's `stats` asks Acme's `notes` for the notes of a project. Plugins talk only through **contracts** (the type of an
|
||||
object: its state and methods) and registries. A contract has its own version:
|
||||
|
||||
```ts
|
||||
// plugins/notes/contract.ts (Acme), March
|
||||
export const Notes = remote("notes", {
|
||||
version: 1,
|
||||
params: { project: str() },
|
||||
hostAddress: ({ project }) => Project({ id: project }),
|
||||
variants: { reader: { state: { list: obj({ items: arr(str()) }) } } },
|
||||
});
|
||||
```
|
||||
|
||||
Bob's plugin imports this file, and the build copies it into his script. When his plugin asks for `Notes`, it says
|
||||
"version 1, this shape"; the provider (the `notes` plugin's code in the worker) checks that it can serve that version.
|
||||
|
||||
In September Acme changes notes: each note gets a timestamp, `items: arr(obj({ text: str(), at: int() }))`, and the
|
||||
contract becomes version 2. Acme also writes an `accepts` mapping, so consumers of version 1 still get a list of
|
||||
strings. Chord's contract versioning exists for exactly this case.
|
||||
|
||||
Now the question is: which version of the contract file ends up in Bob's script?
|
||||
|
||||
## Today: by name, the newest file
|
||||
|
||||
Decided this morning and built (decision "Plugins import each other by name"): `@acme/notes/contract` resolves to the
|
||||
running `notes` plugin's `contract.ts`. Trace what happens at Bob's next build after September:
|
||||
|
||||
:::steps | A rebuild after Acme's change
|
||||
+++ The build copies the new file
|
||||
Bob's `stats` is rebuilt (a rollout, a restart, any edit anywhere). `@acme/notes/contract` is now the version 2 file,
|
||||
and it is copied into Bob's script.
|
||||
+++ Bob's plugin claims version 2
|
||||
At runtime his plugin asks for `Notes` version 2. The provider serves version 2 gladly: no `accepts` is used.
|
||||
+++ Bob's code reads version 1's shape
|
||||
Bob's code does `list.items.join(" ")` and `countWords` on it. The items are now objects. He counts the words of
|
||||
`"[object Object] [object Object]"`. Nothing fails; the panel shows wrong numbers.
|
||||
:::
|
||||
|
||||
The contract version was supposed to protect Bob, and it did nothing: copying the newest file made Bob's plugin claim
|
||||
a version his code was never written for.
|
||||
|
||||
## Proposed: the version Bob wrote against
|
||||
|
||||
The contract file must come from where Bob's code came from: the state of the world he wrote against. For a stranger's
|
||||
plugin that is an npm dependency at a fixed version:
|
||||
|
||||
```json
|
||||
// plugins/stats/package.json
|
||||
{ "name": "@bob/stats", "dependencies": { "@acme/notes-contracts": "1.0.0" } }
|
||||
```
|
||||
|
||||
Now Bob's script carries the March contract file forever (npm packages are copied at the installed version, as row 1
|
||||
of the first table). His plugin asks for version 1; the provider maps it through `accepts`; Bob gets strings as
|
||||
before. If Acme ever stops accepting version 1, Bob's plugin waits with "contract mismatch: v1, served v3", visible in
|
||||
the UI, never wrong numbers.
|
||||
|
||||
For Acme's own plugins (`search` using `notes`) the problem does not exist: they live in one repository and change in
|
||||
the same commit. They import each other by relative path, `../notes/contract.ts`, the same file at the same revision.
|
||||
The by-name rule also needed path aliases in the TypeScript configuration for every plugin (six were added for the
|
||||
tests and examples of slice 10); relative paths need none.
|
||||
|
||||
# The Open Points {#open lede="Eight decisions, each with the problem it answers and a recommendation."}
|
||||
|
||||
## 1. Reverse "import by name"
|
||||
|
||||
**Problem:** the trace above: a stranger's plugin silently claims a contract version it was not written for.
|
||||
**Proposal:** the app's own plugins import each other relatively (`../notes/contract.ts`); everyone else gets
|
||||
contracts as an npm dependency at a fixed version. Importing a running plugin by name is removed; a relative path
|
||||
leaving the plugin's root stays a build error.
|
||||
**Recommendation:** do it, in the plugin slice (slice 10 is committed with by-name imports; the change is small:
|
||||
`build.ts` and the examples' imports).
|
||||
|
||||
## 2. What the app's API is, and Chord in it
|
||||
|
||||
**Problem:** a plugin imports two host packages that both change, `@acme/app/v3` and `@earendil-works/chord/next/v2`.
|
||||
Acme must then say which combinations work: does a plugin importing app v3 and Chord v1 still run? Two version
|
||||
numbers per plugin, and a matrix nobody tests.
|
||||
**Proposal:** plugins import only the app's API. The app re-exports the part of Chord it supports:
|
||||
|
||||
```ts
|
||||
// src/api/v3/index.ts (Acme's base code)
|
||||
export { obj, arr, str, int, remote, local, defineRegistry } from "@earendil-works/chord/next";
|
||||
export { Workspace, Panels, defineFacet } from "../../core.ts";
|
||||
```
|
||||
|
||||
One number, the app's, describes everything a plugin gets from its host. When Acme upgrades Chord in a way that
|
||||
breaks plugins, Acme bumps its API version, and decides whether `/v2` survives as a thin layer.
|
||||
**Generations:** if the app provides `/v3` and `/v2`, a facet must import from one of them only, never both (the
|
||||
build checks it), because `/v2`'s `Panels` and `/v3`'s `Panels` may be different registries.
|
||||
**Recommendation:** yes; app kit apps get it for free (`chord/next/app` provides the API module).
|
||||
|
||||
## 3. The API split by host kind
|
||||
|
||||
**Problem:** the API module holds everything plugins need, including web things: a `Sheet` component, a theme, the
|
||||
`Panels` registry whose entries are React components. A worker plugin imports `@acme/app/v3` for `Workspace`, and the
|
||||
worker process has to load the module, so it loads React.
|
||||
**Proposal:** `/v3` holds what every host may use (contracts, registry tokens, host kinds, principals) and imports no
|
||||
UI library at runtime; `/v3/web` and `/v3/tui` hold UI pieces. A worker facet importing `/v3/web` is a build error.
|
||||
**Recommendation:** yes.
|
||||
|
||||
## 4. Host kinds on registry tokens
|
||||
|
||||
**Problem:** Bob contributes his panel from `worker.ts` by mistake (`self.contribute(Panels, …)`). The worker has no
|
||||
`Panels` registry; the contribution sits in a table nobody reads. Nothing fails; the panel never appears.
|
||||
**Proposal:** a token names the kinds that serve it, `defineRegistry("app.panels", { version: 1, hosts: ["web"] })`;
|
||||
contributing from another kind is a type error (the facet's `self` is typed per kind) and a load error.
|
||||
**Recommendation:** yes; together with point 3, `Panels` lives in `/v3/web` anyway.
|
||||
|
||||
## 5. Contracts for third parties
|
||||
|
||||
**Problem:** with point 1, Bob needs Acme's plugins' contracts as an npm package. Which package?
|
||||
**Options:** (a) the app's API exports the contracts of its own plugins that others may use (`@acme/app/v3` has
|
||||
`Notes`): one place, versioned with the app; (b) Acme publishes its app package to npm and Bob depends on it; (c) each
|
||||
plugin publishes its own contracts package. (c) is what the owner called unpleasant.
|
||||
**Recommendation:** (a) for contracts Acme wants others to use: the API is the app's promise. Note: in (a) the
|
||||
contract file comes with the API version, so Bob's v2 import keeps v2's contract files (the thin layer keeps them).
|
||||
|
||||
## 6. Testing a plugin alone
|
||||
|
||||
**Problem:** testing Bob's `stats` needs a worker with a `Notes` provider, an app API, a store.
|
||||
**Proposal:** `testPlugin("plugins/stats", { kind: "worker", stubs: [notesStub] })` builds the plugin as production
|
||||
does, loads it on an in-memory host with the kind's real host packages, and loads stub facets that serve the
|
||||
contracts it uses. A stub is an ordinary facet (`host.serve(Notes, () => ({ list: constant({ items: ["a b"] }) }))`).
|
||||
**Recommendation:** yes, in the app kit.
|
||||
|
||||
## 7. Single-file plugins
|
||||
|
||||
**Problem:** a plugin that adds one route needs a directory and a `package.json` with a name.
|
||||
**Proposal:** `plugins/hello.server.ts` is a plugin named `hello` with one server facet, no dependencies; when it
|
||||
needs a package or a second file, it becomes a directory.
|
||||
**Recommendation:** yes, cheap; agents write many such plugins.
|
||||
|
||||
## 8. The app's own plugins follow the same rules
|
||||
|
||||
**Problem:** are Acme's built-in plugins special?
|
||||
**Proposal:** no. They import the app's API like everyone (not base code's internals), import each other relatively
|
||||
(point 1), are built at startup and reload like any plugin. The only difference: they change in the same commit as
|
||||
base code, so they always use the newest API version.
|
||||
**Recommendation:** yes. It keeps base code small and means every built-in plugin is an example of a correct plugin.
|
||||
|
||||
# What to Decide {.epilogue lede="In the order that makes each answer easier."}
|
||||
|
||||
1. Reverse "import by name" (recommended: yes, in the plugin slice).
|
||||
2. Plugins import only the app's API; Chord is re-exported in it; one generation per facet (recommended: yes).
|
||||
3. Split the API by host kind (recommended: yes).
|
||||
4. Host kinds on registry tokens (recommended: yes).
|
||||
5. Contracts for third parties through the app's API (recommended: (a)).
|
||||
6. `testPlugin` with stub facets (recommended: yes).
|
||||
7. Single-file plugins (recommended: yes).
|
||||
8. Built-in plugins follow the same rules (recommended: yes).
|
||||
|
||||
Later, not part of these: project plugins (per working directory, with trust), and installing plugins from npm or git.
|
||||
|
||||
# Words {.appendix}
|
||||
|
||||
| Word | Meaning |
|
||||
| --- | --- |
|
||||
| base code | the app's own program, started by `node server.ts`; never reloads |
|
||||
| API module | the part of base code plugins may import, one per version (`@acme/app/v3`) |
|
||||
| host package | a module the process provides to every plugin (Chord, the API modules, React on the web) |
|
||||
| contract | an object type: its state and methods, with a version; consumers name the version they use |
|
||||
| `accepts` | the provider's mapping that serves an older contract version |
|
||||
| registry token | an extension point (`Panels`); plugins contribute to it |
|
||||
| facet | a plugin's code for one host kind (`worker.ts`) |
|
||||
| generation | one set of API versions provided together (`/v3` with its web and tui modules) |
|
||||
430
packages/chord/docs/next/plugin-primer/index.html
Normal file
430
packages/chord/docs/next/plugin-primer/index.html
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>The Plugin Model, a Primer: what a plugin depends on, and how mismatches are caught</title>
|
||||
<style>
|
||||
|
||||
:root{--paper:#fbf8f2;--ink:#2a2723;--muted:#7b7468;--rule:#e3dbcc;--accent:#b4472f;--blue:#2e6a8a;--sand:#c2b18c;--soft:#f4eee2}
|
||||
*{box-sizing:border-box}
|
||||
html{background:#efe9dd}
|
||||
body{margin:0;color:var(--ink);font:19px/1.68 'Iowan Old Style','Palatino Linotype',Palatino,'Book Antiqua',Georgia,serif;
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
||||
.book{max-width:860px;margin:0 auto;background:var(--paper);box-shadow:0 0 0 1px #e1d9c8,0 30px 80px -40px rgba(60,40,10,.35);padding:0 0 4rem}
|
||||
.inner{padding:0 90px}
|
||||
h1,h2,h3{font-weight:400;line-height:1.15}
|
||||
h2{font-size:2.1rem;margin:.2em 0 .3em;letter-spacing:-.01em}
|
||||
h3{font-size:1.05rem;text-transform:uppercase;letter-spacing:.14em;margin:2.4em 0 .6em;color:var(--ink);font-weight:600;
|
||||
font-family:'Avenir Next','Helvetica Neue',sans-serif}
|
||||
p{margin:0 0 1.05em;hyphens:auto;text-align:left}
|
||||
code{font:.8em/1 'SF Mono',Menlo,Consolas,monospace;background:#f1ebde;padding:.1em .35em;border-radius:3px}
|
||||
pre{font:.74rem/1.55 'SF Mono',Menlo,Consolas,monospace;background:#f4eee2;border-left:3px solid var(--sand);padding:1rem 1.2rem;margin:1.4rem 0 1.8rem;
|
||||
overflow-x:auto;border-radius:0 4px 4px 0;white-space:pre;tab-size:2}
|
||||
pre code{background:none;padding:0;font-size:1em}
|
||||
em{font-style:italic}
|
||||
a{color:var(--blue)}
|
||||
.cover{min-height:92vh;display:flex;flex-direction:column;justify-content:center;padding:90px;background:
|
||||
radial-gradient(circle at 85% 15%,rgba(180,71,47,.13),transparent 40%),radial-gradient(circle at 10% 90%,rgba(46,106,138,.12),transparent 45%),var(--paper);
|
||||
border-bottom:1px solid var(--rule)}
|
||||
.kicker{font:600 .78rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.22em;text-transform:uppercase;color:var(--accent)}
|
||||
.cover h1{font-size:5.2rem;line-height:.98;margin:.35em 0 .25em;letter-spacing:-.025em}
|
||||
.subtitle{font-size:1.45rem;font-style:italic;color:#4a453e;max-width:30em;text-align:left}
|
||||
.cover-stats,.stats{display:flex;flex-wrap:wrap;gap:1.2rem 2.4rem;margin:2.2rem 0}
|
||||
.stats{margin:1.6rem 0 1.8rem;padding:1.1rem 0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
|
||||
.stat b{display:block;font-size:2.1rem;font-weight:400;line-height:1.1;font-variant-numeric:lining-nums}
|
||||
.stat span{font:.78rem/1.3 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);letter-spacing:.03em;display:block;max-width:14em}
|
||||
.cover-note{font-size:.9rem;color:var(--muted);max-width:34em;text-align:left;margin-top:2rem}
|
||||
.toc{padding:4rem 0 2rem}
|
||||
.toc ol{list-style:none;padding:0;margin:1rem 0;columns:2;column-gap:3rem}
|
||||
.toc li{padding:.3rem 0;border-bottom:1px dotted var(--rule);break-inside:avoid}
|
||||
.toc a{color:var(--ink);text-decoration:none}
|
||||
.toc a:hover{color:var(--accent)}
|
||||
.preface{padding-top:2rem}
|
||||
.findings{padding-left:1.4em}
|
||||
.findings li{margin:.55em 0;padding-left:.3em}
|
||||
.findings li::marker{color:var(--accent);font-weight:600}
|
||||
.chapter{padding-top:4.5rem;margin-top:3rem;border-top:1px solid var(--rule)}
|
||||
.opener{text-align:center;margin-bottom:2.6rem}
|
||||
.numeral{font-size:4.2rem;color:var(--accent);line-height:1;font-variant:small-caps;letter-spacing:.05em}
|
||||
.opener h2{font-size:2.5rem;margin:.25em 0}
|
||||
.lede{font-style:italic;color:#5a544a;font-size:1.12rem;max-width:30em;margin:0 auto;text-align:center}
|
||||
.dropcap::first-letter{float:left;font-size:4.1em;line-height:.82;padding:.06em .09em 0 0;color:var(--accent)}
|
||||
.fig{margin:2rem 0 2.2rem}
|
||||
.fig.wide{margin-left:-50px;margin-right:-50px}
|
||||
.fig svg{display:block;margin:0 auto}
|
||||
figcaption{font:.8rem/1.45 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);margin-top:.7rem;padding-left:.8rem;border-left:2px solid var(--rule)}
|
||||
.fignum{color:var(--ink);font-weight:600}
|
||||
.note{font:.86rem/1.55 'Avenir Next','Helvetica Neue',sans-serif;color:#5b5549;background:#f4eee2;border-left:3px solid var(--sand);
|
||||
padding:.9rem 1.1rem;margin:1.8rem 0;border-radius:0 4px 4px 0}
|
||||
.note.review{border-left-color:var(--accent)}
|
||||
.note b.tag{font-weight:600;letter-spacing:.12em;text-transform:uppercase;font-size:.72rem;color:var(--accent);display:block;margin-bottom:.3rem}
|
||||
.note code{font-size:.85em}
|
||||
table{width:100%;border-collapse:collapse;margin:1.4rem 0 1.8rem;font:.8rem/1.4 'Avenir Next','Helvetica Neue',sans-serif}
|
||||
th{text-align:left;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-size:.68rem;border-bottom:1.5px solid var(--ink);padding:.45rem .5rem}
|
||||
td{padding:.42rem .5rem;border-bottom:1px solid var(--rule);vertical-align:top}
|
||||
td code{font-size:.92em}
|
||||
table.num td:not(:first-child),table.num th:not(:first-child){text-align:right}
|
||||
.epilogue p{font-size:1.04em}
|
||||
.appendix{font-size:.92em}
|
||||
.appendix ul,.chapter ul{padding-left:1.2em}
|
||||
.appendix li,.chapter li{margin:.4em 0}
|
||||
.colophon{text-align:center;font:.78rem 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);margin-top:4rem;letter-spacing:.05em}
|
||||
@media (max-width:760px){.inner{padding:0 22px}.cover{padding:40px 22px}.cover h1{font-size:3.2rem}.fig.wide{margin-left:0;margin-right:0}
|
||||
.toc ol{columns:1}p{text-align:left}body{font-size:17px}}
|
||||
@media print{html{background:#fff}.book{box-shadow:none}.chapter{break-before:page}.fig{break-inside:avoid}}
|
||||
.transcript p b{font:600 .78rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-right:.3em}
|
||||
pre .tk-c{color:#8d8576;font-style:italic}pre .tk-s{color:#5f7a3d}pre .tk-n{color:#a0662a}pre .tk-k{color:#b4472f}pre .tk-t{color:#2e6a8a}pre .tk-f{color:#7a3f6a}
|
||||
h4{font:600 .82rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.06em;margin:1.8em 0 .5em;color:var(--muted)}
|
||||
.inner ul,.inner ol:not(.findings){padding-left:1.2em}
|
||||
.inner li{margin:.35em 0}
|
||||
blockquote{margin:1.4rem 0;padding:.2rem 0 .2rem 1.2rem;border-left:3px solid var(--rule);color:#5a544a;font-style:italic}
|
||||
.fig svg text{dominant-baseline:auto}
|
||||
.fig .replay{font:600 .7rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);
|
||||
background:none;border:1px solid var(--rule);border-radius:3px;padding:.2rem .55rem;margin:0 0 0 1rem;cursor:pointer;float:right}
|
||||
.fig .replay:hover{border-color:var(--accent)}
|
||||
.anim.armed .step{opacity:0}
|
||||
.anim.armed.play .step{animation:stepIn .45s ease-out both;animation-delay:calc(var(--i) * var(--pace, .45s))}
|
||||
.anim.armed.play .step.edge{animation-name:edgeIn}
|
||||
@keyframes stepIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||
@keyframes edgeIn{from{opacity:0}to{opacity:1}}
|
||||
.steps{margin:2rem 0 2.2rem;border:1px solid var(--rule);border-radius:6px;background:#fffdf8}
|
||||
.steps-head{display:flex;align-items:center;gap:.8rem;padding:.6rem 1rem;border-bottom:1px solid var(--rule);
|
||||
font:600 .72rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
|
||||
.steps-head .title{flex:1}
|
||||
.steps-head .count{color:var(--muted);letter-spacing:.06em}
|
||||
.steps-head button{font:600 .72rem 'Avenir Next','Helvetica Neue',sans-serif;color:var(--ink);background:var(--soft);border:1px solid var(--rule);
|
||||
border-radius:3px;padding:.25rem .65rem;cursor:pointer}
|
||||
.steps-head button:disabled{opacity:.35;cursor:default}
|
||||
.step-panel{padding:1rem 1.2rem .4rem}
|
||||
.step-panel>h4:first-child{margin-top:.2rem;color:var(--ink)}
|
||||
.steps.js .step-panel{display:none}
|
||||
.steps.js .step-panel.on{display:block}
|
||||
.steps:not(.js) .steps-head button,.steps:not(.js) .steps-head .count{display:none}
|
||||
.steps:not(.js) .step-panel+.step-panel{border-top:1px dashed var(--rule)}
|
||||
.fig figcaption{display:flow-root}
|
||||
.fig svg{max-width:100%;height:auto}
|
||||
table{max-width:100%}
|
||||
@media (max-width:760px){.inner{padding:0 20px}.cover{padding:48px 22px}.cover h1{font-size:3.2rem}.fig.wide{margin-left:0;margin-right:0}table{display:block;overflow-x:auto}.toc ol{columns:1}}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body><div class="book">
|
||||
<section class="cover">
|
||||
<div class="kicker">A primer · Chord v2 · 2026-10-31</div>
|
||||
<h1>Plugins<br>from the Ground Up</h1>
|
||||
<p class="subtitle">One app, its own plugins and a stranger's plugin, followed through a year of changes, to show what the open decisions are about</p>
|
||||
<div class="cover-stats"><div class="stat"><b>4</b><span>things a plugin depends on</span></div><div class="stat"><b>3</b><span>of them change without the plugin knowing</span></div><div class="stat"><b>8</b><span>points still to decide</span></div><div class="stat"><b>1</b><span>rule decided for base code: the version is in the import path</span></div></div>
|
||||
<div class="cover-note"><p>Written by the redesign session for the owner, after the proposal <code>docs/next/plugin-model/</code> did not explain enough.
|
||||
Everything decided cites its decision in <code>decisions.md</code>; everything open is marked and ends with a recommendation.
|
||||
Built behaviour is slice 7c and slice 10 (<code>src/next-node/plugins/</code>).</p>
|
||||
</div>
|
||||
</section><div class="inner">
|
||||
<nav class="toc"><h2>Contents</h2><ol>
|
||||
<li><a href="#before-we-begin">Before we begin</a></li>
|
||||
<li><a href="#four">I. Four Things a Plugin Depends On</a></li>
|
||||
<li><a href="#base">II. Base Code: Versions in the Import Path</a></li>
|
||||
<li><a href="#contracts">III. Other Plugins: the Contract Problem</a></li>
|
||||
<li><a href="#open">IV. The Open Points</a></li>
|
||||
<li><a href="#what-to-decide">What to Decide</a></li>
|
||||
<li><a href="#words">Appendix A: Words</a></li>
|
||||
</ol></nav>
|
||||
|
||||
<section class="preface" id="before-we-begin">
|
||||
<h2>Before we begin</h2>
|
||||
<p class="dropcap">Acme builds a notes app on Chord. The app is a server, a worker per project and browser tabs. Most features are
|
||||
plugins: Acme's own <code>notes</code> plugin keeps the notes, its <code>search</code> plugin searches them. Bob, who does not work at
|
||||
Acme, writes a <code>stats</code> plugin that counts words per project and shows a panel. Bob wrote it in March. It is now
|
||||
November.</p>
|
||||
<p>Between March and November, three things changed without Bob: Acme released a new version of the app, Acme's <code>notes</code>
|
||||
plugin changed what its notes look like, and Chord got a new release. Bob's plugin is still the March code. The
|
||||
question this primer answers is the only question the plugin model has to answer:</p>
|
||||
<p><strong>When a plugin written against one state of the world runs against another, what does it get, and who notices the
|
||||
mismatch?</strong></p>
|
||||
<p>The answer is different for each thing a plugin depends on, so the primer takes them one at a time.</p>
|
||||
<h3 id="what-a-plugin-is-in-one-picture">What a plugin is, in one picture</h3>
|
||||
<pre class="plain"><code>plugins/stats/ Bob's plugin
|
||||
package.json { "name": "@bob/stats", "dependencies": { "words-count": "2.1.0" } }
|
||||
worker.ts runs in each project's worker: counts words
|
||||
web.tsx runs in each tab: the panel</code></pre>
|
||||
<p>The server builds every plugin's files into one script per host kind (esbuild, a few milliseconds each). Each
|
||||
process evaluates the scripts of its kind: the worker runs <code>worker.ts</code>'s script, a tab runs <code>web.tsx</code>'s. That much
|
||||
is built (slices 7c and 10).</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="four"><header class="opener"><div class="numeral">I</div><h2>Four Things a Plugin Depends On</h2><p class="lede">Who changes each one, and when the plugin finds out.</p></header>
|
||||
<p class="dropcap">Look at the top of Bob's <code>worker.ts</code>:</p>
|
||||
<pre><code><span class="tk-k">import</span> countWords <span class="tk-k">from</span> <span class="tk-s">"words-count"</span>; <span class="tk-c">// 1. an npm package</span>
|
||||
<span class="tk-k">import</span> { defineFacet, <span class="tk-t">Project</span> } <span class="tk-k">from</span> <span class="tk-s">"@acme/app"</span>; <span class="tk-c">// 2. the app's base code</span>
|
||||
<span class="tk-k">import</span> { obj, num, remote } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>; <span class="tk-c">// 3. Chord</span>
|
||||
<span class="tk-k">import</span> { <span class="tk-t">Notes</span> } <span class="tk-k">from</span> <span class="tk-s">"@acme/notes/contract"</span>; <span class="tk-c">// 4. another plugin</span></code></pre>
|
||||
<p>Each import is a different kind of dependency, and they behave differently over time:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Import</th>
|
||||
<th>Who changes it</th>
|
||||
<th>What Bob's plugin gets today</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td><code>words-count</code></td>
|
||||
<td>its npm author</td>
|
||||
<td>the version installed in Bob's plugin directory, copied into his script: frozen until Bob updates it</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>@acme/app</code></td>
|
||||
<td>Acme, when it releases the app</td>
|
||||
<td>whatever the running app is: always the newest</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>@earendil-works/chord/next</code></td>
|
||||
<td>Chord, through Acme's upgrade</td>
|
||||
<td>whatever the running app ships: always the newest</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>@acme/notes/contract</code></td>
|
||||
<td>Acme, whenever it edits <code>notes</code></td>
|
||||
<td>the running <code>notes</code> plugin's file, copied at Bob's next build: always the newest</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<p>The first row is safe: npm packages are copied into the script at the version Bob installed, so they never change
|
||||
under him. The other three rows always give the newest, and Bob's March code meets November's app. Whether that
|
||||
works depends on what changed. The rest of the primer is about those three rows.</p>
|
||||
<div class="note"><b class="tag">Why not copy base code and Chord into the script too</b><p>Because they must be shared. If Bob's script carried its own copy of Chord, its <code>defineFacet</code> would make facets the
|
||||
worker's host does not recognize, and its <code>Project</code> host kind would be a different object from the app's. Base code
|
||||
and Chord are <strong>host packages</strong>: the process provides one instance and every plugin uses it. That is decided and
|
||||
built. It is also why they can change under a plugin.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="base"><header class="opener"><div class="numeral">II</div><h2>Base Code: Versions in the Import Path</h2><p class="lede">Decided: a plugin says which app API it was written for, where it imports it.</p></header>
|
||||
<p class="dropcap">In June, Acme renames the host kind <code>Project</code> to <code>Workspace</code>. Bob's March plugin imports <code>Project</code>; it no longer
|
||||
exists. Without any versioning, Bob's plugin builds (the build does not type check), then fails when it runs with
|
||||
something like "Cannot read properties of undefined", deep inside his code.</p>
|
||||
<p>Decided on 2026-10-31 (decision "API versions in the import path"): the app exposes only <strong>API modules</strong>, each with a
|
||||
version in its path, and lists the versions it still provides.</p>
|
||||
<pre><code><span class="tk-c">// Bob, in March</span>
|
||||
<span class="tk-k">import</span> { defineFacet, <span class="tk-t">Project</span> } <span class="tk-k">from</span> <span class="tk-s">"@acme/app/v2"</span>;
|
||||
|
||||
<span class="tk-c">// Acme's app, after June</span>
|
||||
apiVersions: { <span class="tk-s">"@acme/app/v3"</span>: apiV3, <span class="tk-s">"@acme/app/v2"</span>: apiV2 } <span class="tk-c">// still provides v2, through a thin layer over v3</span></code></pre>
|
||||
<p>Now there are two cases, both visible:</p>
|
||||
<ul>
|
||||
<li>Acme still provides <code>/v2</code>: Bob's plugin gets the v2 module, in which <code>Project</code> still exists (Acme's thin layer maps
|
||||
it to <code>Workspace</code>). It works.</li>
|
||||
<li>Acme dropped <code>/v2</code>: Bob's plugin fails to build with "@acme/app/v2 is not available: this app provides v3". The
|
||||
plugin is left out (per plugin: nothing else breaks), and the message tells Bob, or an agent, exactly what to
|
||||
change.</li>
|
||||
</ul>
|
||||
<p>Nothing outside the code says what Bob depends on: no <code>package.json</code> field, no version ranges. The import says it.</p>
|
||||
<p><strong>What is still open here</strong> is what goes into the API and how Chord relates to it. That is point 2 below.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="contracts"><header class="opener"><div class="numeral">III</div><h2>Other Plugins: the Contract Problem</h2><p class="lede">Why importing another plugin by name gives the wrong answer for a stranger's plugin.</p></header>
|
||||
<p class="dropcap">Bob's <code>stats</code> asks Acme's <code>notes</code> for the notes of a project. Plugins talk only through <strong>contracts</strong> (the type of an
|
||||
object: its state and methods) and registries. A contract has its own version:</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/contract.ts (Acme), March</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Notes</span> = <span class="tk-f">remote</span>(<span class="tk-s">"notes"</span>, {
|
||||
version: <span class="tk-n">1</span>,
|
||||
params: { project: <span class="tk-f">str</span>() },
|
||||
hostAddress: ({ project }) => <span class="tk-t">Project</span>({ id: project }),
|
||||
variants: { reader: { state: { list: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-f">str</span>()) }) } } },
|
||||
});</code></pre>
|
||||
<p>Bob's plugin imports this file, and the build copies it into his script. When his plugin asks for <code>Notes</code>, it says
|
||||
"version 1, this shape"; the provider (the <code>notes</code> plugin's code in the worker) checks that it can serve that version.</p>
|
||||
<p>In September Acme changes notes: each note gets a timestamp, <code>items: arr(obj({ text: str(), at: int() }))</code>, and the
|
||||
contract becomes version 2. Acme also writes an <code>accepts</code> mapping, so consumers of version 1 still get a list of
|
||||
strings. Chord's contract versioning exists for exactly this case.</p>
|
||||
<p>Now the question is: which version of the contract file ends up in Bob's script?</p>
|
||||
<h3 id="today-by-name-the-newest-file">Today: by name, the newest file</h3>
|
||||
<p>Decided this morning and built (decision "Plugins import each other by name"): <code>@acme/notes/contract</code> resolves to the
|
||||
running <code>notes</code> plugin's <code>contract.ts</code>. Trace what happens at Bob's next build after September:</p>
|
||||
<div class="steps"><div class="steps-head"><span class="title">A rebuild after Acme's change</span><span class="count"></span><button class="prev" type="button">Back</button><button class="next" type="button">Next</button></div><div class="step-panel"><h4>The build copies the new file</h4><p>Bob's <code>stats</code> is rebuilt (a rollout, a restart, any edit anywhere). <code>@acme/notes/contract</code> is now the version 2 file,
|
||||
and it is copied into Bob's script.</p>
|
||||
</div><div class="step-panel"><h4>Bob's plugin claims version 2</h4><p>At runtime his plugin asks for <code>Notes</code> version 2. The provider serves version 2 gladly: no <code>accepts</code> is used.</p>
|
||||
</div><div class="step-panel"><h4>Bob's code reads version 1's shape</h4><p>Bob's code does <code>list.items.join(" ")</code> and <code>countWords</code> on it. The items are now objects. He counts the words of
|
||||
<code>"[object Object] [object Object]"</code>. Nothing fails; the panel shows wrong numbers.</p>
|
||||
</div></div>
|
||||
<p>The contract version was supposed to protect Bob, and it did nothing: copying the newest file made Bob's plugin claim
|
||||
a version his code was never written for.</p>
|
||||
<h3 id="proposed-the-version-bob-wrote-against">Proposed: the version Bob wrote against</h3>
|
||||
<p>The contract file must come from where Bob's code came from: the state of the world he wrote against. For a stranger's
|
||||
plugin that is an npm dependency at a fixed version:</p>
|
||||
<pre><code><span class="tk-c">// plugins/stats/package.json</span>
|
||||
{ <span class="tk-s">"name"</span>: <span class="tk-s">"@bob/stats"</span>, <span class="tk-s">"dependencies"</span>: { <span class="tk-s">"@acme/notes-contracts"</span>: <span class="tk-s">"1.0.0"</span> } }</code></pre>
|
||||
<p>Now Bob's script carries the March contract file forever (npm packages are copied at the installed version, as row 1
|
||||
of the first table). His plugin asks for version 1; the provider maps it through <code>accepts</code>; Bob gets strings as
|
||||
before. If Acme ever stops accepting version 1, Bob's plugin waits with "contract mismatch: v1, served v3", visible in
|
||||
the UI, never wrong numbers.</p>
|
||||
<p>For Acme's own plugins (<code>search</code> using <code>notes</code>) the problem does not exist: they live in one repository and change in
|
||||
the same commit. They import each other by relative path, <code>../notes/contract.ts</code>, the same file at the same revision.
|
||||
The by-name rule also needed path aliases in the TypeScript configuration for every plugin (six were added for the
|
||||
tests and examples of slice 10); relative paths need none.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="open"><header class="opener"><div class="numeral">IV</div><h2>The Open Points</h2><p class="lede">Eight decisions, each with the problem it answers and a recommendation.</p></header>
|
||||
<h3 id="1-reverse-quot-import-by-name-quot">1. Reverse "import by name"</h3>
|
||||
<p><strong>Problem:</strong> the trace above: a stranger's plugin silently claims a contract version it was not written for.
|
||||
<strong>Proposal:</strong> the app's own plugins import each other relatively (<code>../notes/contract.ts</code>); everyone else gets
|
||||
contracts as an npm dependency at a fixed version. Importing a running plugin by name is removed; a relative path
|
||||
leaving the plugin's root stays a build error.
|
||||
<strong>Recommendation:</strong> do it, in the plugin slice (slice 10 is committed with by-name imports; the change is small:
|
||||
<code>build.ts</code> and the examples' imports).</p>
|
||||
<h3 id="2-what-the-app-39-s-api-is-and-chord-in-it">2. What the app's API is, and Chord in it</h3>
|
||||
<p><strong>Problem:</strong> a plugin imports two host packages that both change, <code>@acme/app/v3</code> and <code>@earendil-works/chord/next/v2</code>.
|
||||
Acme must then say which combinations work: does a plugin importing app v3 and Chord v1 still run? Two version
|
||||
numbers per plugin, and a matrix nobody tests.
|
||||
<strong>Proposal:</strong> plugins import only the app's API. The app re-exports the part of Chord it supports:</p>
|
||||
<pre><code><span class="tk-c">// src/api/v3/index.ts (Acme's base code)</span>
|
||||
<span class="tk-k">export</span> { obj, arr, str, int, remote, local, defineRegistry } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||||
<span class="tk-k">export</span> { <span class="tk-t">Workspace</span>, <span class="tk-t">Panels</span>, defineFacet } <span class="tk-k">from</span> <span class="tk-s">"../../core.ts"</span>;</code></pre>
|
||||
<p>One number, the app's, describes everything a plugin gets from its host. When Acme upgrades Chord in a way that
|
||||
breaks plugins, Acme bumps its API version, and decides whether <code>/v2</code> survives as a thin layer.
|
||||
<strong>Generations:</strong> if the app provides <code>/v3</code> and <code>/v2</code>, a facet must import from one of them only, never both (the
|
||||
build checks it), because <code>/v2</code>'s <code>Panels</code> and <code>/v3</code>'s <code>Panels</code> may be different registries.
|
||||
<strong>Recommendation:</strong> yes; app kit apps get it for free (<code>chord/next/app</code> provides the API module).</p>
|
||||
<h3 id="3-the-api-split-by-host-kind">3. The API split by host kind</h3>
|
||||
<p><strong>Problem:</strong> the API module holds everything plugins need, including web things: a <code>Sheet</code> component, a theme, the
|
||||
<code>Panels</code> registry whose entries are React components. A worker plugin imports <code>@acme/app/v3</code> for <code>Workspace</code>, and the
|
||||
worker process has to load the module, so it loads React.
|
||||
<strong>Proposal:</strong> <code>/v3</code> holds what every host may use (contracts, registry tokens, host kinds, principals) and imports no
|
||||
UI library at runtime; <code>/v3/web</code> and <code>/v3/tui</code> hold UI pieces. A worker facet importing <code>/v3/web</code> is a build error.
|
||||
<strong>Recommendation:</strong> yes.</p>
|
||||
<h3 id="4-host-kinds-on-registry-tokens">4. Host kinds on registry tokens</h3>
|
||||
<p><strong>Problem:</strong> Bob contributes his panel from <code>worker.ts</code> by mistake (<code>self.contribute(Panels, …)</code>). The worker has no
|
||||
<code>Panels</code> registry; the contribution sits in a table nobody reads. Nothing fails; the panel never appears.
|
||||
<strong>Proposal:</strong> a token names the kinds that serve it, <code>defineRegistry("app.panels", { version: 1, hosts: ["web"] })</code>;
|
||||
contributing from another kind is a type error (the facet's <code>self</code> is typed per kind) and a load error.
|
||||
<strong>Recommendation:</strong> yes; together with point 3, <code>Panels</code> lives in <code>/v3/web</code> anyway.</p>
|
||||
<h3 id="5-contracts-for-third-parties">5. Contracts for third parties</h3>
|
||||
<p><strong>Problem:</strong> with point 1, Bob needs Acme's plugins' contracts as an npm package. Which package?
|
||||
<strong>Options:</strong> (a) the app's API exports the contracts of its own plugins that others may use (<code>@acme/app/v3</code> has
|
||||
<code>Notes</code>): one place, versioned with the app; (b) Acme publishes its app package to npm and Bob depends on it; (c) each
|
||||
plugin publishes its own contracts package. (c) is what the owner called unpleasant.
|
||||
<strong>Recommendation:</strong> (a) for contracts Acme wants others to use: the API is the app's promise. Note: in (a) the
|
||||
contract file comes with the API version, so Bob's v2 import keeps v2's contract files (the thin layer keeps them).</p>
|
||||
<h3 id="6-testing-a-plugin-alone">6. Testing a plugin alone</h3>
|
||||
<p><strong>Problem:</strong> testing Bob's <code>stats</code> needs a worker with a <code>Notes</code> provider, an app API, a store.
|
||||
<strong>Proposal:</strong> <code>testPlugin("plugins/stats", { kind: "worker", stubs: [notesStub] })</code> builds the plugin as production
|
||||
does, loads it on an in-memory host with the kind's real host packages, and loads stub facets that serve the
|
||||
contracts it uses. A stub is an ordinary facet (<code>host.serve(Notes, () => ({ list: constant({ items: ["a b"] }) }))</code>).
|
||||
<strong>Recommendation:</strong> yes, in the app kit.</p>
|
||||
<h3 id="7-single-file-plugins">7. Single-file plugins</h3>
|
||||
<p><strong>Problem:</strong> a plugin that adds one route needs a directory and a <code>package.json</code> with a name.
|
||||
<strong>Proposal:</strong> <code>plugins/hello.server.ts</code> is a plugin named <code>hello</code> with one server facet, no dependencies; when it
|
||||
needs a package or a second file, it becomes a directory.
|
||||
<strong>Recommendation:</strong> yes, cheap; agents write many such plugins.</p>
|
||||
<h3 id="8-the-app-39-s-own-plugins-follow-the-same-rules">8. The app's own plugins follow the same rules</h3>
|
||||
<p><strong>Problem:</strong> are Acme's built-in plugins special?
|
||||
<strong>Proposal:</strong> no. They import the app's API like everyone (not base code's internals), import each other relatively
|
||||
(point 1), are built at startup and reload like any plugin. The only difference: they change in the same commit as
|
||||
base code, so they always use the newest API version.
|
||||
<strong>Recommendation:</strong> yes. It keeps base code small and means every built-in plugin is an example of a correct plugin.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter epilogue" id="what-to-decide"><header class="opener"><div class="numeral">∴</div><h2>What to Decide</h2><p class="lede">In the order that makes each answer easier.</p></header>
|
||||
<ol>
|
||||
<li>Reverse "import by name" (recommended: yes, in the plugin slice).</li>
|
||||
<li>Plugins import only the app's API; Chord is re-exported in it; one generation per facet (recommended: yes).</li>
|
||||
<li>Split the API by host kind (recommended: yes).</li>
|
||||
<li>Host kinds on registry tokens (recommended: yes).</li>
|
||||
<li>Contracts for third parties through the app's API (recommended: (a)).</li>
|
||||
<li><code>testPlugin</code> with stub facets (recommended: yes).</li>
|
||||
<li>Single-file plugins (recommended: yes).</li>
|
||||
<li>Built-in plugins follow the same rules (recommended: yes).</li>
|
||||
</ol>
|
||||
<p>Later, not part of these: project plugins (per working directory, with trust), and installing plugins from npm or git.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter appendix" id="words"><header class="opener"><div class="numeral">A</div><h2>Words</h2></header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Word</th>
|
||||
<th>Meaning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td>base code</td>
|
||||
<td>the app's own program, started by <code>node server.ts</code>; never reloads</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>API module</td>
|
||||
<td>the part of base code plugins may import, one per version (<code>@acme/app/v3</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>host package</td>
|
||||
<td>a module the process provides to every plugin (Chord, the API modules, React on the web)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>contract</td>
|
||||
<td>an object type: its state and methods, with a version; consumers name the version they use</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>accepts</code></td>
|
||||
<td>the provider's mapping that serves an older contract version</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>registry token</td>
|
||||
<td>an extension point (<code>Panels</code>); plugins contribute to it</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>facet</td>
|
||||
<td>a plugin's code for one host kind (<code>worker.ts</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>generation</td>
|
||||
<td>one set of API versions provided together (<code>/v3</code> with its web and tui modules)</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<p class="colophon">The Plugin Model, a Primer · first edition · 2026-10-31</p>
|
||||
</div></div>
|
||||
<script>(function activate(root) {
|
||||
const replay = (f) => {
|
||||
f.classList.remove("play");
|
||||
void f.getBoundingClientRect();
|
||||
f.classList.add("play");
|
||||
};
|
||||
const io = new IntersectionObserver(
|
||||
(es) => {
|
||||
for (const e of es)
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add("play");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.3 },
|
||||
);
|
||||
root.querySelectorAll(".anim:not(.armed)").forEach((f) => {
|
||||
f.classList.add("armed");
|
||||
io.observe(f);
|
||||
const b = f.querySelector(".replay");
|
||||
if (b) b.onclick = () => replay(f);
|
||||
});
|
||||
root.querySelectorAll(".steps:not(.js)").forEach((s) => {
|
||||
s.classList.add("js");
|
||||
const ps = [...s.querySelectorAll(":scope>.step-panel")];
|
||||
let i = 0;
|
||||
const c = s.querySelector(".count"),
|
||||
p = s.querySelector(".prev"),
|
||||
n = s.querySelector(".next");
|
||||
const show = () => {
|
||||
ps.forEach((x, j) => x.classList.toggle("on", j === i));
|
||||
c.textContent = `${i + 1} / ${ps.length}`;
|
||||
p.disabled = i === 0;
|
||||
n.disabled = i === ps.length - 1;
|
||||
ps[i].querySelectorAll(".anim").forEach(replay);
|
||||
};
|
||||
p.onclick = () => {
|
||||
i--;
|
||||
show();
|
||||
};
|
||||
n.onclick = () => {
|
||||
i++;
|
||||
show();
|
||||
};
|
||||
show();
|
||||
});
|
||||
})(document)</script>
|
||||
</body></html>
|
||||
285
packages/chord/docs/next/slice-10.md
Normal file
285
packages/chord/docs/next/slice-10.md
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
# Slice 10: HTTP and workers (spec)
|
||||
|
||||
Status: **built** (2026-10-31; build reviews by Fable, Opus, Astra and Sol, security audits by Fable, Opus and Astra, four fix rounds with rechecks; the shape after the fix rounds is in §2, §3 and design §23). Approved by the owner (2026-10-30: "just build it"; D1-D12, D10b, round 1's R1-R6 and round 2's Q1-Q6 in the
|
||||
decision "Slice 10's points"; spec reviews round 1 by Fable and Opus, `/tmp/s10-spec-review-{fable,opus}.md`, round 2
|
||||
by Fable, Opus, Astra and Sol, `/tmp/s10-spec-review2-*.md`, folded in).
|
||||
Slice 10 builds two Node components of the app kit (decision "Slice 7b's open points" (6)): `chord/next/http` (the
|
||||
server's HTTP and WebSocket entry, routes contributed by plugins through a registry, responses as descriptions) and
|
||||
`chord/next/workers` (a worker process per host address, started on demand, stopped when idle, its plugins shipped from
|
||||
the server). Both live in `packages/chord/src/next-node/` (Node-only); the kernel stays portable. The web page host and
|
||||
the TUI are slice 11, telemetry slice 9.
|
||||
|
||||
Read in full before writing code: this spec; `decisions.md`; `design.md` (§3 hosts, routes, `host.activity`, §17,
|
||||
§18, §21 "Loading plugins and the reload loop", §23 in full); `slices.md`; `slice-7c.md` and
|
||||
`src/next-node/plugins/README.md`; the 7b brief `slice-7b/book.md` (chapters "Assets and Files", "HTTP", "A Mini p2
|
||||
from Chord"); p2's `src/hosts/server.ts`, `src/runtime/server.ts`, `src/hosts/worker.ts`, `src/hosts/socket-limits.ts`,
|
||||
`src/hosts/principals.ts`, `plugins/files/server.ts`, `plugins/terminals/server.ts`, `plugins/radius/server.ts`; Chord
|
||||
v1's `src/manager.ts` (`processLock`, `forkDriver`, `createHostManager`, `childControl`).
|
||||
|
||||
## 1. What p2 has, and what moves into Chord
|
||||
|
||||
p2's server host (`src/hosts/server.ts`, 742 lines; `src/runtime/server.ts`, 720) does, besides p2's own policy:
|
||||
|
||||
| p2 today | Moves to |
|
||||
| --- | --- |
|
||||
| a Node `http` server on loopback, requests parked until `load` resolved, upgrades captured before listening | `http` |
|
||||
| `Routes` (`"METHOD /path"`, `/prefix/*`), handlers writing Node's `ServerResponse` themselves (ranges, download headers, `HEAD` by hand in `files`) | `http`: routes returning descriptions |
|
||||
| `Upgrades` (a WebSocket by path for terminals and voice), `/chord` (`acceptWebSockets` with per-socket limits) | `http` |
|
||||
| `Authenticators`, `RequestResolvers` (cookies, devices, the Radius relay), Fetch Metadata, CSP and the loopback cookie | the app (p3's base code), through `http`'s `authenticate` and `headers` (D3) |
|
||||
| loopback and allowed hosts, the `Origin` check on upgrades | `http` (R4), allowed hosts contributable by plugins |
|
||||
| `SessionHosts`: a worker per session, `createHostManager` + `forkDriver`, a kernel lock per session, idle stop from reported counts, crash notices, sticky failed starts, ready timeout | `workers` (failures stay until Retry or a rollout, R2) |
|
||||
| relocation and pause of a worker, the catalog, `WorkerInitValues` | the app (built on `workers`' `stop` and start data) |
|
||||
| `hosts/worker.ts`: the child's control channel, lock, start data, connect, load, report counts, shutdown | `workers`' child side |
|
||||
| the dev reload across server and workers, worker plugin lists | `plugins` (7c) plus `workers`' rollout step (§4) |
|
||||
|
||||
Every option that is not bootstrap is changeable by plugins (decision "The app kit is malleable by plugins"): either a
|
||||
contribution to `Http` or a function the component calls per use, which the app backs with its own registries;
|
||||
nothing is copied out of a contribution or an option at startup. Bootstrap (changed by a restart): the listen address
|
||||
and port, the links path, the worker entry with its `execArgv` and `env`, the host kind workers serve.
|
||||
|
||||
## 2. `chord/next/http`
|
||||
|
||||
As built after the fix rounds (decisions "The HTTP core is a fetch handler", "The HTTP server is a native object",
|
||||
"Settings are arguments, extensions are registries", "The HTTP component's security baseline", "allowed hosts only
|
||||
through contributions", "A CSP nonce per response", "three small points", "a route's overhead accepted"); design §23
|
||||
states every rule, `src/next-node/http/README.md` the API.
|
||||
|
||||
```ts
|
||||
const http = createHttp(server, {
|
||||
listen: { host: "127.0.0.1", port: 8788 }, // settings: arguments, a restart to change
|
||||
links: { path: "/chord", options: (request, principal) => ({ limits: linkLimits(principal), maxPayload: 64 * MiB }) },
|
||||
closeTimeout: 30_000,
|
||||
onError: (error) => log(error), // `HttpError`, below
|
||||
}, ctx);
|
||||
const app = defineFacet((_host, self) => {
|
||||
self.contribute(Authenticators, (request, ctx) => principalFromCookie(readCookie(request, "session", secret)));
|
||||
self.contribute(Http, { hosts: ["notes.example.com"], relays: [radiusRelay] });
|
||||
});
|
||||
await plugins.load(server, { kind: "server", core: [{ name: "chord.http", facet: http.facet }, { name: "@app/core", facet: app }], modules }, ctx);
|
||||
…
|
||||
await http.close(ctx); // first: no new requests, sockets closed, running requests waited for up to `closeTimeout`
|
||||
await workers.close(ctx);
|
||||
await server.close(ctx);
|
||||
```
|
||||
|
||||
- **The core** is `http.fetch(request, { localAddress?, localPort?, remoteAddress? })`, a Web `Request` to a Web
|
||||
`Response`; `http.upgrade` is Node's upgrade adapter; `nodeRequest(message, res)` converts a Node request with the
|
||||
listener's checks (`{ kind: "request", request, connection } | { kind: "refused", status }`, never a throw) for apps
|
||||
mounting on their own Node server. Chord's own listener (with `listen`) is a host-lifetime object of `chord.http`
|
||||
whose native is a Node server, on a fast path that skips the Web `Request` unless a route or authenticator reads
|
||||
it.
|
||||
- **Registries:** `Http` (`"chord.http"`, version 1: `routes`, `wraps`, `sockets`, `maxPayload`, `hosts`, `framers`,
|
||||
`relays`, `csp`; a value or a writable) and `Authenticators` (in contributor order, the first principal wins).
|
||||
Requests before the publication wait for it (503 past `readyTimeout`).
|
||||
- **Admission, foreign requests, origins, default headers, cookies, downloads:** the security baseline (design §23).
|
||||
Refusals before any plugin code: a non-loopback socket without an allowed host, forwarded headers without a
|
||||
verified relay, duplicate or missing `Host`, userinfo in `Host`, absolute-form targets (403), unparsable targets
|
||||
(400), `TRACE`/`TRACK`/`CONNECT` (501; a route for them refused at compose).
|
||||
- **A request's scope, `HttpRequest`, patterns, wraps, responses, `fileResponse`, sockets, links, revocation,
|
||||
`close`:** design §23. The spec's earlier text (an `httpServer` that listens first, `allowedHosts`, one
|
||||
`authenticate` and a `headers` option, 64 KiB reads) is superseded by those decisions.
|
||||
- **Errors** (`onError`, `HttpError`): `route_failed { pattern, plugin, error }`, `wrap_failed { name, plugin, error }`,
|
||||
`socket_failed { pattern, plugin, error }`, `response_failed { pattern, error }`, `hook_failed { hook, plugin?,
|
||||
error }` (`authenticate`, `relay`, `links.options`).
|
||||
- `ws` is an optional peer dependency (D7), imported only here.
|
||||
|
||||
## 3. `chord/next/workers`
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
const workers = createWorkers(server, Session, {
|
||||
kind: "worker", // the facet kind it ships
|
||||
entry: "./worker.ts", execArgv: ["--import", resolver], env: { … }, // bootstrap
|
||||
authorize: (principal, address) => app.mayReach(principal, address), // the route's `authorize` (synchronous)
|
||||
principal: (address) => ({ kind: "worker", session: address.params.id }),
|
||||
start: (address, ctx) => app.startData(address, ctx), // JSON, before each start
|
||||
lock: (address) => `app:${realpath(dataDir)}:worker:${address.key}`, // one live worker per address
|
||||
idleMs: () => app.idleMs(),
|
||||
plugins, // the plugins component
|
||||
onOutput: (address, stream, chunk) => logs.append(address.key, stream, chunk),
|
||||
onError: (error) => log(error), // `WorkersError`, below
|
||||
}, ctx);
|
||||
workers.state // JSON watchable (R5)
|
||||
await workers.stop(Session({ id }), ctx); // archive: suspended while held (Q3), then stopped
|
||||
await workers.retry(Session({ id }), ctx); // the Retry button: a failed or suspended address resolves again
|
||||
await workers.close(ctx); // no new starts, every child stopped
|
||||
```
|
||||
|
||||
```ts
|
||||
// worker.ts
|
||||
await workerMain(async ({ address, start, parent, plugins, onClose }, ctx) => {
|
||||
const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(join(start.dir, "session.sqlite"))), ctx);
|
||||
onClose((ctx) => store.close(ctx)); // closers run in reverse order
|
||||
const worker = createHost<P>({ address, principal: { kind: "worker", session: start.id }, store, onError });
|
||||
onClose((ctx) => worker.close(ctx));
|
||||
worker.connect(parent, { principal: { kind: "server" }, trusted: true }, ctx); // `started` goes out now: connect
|
||||
// before anything slow (a store opens in ms)
|
||||
await plugins.load(worker, { kind: "worker", core: [{ name: "@app/core", facet: coreWorker(start) }] }, ctx);
|
||||
return { busy: harness.activeTasks }; // extra keep-alive counts
|
||||
}, {
|
||||
hostPackages: { "@earendil-works/chord/next": chordNext, "@app/core": appCore }, // the child's own instances
|
||||
redact: (error) => ({ message: redactSecrets(String(error)), ...codeOf(error) }), // what crosses as `fatal`
|
||||
onError: (error) => log(error),
|
||||
});
|
||||
```
|
||||
|
||||
- **The route:** `createWorkers` calls `host.route(Kind, { authorize, resolve }, ctx)` itself and keeps the
|
||||
`RouteHandle` (`retry`, `suspend`: D10b, R1). `authorize` is synchronous (the kernel's): a plugin backing it keeps an
|
||||
index.
|
||||
- **Per address, events and operations:** a child's events (its exit, its link's close, `started`, `ready`, `fatal`)
|
||||
are observed as they happen and recorded at once on that child (a late event of an old child never changes a new
|
||||
one); the operations (start, stop) run in one queue per address: a start waits for the previous child's exit, a
|
||||
stop during a start waits for it then stops, and a start queued behind an exit checks the address's state after it
|
||||
(a crash recorded meanwhile makes it reject with that failure). Stops record their intent before `shutdown`, so the
|
||||
exit they cause is no crash.
|
||||
- **A start** (`resolve(address)`): calls `start(address, ctx)` (abortable; a throw rejects the start), checks that the
|
||||
component is not closing, forks `entry` (`serialization: "advanced"`, a detached process group, stdout and stderr
|
||||
piped: the stderr tail kept, mirrored to the server's when there is no `onOutput`), makes `ipcLink(child)` at once
|
||||
(so the child's `hello` and early holds are queued, never lost), and sends the start message `{ address, start,
|
||||
scripts, lock }` (`scripts`: the worker kind's complete current set, read when the message is sent). The child takes
|
||||
the lock (when an old worker still holds it, it waits by connecting to the lock's socket and trying again when that
|
||||
connection closes: no polling), and sends `started` when the app's host sets its listener on `parent` (`parent` is
|
||||
a thin wrapper of the IPC link, so `connect` is the signal: the app connects before anything slow). `resolve` then
|
||||
returns `{ link, principal: principal(address), trusted: true }`, and the handshake and the worker's server traffic
|
||||
run during its `load`. The start timer (60 s from the fork) covers fork, lock and connect only (Q4): past it the tree is
|
||||
killed and the start fails. `ready` (after the app's function returned, carrying the idle state and its load report) sets `state` to
|
||||
`running` and lets idle stops begin; a `load` that never ends shows `starting` until `stop`.
|
||||
- **Workers never restart by themselves** (R2): a crash (an exit nobody asked for), a `fatal`, a link lost while its
|
||||
child lives, or a failed start (`start` threw, the start timer, a lock not taken in time) leaves the address `failed`
|
||||
with its error (`worker_failed` reported once). A link lost while its child lives (the heartbeat after a blocked
|
||||
event loop, a protocol error) starts the stop: the grace (5 s), then the tree is killed; its exit is the crash ("link
|
||||
lost"). `resolve` of a failed address rejects at once with that error; its holders and new holds see `failed {
|
||||
waiting }` with it as cause (Q1) and stay on the address's path, nothing forks. `workers.retry(address)`
|
||||
(`RouteHandle.retry`) or a rollout reaching workers clears it (Q2, §4). An idle stop is no failure: the next hold
|
||||
starts the worker on demand.
|
||||
- **Keep-alive** (D9, R3, Q5): the child's idle state is all of `host.activity`'s `references`, `jobs` and `lingering`
|
||||
and the app's `busy` (a JSON watchable of numbers) at zero, not `calls` (a remote call runs on a hold; local calls
|
||||
come from routines, jobs and tasks counted elsewhere). `ready { idle }` carries the first state, then `{ idle }` on
|
||||
each flip; a rollout applying in the child counts as busy. The server stops a worker idle since `idleMs()` (5 min by
|
||||
default; checked every second), never before `ready`. A stop (idle, `stop`, `close`): `shutdown`; the child aborts its
|
||||
`ctx`, runs the closers registered with `onClose` in reverse order and exits, forcing its own exit after 3 s; the
|
||||
server kills the tree after 5 s.
|
||||
- **`state`** (R5, decision "worker errors in public"): per address key `{ status: "starting" | "running" |
|
||||
"stopping", pid, startedAt }` or `{ status: "failed", error: { message, code?, reason? }, at }` with a public error
|
||||
(a fixed message, or the child's redacted `fatal`); no entry when stopped or never started. The full error and the
|
||||
stderr tail go only to `onError` (`worker_failed`); every app hook of a start (`start`, `principal`, `lock`, the
|
||||
fork) is inside that boundary.
|
||||
- **`stop(address, ctx)`** (R1, Q3): `RouteHandle.suspend(address)`, then the stop. The suspension lasts while the
|
||||
address has holders (they see `failed { waiting }`, "suspended"); with the last holder it goes, and a later hold
|
||||
starts the worker on demand; `retry(address)` lifts it at once. Whether an archived session may be opened is the
|
||||
app's (`authorize`, its views).
|
||||
- **`close(ctx)`:** cancels active and queued starts first (a `start(address)` waiting on the app's barrier is
|
||||
aborted; nothing forks after it), then stops every child (grace, then kill), clears the timers; new starts reject
|
||||
`released/closed`; the caller's cancellation ends only its own wait. `createWorkers`'s `ctx` aborting (the host
|
||||
closing aborts the route's context) closes it too, and from then on every exit is a stop, never a crash (no
|
||||
`worker_failed` at shutdown).
|
||||
- **The child** (`workerMain(fn, { hostPackages, redact, onError })`): reads the start message, takes the lock, makes
|
||||
`ipcLink(process)` and its wrapper `parent`, creates its plugin runtime with `hostPackages` (the child's own module
|
||||
instances; functions cannot cross IPC), calls `fn({ address, start, parent, plugins, onClose }, ctx)`. `shutdown`,
|
||||
the parent's disconnect, `SIGTERM` and `SIGINT` alike abort `ctx` and run the closers registered so far, also while
|
||||
`fn` still runs (its startup is cancelled through `ctx`). An uncaught error, or `fn` throwing, sends `fatal` with
|
||||
`redact(error)` (never the original message) and exits; after `started` that is a crash like any other.
|
||||
- **Control messages** (JSON beside the link's `Uint8Array` frames; the IPC link skips them): parent to child `start`,
|
||||
`shutdown`, `reload { scripts }`; child to parent `started`, `ready { idle }`, `idle { idle }`, `fatal { error }`,
|
||||
`reloaded { report } | { error }`. One `reload` is outstanding per child at a time: a reload whose caller aborted
|
||||
keeps the child's slot until its `reloaded` or the child's exit, and the next waits for it. Errors cross as `{
|
||||
message, code?, reason? }`.
|
||||
- **Errors** (`onError`, `WorkersError`, which includes `BatchError` for a `state` watch listener that throws): `worker_failed { address, error, stderrTail? }` (private),
|
||||
`authorize_failed { address, error }` (a throwing `authorize`: the hold is refused `forbidden`), `worker_exited {
|
||||
address, code, signal }` (only for an exit nobody asked for: not after a stop or a start the component failed).
|
||||
|
||||
### Plugins in workers (D11)
|
||||
|
||||
Workers never build. The plugins component (7c) gets `scripts(kind, ctx)`: the complete current set of one kind (every
|
||||
discovered plugin's facet of it, built on first use, then updated by every rollout that ships that kind), each with its
|
||||
plugin record (`name`, `dir`, `chord.external`). The component's per-host loading (its loaded map, `#prepare`, `adopt`,
|
||||
the queue per host) moves into a file without esbuild or the watcher, `createPluginRuntime()`
|
||||
with `load(host, { kind, core, scripts, modules }, ctx)` (`modules`: the host packages' module objects of this process) and `reload(host, scripts, ctx)` (a complete set: removed = loaded −
|
||||
shipped; same digests unchanged); `createPlugins` uses it, and the child's `plugins` is a `WorkerPlugins` over one
|
||||
(`load(host, { kind, core })` loads the start message's scripts; `reload` messages wait until `fn` returned); `import`
|
||||
of `chord/next/plugins/runtime` loads no esbuild. Each kind is defined once in `createPlugins({ kinds: { <kind>: {
|
||||
platform, hostPackages } } })` (decision "Slice 11's points", Kinds).
|
||||
|
||||
## 4. The rollout across server and workers
|
||||
|
||||
```ts
|
||||
const built = await plugins.build(plugins.state.value.dirty, ctx);
|
||||
const result = await plugins.rollout([plugins.step("server", server), workers.step("workers")], built, ctx);
|
||||
// result.steps: [{ name: "server", hosts: [{ host: "server", report }] },
|
||||
// { name: "workers", hosts: [{ host: "session|S1", report }, { host: "session|S2", stopped: true },
|
||||
// { host: "session|S3", error }] }]
|
||||
```
|
||||
|
||||
`RolloutStep.reload(built, ctx)` returns `{ hosts: readonly ({ host; report } | { host; stopped: true } | { host; error
|
||||
})[] }` (D12; 7c's in-process step returns a list of one, its `host` the name given to `step`). `workers.step(name)`:
|
||||
|
||||
1. replaces the workers' current set (`plugins.scripts(kind)` updated from `built`);
|
||||
2. clears the failures recorded before this rollout and calls `RouteHandle.retry` for those addresses that have holders
|
||||
(Q2): they start with the new set and are listed; an unheld one starts at its next hold;
|
||||
3. ships the complete set to every running or starting worker whose start message or last reload carried an older one
|
||||
(a child queues a reload that arrives before its `ready` until its load is done); workers reload in parallel.
|
||||
|
||||
A worker's entry settles on its `reloaded` or on its exit or link loss, whichever comes first. A worker stopped cleanly
|
||||
during the step (idle, `stop`) is listed `stopped` and dirties nothing (its next start gets the current set); one that
|
||||
crashed or failed to apply is listed with its error and does not stop the rollout, and every plugin of its kind in the
|
||||
build counts as failed there (stays dirty); a worker failing during this rollout stays failed. A plugin's dirty mark
|
||||
clears only when it applied on every host of every step, and a kind built in the rollout that no step shipped keeps its
|
||||
plugins dirty.
|
||||
|
||||
## 5. Not in slice 10
|
||||
|
||||
The web page host, the browser's evaluation of scripts and serving them (slice 11); the TUI (slice 11); relocation and
|
||||
pause of workers (the app's: `start(address)` is where the app awaits its own barrier); request resolvers and
|
||||
authenticators as registries (the app's); chunked transfers as a Chord helper (an owner pattern, design §23); the
|
||||
channel component (D6); telemetry spans for requests (slice 9).
|
||||
|
||||
## 6. Kernel changes
|
||||
|
||||
- `host.route(Kind, options, ctx)` returns a `RouteHandle { retry(address), suspend(address) }` (D10b, R1, Q3).
|
||||
- A route has no backoff timer (R2): a rejected `resolve`, or a link that closes before its handshake completed, fails
|
||||
the route's holders `failed { waiting }` with its error (they stay on the address's path, Q1) and resolves nothing
|
||||
more; the route resolves again only for a new hold, on `retry`, or once after a link that had completed its handshake
|
||||
dropped while holds exist; a suspended address resolves nothing while it has holders and forgets the suspension with
|
||||
the last one.
|
||||
- `host.root(fn, ctx, { principal: undefined })` runs anonymous (`ctx.principal` undefined); only an omitted
|
||||
`principal` takes the host's (a public-API change of `RootOptions`: `"principal" in options`).
|
||||
- Origins only from relays (decision, 2026-10-31, security audit): `origin` is read only on parent and route links;
|
||||
on a child link it is a protocol error; a per-origin record goes once it has no holds, calls or messages in flight.
|
||||
- An anonymous `host.root` scope gets no remote object served on its host (`forbidden`).
|
||||
- Design §3: the paragraph on a `resolve` waiting for the worker's `load` is updated (done), and "Chord never closes a
|
||||
route link itself except at `host.close`" becomes "except at `host.close`, the heartbeat and protocol errors".
|
||||
|
||||
## 7. Tests, examples, cost
|
||||
|
||||
- One test file per source file. HTTP: listening before the publication (a request waits, then is served; 503 past
|
||||
`readyTimeout`); the router resolved once; `Host` (loopback forms, allowed from the option and from a writable
|
||||
contribution changing at runtime) and `Origin` checks; anonymous requests (`ctx.principal` undefined on a server with
|
||||
a principal); authentication per request and upgrade; throwing hooks; patterns, decoding, precedence, `HEAD`, 404;
|
||||
conflicts reported; wraps (phases, order, scopes, a missing route, a throwing wrap); every response kind (ranges
|
||||
206/416, multi-range, `HEAD`, download names, chunked without `size`, a short `read`, a disconnect stopping `read`,
|
||||
header merging); `json`/`body` limits and their statuses, an unread body; a throwing route; the links endpoint
|
||||
(`host.accept`, options per socket, a refused socket, the output cap); plugin sockets (an oversized frame closes only
|
||||
that socket; a reload removing the contribution closes its sockets 1012); a removed allowed host closing its
|
||||
connections; a reload swapping a route while a request runs on the old router; `fileResponse` closing its handle on
|
||||
every path; `close` (refusals, waiting, then sealing). Workers: a start on the first hold; the link made at the fork
|
||||
(a load longer than the heartbeat's 30 s binds); `started` on `connect`; the start timer covering only the start (a
|
||||
slow `load` is not killed); the lock (an old worker's lock waited for without polling); `start` throwing; a crash,
|
||||
a `fatal`, a link lost with a live child (killed after the grace), and a failed start each staying failed (no fork for
|
||||
a new hold; holders `failed { waiting }`), `retry` and a rollout clearing them and rebinding the holders; idle stop,
|
||||
a worker idle from its start, a hold keeping it; `busy`; 1,000 sequential local calls sending no message;
|
||||
`stop` with holders (suspended, then forgotten with the last holder); stop during start and start during stop; a
|
||||
crash while a resolve is queued; `close` (cancelling a start waiting in `start(address)`, no `worker_failed` at
|
||||
shutdown); the rollout step (complete sets, a worker starting during it, a clean stop listed `stopped`, a crash during
|
||||
it, an aborted reload's late reply, a kind no step shipped staying dirty); the child's shutdown during its startup,
|
||||
disconnect and signals; `redact` (a secret in the original message reaches neither `fatal` nor `state`). Kernel:
|
||||
`RouteHandle.retry` and `suspend`; a link closing before its handshake (no re-resolve loop); no re-resolve without a
|
||||
hold, a retry or one drop of a healthy link; `host.root` with an explicit `undefined` principal.
|
||||
- GC: requests (their scopes, bodies, file handles) and stopped workers' state collected; no listener left on a closed
|
||||
socket; 1,000 addresses started and stopped leave no `state` entries, no per-address queue records and no
|
||||
suspensions.
|
||||
- Examples (each a folder, as 7c's): `40-http` (routes, a wrap, a download with ranges, a socket, an allowed host
|
||||
added at runtime), `41-workers` (a server routing a session kind to forked workers, idle stop, a crash staying failed
|
||||
until Retry, a rollout reaching the workers and restarting a held failed one).
|
||||
- Benchmark: a route's overhead per request against a plain Node handler (bar: within 10% of a keep-alive round
|
||||
trip; Opus measured 2.5 µs of 54 µs); `bytes` throughput for a 100 MiB file against `pipeline`; a worker's cold start
|
||||
(fork to `started` and to `ready`) with shipped scripts; memory per idle worker on the server.
|
||||
114
packages/chord/docs/next/slice-11.md
Normal file
114
packages/chord/docs/next/slice-11.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Slice 11: web and TUI surfaces (spec)
|
||||
|
||||
Status: **approved** (owner, 2026-10-30/11-01: P1-P9 in the decisions "Slice 11's points" and "Slice 11, P3" to "P9",
|
||||
then "go ahead, just do all the things"). Built after slices 12 and 13: the surfaces are generic plumbing that the app
|
||||
kit wires in (`startApp` serves the web surface; a TUI client entry). The app keeps everything visible: HTML shell,
|
||||
CSS, components, themes, navigation, layout, keys.
|
||||
|
||||
Read in full before writing code: this spec; `decisions.md` (slice 11's entries, the plugin model, the app kit);
|
||||
`design.md` §3 (clients), §21, §23 (all, "Web and TUI" included); `slice-10.md`, `slice-12.md`, `slice-13.md`; p2's
|
||||
`src/hosts/web.ts`, `src/hosts/web/main.tsx`, `src/web/reconnect.ts`, `src/runtime/web.tsx`, `src/hosts/bundles.ts`,
|
||||
`src/hosts/tui.ts`, `src/hosts/tui-client.ts`, `src/hosts/tui-sandbox.ts`, `src/hosts/tui-bootstrap.mjs`,
|
||||
`src/hosts/tui-worker-runtime.ts`, `src/runtime/tui.ts` (`TuiScope`).
|
||||
|
||||
## 1. `chord/next/web` (server side)
|
||||
|
||||
```ts
|
||||
// inside startApp (slice 13), or by hand:
|
||||
const web = webSurface(server, {
|
||||
kind: "web", plugins,
|
||||
entry: "./src/web/main.tsx", // the app's web start code: the base bundle's entry
|
||||
page: (request, assets) => shell(request.nonce, assets), // the app's HTML; assets: base script, plugins file, styles
|
||||
}, ctx);
|
||||
// its facet (routes to Http, the WebScripts object) goes into the server's core facets
|
||||
```
|
||||
|
||||
- **Base bundle** per client kind: built on the server by esbuild from `entry`, with the kind's host packages inlined
|
||||
once (the app's API modules for the kind, Chord, React), content-hashed (`/_chord/web/base.<hash>.js`), cached
|
||||
forever (`Cache-Control: immutable`, overriding the baseline's `no-store` for these routes only); rebuilt when base
|
||||
code changes (the server restarts anyway: built at start).
|
||||
- **Plugin scripts:** each web facet served as a script that registers itself (`chordFacet("<plugin>", "<digest>",
|
||||
function (require, module, exports) { … })`, P2), at `/_chord/web/plugin/<plugin>.<digest>.js`, immutable; the
|
||||
current set as one combined file `/_chord/web/plugins.<set-hash>.js` (P3); stylesheets and assets of the build
|
||||
likewise, under `publicPath`.
|
||||
- **`WebScripts`** (a `remote` object the surface serves, params `{}`, any principal that may load the app): its state
|
||||
is the current set `{ plugins: [{ name, digest, url, css? }], base }`; a rollout that ships the web kind updates it
|
||||
(the plugins component's `scripts("web")` changes; the surface follows them) without waiting for pages (P5).
|
||||
- Routes and the page are `Http` contributions of the surface's facet; the page route is `page: true` (navigation from
|
||||
another site allowed) and public or not as the app says.
|
||||
|
||||
## 2. `chord/next/web` (browser side)
|
||||
|
||||
```ts
|
||||
// src/web/main.tsx (the app's entry, in the base bundle)
|
||||
startPage<P>({
|
||||
connect: () => new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`),
|
||||
modules: { "@acme/app/v3": api, "@acme/app/v3/web": apiWeb, react: React, "react/jsx-runtime": jsx },
|
||||
core: [{ name: "@acme/web", facet: webCore }],
|
||||
canRetry: async () => true,
|
||||
onConnection: (state) => setBanner(state),
|
||||
render: (host) => createRoot(document.getElementById("root")!).render(<HostProvider host={host}><App /></HostProvider>),
|
||||
});
|
||||
```
|
||||
|
||||
- A client host (no address); the connection loop (P6, p2's `keepConnected`: reconnect 250 ms to 5 s, wake on
|
||||
visibility, `online` and network change, replace a silent socket, stop on an outdated protocol (reload the page) or
|
||||
when `canRetry` says no); `onConnection` reports `connecting | connected | waiting`.
|
||||
- Loads the combined plugins file, evaluates each registered facet with `modules` (the `require` they get answers only
|
||||
host packages and `@earendil-works/chord/next/plugins`'s browser half if any), loads them with `core` in one
|
||||
`host.load`, then holds `WebScripts`: on a new set it fetches the changed scripts (`<script src>`, one each), and
|
||||
calls `host.reload` with the complete set (P4: in place; a failed plugin keeps its old facet, reported to the app);
|
||||
a changed `base` hash reloads the page.
|
||||
- CSP: scripts are `'self'` plus the nonce for the page's inline bootstrap; no `eval`.
|
||||
|
||||
## 3. `chord/next/react`
|
||||
|
||||
`HostProvider`, `useHost()`, `useHold(Type, params)` (a reference held for the component's life, released on unmount,
|
||||
re-held when params change), `useWatch(watchable)` (the current value, re-rendering on change; `useSyncExternalStore`),
|
||||
`useRegistry(R)`, `useRefState(ref)`. A component unmounted by a reload has not left (design §21): releases are plain
|
||||
releases, presence is the app's. No components, no styling.
|
||||
|
||||
## 4. `chord/next/tui`
|
||||
|
||||
- **Launcher** (trusted, a Node process): `startTerminal({ url, token | tokenFile, pairing?, ... })` connects (a token
|
||||
from the owner token file, slice 13, or device pairing), downloads the base bundle and the TUI kind's plugin scripts
|
||||
over HTTP, and starts the **sandbox**: a child process on pipes (decision "The terminal's sandbox is a child
|
||||
process", replacing p2's worker thread: empty environment, Node's permission model granting nothing, no SQLite; no
|
||||
terminal, file system, child processes or network except the bridge), always (P8), whose runtime gets a
|
||||
bridge terminal (input events in, frames out), the Chord link (messages relayed by the launcher) and the evaluated
|
||||
scripts. The launcher keeps credentials, the real terminal and local preferences, and exposes a small set of actions
|
||||
to the sandbox (clipboard, open URL), each confirmed by the launcher's own UI when it touches the machine.
|
||||
- **Runtime** (inside the sandbox, in the base bundle): a client host like the page's, the same connection loop over
|
||||
the bridged link, in-place reloads through a `TuiScripts` object as the web.
|
||||
- **`TuiScope`** (P9): `new TuiScope(host, changed)` with `hold`, `watch`, `registry`, `dispose`; no pi-tui dependency.
|
||||
|
||||
## 4b. Looks and themes (decision "Looks and themes", `docs/next/theming/`)
|
||||
|
||||
- The kit ships `Themes` (`defineRegistry<Theme, readonly Theme[]>`, in the shared module the kit's apps re-export),
|
||||
`Theme = { id, name, mode: "dark" | "light", colors: { accent, bg, fg, muted, error, warning, success, ...app roles },
|
||||
web?, tui? }`, and a `themes` preference per user (`{ dark, light }` theme ids) stored by the kit's users plugin or a
|
||||
local object for single-user apps.
|
||||
- Web: `startPage` watches `Themes` and the preference with `prefers-color-scheme`, and writes the active theme as CSS
|
||||
variables on `:root` (`--accent`, ..., plus `web` extras), with no reload; the base bundle's stylesheet is the app's.
|
||||
- TUI: `TuiScope`'s host gets a `theme` lookup by role (true colour, else the nearest of 256 colours, `tui` overrides
|
||||
first).
|
||||
- CSS modules (`*.module.css`) work in plugin builds (esbuild's local-css loader); documented in `plugin-authoring.md`.
|
||||
- Version 1 of `Themes`; a later role is added by a version bump mapping old entries (missing roles from the default
|
||||
theme).
|
||||
|
||||
## 5. In the app kit
|
||||
|
||||
`startApp` gains `web: { entry, page }` and `tui: { entry }` (optional); with them it creates the surfaces and adds
|
||||
their facets. Without, an app has no web or TUI surface (an API-only app).
|
||||
|
||||
## 6. Tests, examples
|
||||
|
||||
- One test file per source file. Web server side: the base bundle (hash, immutable, rebuilt per start), plugin
|
||||
scripts and the combined file (registration wrapper, no eval, digests), `WebScripts` updated by a rollout, the page
|
||||
route and CSP. Browser side under jsdom or a real browser (the repo's browser smoke setup, if it fits): the loop
|
||||
(backoff, wake events, outdated protocol, `canRetry`), loading the combined file, an in-place reload keeping a
|
||||
component's state, a base change reloading. React hooks (hold released on unmount, watch re-render, registry).
|
||||
TUI: the sandbox cannot read a file or spawn a process (a test plugin that tries), the bridge, an in-place reload,
|
||||
`TuiScope` disposal; pairing a TUI client against a kit app.
|
||||
- Examples: `56-web` (a kit app with a page, two web plugins, an edit applied in place), `57-tui` (a kit app with a
|
||||
TUI client, sandboxed), `58-themes` (a theme plugin restyling a page and a TUI live).
|
||||
132
packages/chord/docs/next/slice-12.md
Normal file
132
packages/chord/docs/next/slice-12.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Slice 12: the plugin model (spec)
|
||||
|
||||
Status: **approved** (owner, 2026-11-01: the plugin model's decisions, then "go ahead, just do all the things"; this
|
||||
spec written by the redesign session from them, no separate spec review). Builds on 7c's plugins component
|
||||
(`src/next-node/plugins/`) and slice 10's kinds and `createPluginRuntime`.
|
||||
|
||||
Read in full before writing code: this spec; `decisions.md` entries of 2026-11-01 "Contracts come from where the
|
||||
plugin's code came from", "Plugins import only the app's versioned API", "The plugin model, points 3, 4, 5 and 8,
|
||||
imports and builds", "Single-file plugins", "Testing a plugin alone"; `design.md` §21 (all of it, "The plugin model"
|
||||
included) and §16-§17; `slices.md` (carry-over "Slice 12"); `slice-7c.md`, `slice-10.md` §3 "Plugins in workers";
|
||||
`src/next-node/plugins/README.md` and its sources; the primer `docs/next/plugin-primer/book.md`.
|
||||
|
||||
## 1. The app's API (`createPlugins` option `api`)
|
||||
|
||||
```ts
|
||||
const plugins = createPlugins({
|
||||
roots: [{ path: "./plugins", required: true }, { path: join(dataDir, "plugins") }],
|
||||
api: { name: "@acme/app", versions: ["v3", "v2"] }, // newest first
|
||||
kinds: {
|
||||
server: { platform: "node", hostPackages: ["@acme/app/v3", "@acme/app/v3/server", "@acme/app/v2"] },
|
||||
worker: { platform: "node", hostPackages: ["@acme/app/v3", "@acme/app/v3/worker", "@acme/app/v2"] },
|
||||
web: { platform: "browser", hostPackages: ["@acme/app/v3", "@acme/app/v3/web", "@acme/app/v2", "react", "react/jsx-runtime"] },
|
||||
},
|
||||
onError,
|
||||
}, ctx);
|
||||
```
|
||||
|
||||
- `api` names the app's API package and the versions it provides. A plugin import of `<name>/<version>[/<kind>]`:
|
||||
a version not listed is a build error naming the listed ones ("@acme/app/v1 is not available: this app provides v3,
|
||||
v2"); a listed one that the kind's `hostPackages` lacks (`@acme/app/v3/web` from a worker facet) is a build error
|
||||
naming the kind. One facet's imports of the API all use one version (a mix is a build error naming both).
|
||||
- Chord itself (`@earendil-works/chord/next` and its subpaths other than `/plugins`, which stays as in 7c for
|
||||
`pluginFile`) is not importable by plugins: a build error pointing to the app's API. Apps that do not set `api` keep
|
||||
7c's behaviour (host packages as listed), so Chord's own tests and examples keep importing Chord directly where they
|
||||
model base code.
|
||||
- `roots` entries may be strings (as today) or `{ path, required? }`. A plugin of a required root whose build,
|
||||
evaluation or setup fails at `plugins.load` makes `load` reject (`PluginError` naming it), after the host's own load
|
||||
settled; failures in other roots are reported as today. At a reload, a required plugin failing keeps its old code as
|
||||
any plugin (reported).
|
||||
|
||||
## 2. Imports between plugins (replaces slice 10's by-name lookup)
|
||||
|
||||
- **Relative paths within a root:** an import from one plugin into another plugin's directory under the same root
|
||||
(`../notes/contract.ts`) is inlined (listed in `copied`), the copied file under 7c's rule (only host packages and
|
||||
files of its own plugin). A relative path leaving the root (base code, another root) is a build error naming the file
|
||||
and pointing to the API or a dependency. A git checkout of several plugins cloned into a root is "within a root".
|
||||
- **Package names across repositories:** an import of a package in the plugin's `dependencies` resolves as npm does
|
||||
through that package's `exports` (`@bob/notes` → its `contract.ts`, `@bob/notes/web` → `contract.web.tsx`), inlined
|
||||
at the installed version. A dependency may be an npm version or a git URL with a ref (`github:bob/notes#<commit>`,
|
||||
`git+https://...#<tag>`); `installDependencies` installs it with npm as today. A plugin inside a multi-plugin
|
||||
repository is reached through a dependency on the whole repository, by path into it (`@bob/plugins/notes/contract.ts`)
|
||||
or its `exports` (decision "`chord.plugins` is a package's manifest of plugins").
|
||||
- **`chord.plugins`** in a package's `package.json` lists the plugin folders it provides (globs, exclusions); used by
|
||||
discovery of a root's packages and by `installPlugin`; without it, conventional discovery.
|
||||
- The by-name lookup (`build.ts` `#byName`), its error texts and the six `tsconfig.json` path aliases go; fixtures and
|
||||
examples 35-38 import relatively again.
|
||||
|
||||
## 3. Per-kind files and the import check
|
||||
|
||||
- A file whose name has a kind segment before its extension (`contract.web.tsx`, `ui.tui.ts`, any `*.<kind>.<ext>` with
|
||||
`<kind>` one of `kinds`) is a file of that kind: importing it (directly or through other files) from a facet of
|
||||
another kind is a build error naming the chain. Facet files keep their names (`web.tsx`); contract files are
|
||||
`contract.ts` (shared) and `contract.<kind>.ts(x)`.
|
||||
- The check runs in the existing esbuild `onResolve` path, on the resolved file, for relative imports, dependencies and
|
||||
copied files alike.
|
||||
- Documented, not checked: a contract file mentions native things only through `import type` (TypeScript erases it;
|
||||
a runtime import of a package the kind lacks already fails as above).
|
||||
|
||||
## 4. Single-file plugins
|
||||
|
||||
- Discovery also lists files at a root's top named `<name>.<kind>.(ts|tsx|js|mjs)`; files of one `<name>` form one
|
||||
plugin named `<name>` (an unscoped valid npm name, else reported), one facet per file, `dir` the root, no
|
||||
`package.json` (no dependencies, no `chord.external`). A directory plugin of the same name in the same root is a
|
||||
reported duplicate (the directory wins).
|
||||
- Its build allows imports of host packages only (the app's API); anything else is a build error saying to make it a
|
||||
directory. `pluginFile` refuses (it has no directory of its own).
|
||||
- The watcher: such a file appearing or disappearing at a root's top sets `set`; a change marks the plugin dirty.
|
||||
|
||||
## 5. Installing plugins
|
||||
|
||||
`installPlugin(source, root, ctx)`: `source` is `{ git: url, ref }` (a shallow clone of the ref into
|
||||
`<root>/<repository name>`; a repository whose top has a `package.json` is one plugin, otherwise its subdirectories
|
||||
are plugins) or `{ npm: name, version }` (`npm pack` and extract into `<root>/<name>`), then `installDependencies` for
|
||||
each new plugin; marks `set`. Returns the installed plugin names. Removal is deleting the directory (`set`). No
|
||||
updating in place: install another ref (a new directory) or delete and install.
|
||||
|
||||
## 6. `testPlugin`
|
||||
|
||||
```ts
|
||||
const t = await testPlugin("./plugins/stats", {
|
||||
kind: "worker",
|
||||
api: { name: "@acme/app", versions: ["v3"] },
|
||||
modules: { "@acme/app/v3": apiV3, "@acme/app/v3/worker": apiV3Worker }, // the real API modules
|
||||
stubs: [{ name: "@acme/notes", facet: notesStub }], // facets serving what it uses
|
||||
principal: { kind: "user", id: "u1" }, // the host's principal (optional)
|
||||
}, ctx);
|
||||
const stats = await t.host.get(Stats, { project: "p1" }, ctx);
|
||||
t.contributions(Panels); // what the plugin contributed to a registry nobody serves (the helper serves it)
|
||||
t.unserved(); // contract names requests wait for unbound `not_served`
|
||||
await t.close(ctx);
|
||||
```
|
||||
|
||||
- Built with the same build as production (`buildFacet` with the kind's options), evaluated with `modules`, loaded on a
|
||||
host with `address`/`principal` as given, an in-memory store (`:memory:` SQLite); stubs loaded beside it.
|
||||
- The helper serves, as a collecting registry, every registry the plugin contributes to that no stub or the plugin
|
||||
itself serves (`t.contributions(R)`: the current entries).
|
||||
- `t.unserved()` lists the names of contracts whose requests wait unbound `not_served` on the host (the unbound
|
||||
index), so a test knows what to stub. `t.root(fn, ctx, { principal })` runs as another principal.
|
||||
- Exported from `chord/next/plugins` (`testing.ts`); no test-only modes elsewhere.
|
||||
|
||||
## 7. Docs for authors and agents
|
||||
|
||||
- `docs/next/plugin-authoring.md`: a plugin's layout (directory and single-file), its facet and contract files per
|
||||
kind, what it may import and how each import resolves, versions, how the server builds it (esbuild, what the build
|
||||
handles, no build hooks), installing from git or npm, testing with `testPlugin`; written for agents (short, exact,
|
||||
with one complete example plugin).
|
||||
- Update `src/next-node/plugins/README.md`, the guide `docs/next/guide-plugins-hosts/book.md` (chapters "Plugins Using
|
||||
Plugins" and "Base Code and Plugins"; rebuild `index.html`), design §21's slice-10 note.
|
||||
|
||||
## 8. Tests, examples
|
||||
|
||||
- One test file per source file; build tests for every refusal and allowance of §1-§4 (an unlisted version, a kind
|
||||
module from another kind, mixed versions, a Chord import, a relative path leaving the root, a per-kind file through
|
||||
a chain, a package from `dependencies` through `exports`, a git dependency with a ref (a local bare repository in a
|
||||
temp directory, no network), a subfolder dependency); discovery of single-file plugins; required roots at load;
|
||||
`installPlugin` from a local git repository and a local npm tarball; `testPlugin` (stubs, collected contributions,
|
||||
`unserved`, a principal).
|
||||
- Examples: `49-plugin-api` (an app API with two versions and per-kind modules, a plugin per version, the refusals),
|
||||
`50-plugin-imports` (relative within a root, a git dependency pinned to a commit, per-kind contract files),
|
||||
`51-single-file-plugin`, `52-test-plugin` (a plugin tested with a stub). Examples 35-38 updated.
|
||||
- No hot path changes; no benchmark beyond a cold build of the 20-plugin set (as 7c's) to show the checks cost nothing
|
||||
measurable.
|
||||
120
packages/chord/docs/next/slice-13.md
Normal file
120
packages/chord/docs/next/slice-13.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Slice 13: the app kit (spec)
|
||||
|
||||
Status: **approved** (owner, 2026-11-01: decisions "The app kit, point 1" to "point 5", then "go ahead, just do all
|
||||
the things"). Builds on slices 10 (`http`, `workers`) and 12 (the plugin model). `chord/next/app` in
|
||||
`src/next-node/app/`: an opinionated layer so an app is its API, its built-in plugins and a start script.
|
||||
|
||||
Read in full before writing code: this spec; `decisions.md` entries of 2026-10-31 and 2026-11-01 (the app kit, the
|
||||
plugin model, "The app kit is opinionated", "Settings are arguments, extensions are registries", "The HTTP
|
||||
component's security baseline"); `design.md` §3, §4, §10, §21, §23 (all of it, "The app kit" included); `slices.md`
|
||||
(carry-over "Slice 13"); `slice-10.md`, `slice-12.md`; the READMEs of `next-node/http`, `workers`, `plugins`; p2's
|
||||
`src/runtime/server.ts`, `src/hosts/server.ts`, `src/hosts/worker.ts`, `src/runtime/loopback-cookie.ts`,
|
||||
`src/hosts/principals.ts`, `plugins/devices/`, `plugins/sharing/`, `plugins/files/` for what exists.
|
||||
|
||||
## 1. `startApp` and `startWorker`
|
||||
|
||||
```ts
|
||||
// start.ts (the app's start script)
|
||||
const app = await startApp<P>({
|
||||
name: "acme", // the default data directory: ~/.acme
|
||||
dataDir: process.argv[2], // optional; no environment variables
|
||||
listen: { host: "127.0.0.1", port: 8788 },
|
||||
api: { name: "@acme/app", versions: ["v3"], modules: { "@acme/app/v3": apiV3, "@acme/app/v3/server": apiV3Server } },
|
||||
kinds: { server: { platform: "node" }, worker: { platform: "node" }, web: { platform: "browser", packages: { react, "react/jsx-runtime": jsx } } },
|
||||
principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", key: address.key }) },
|
||||
workers: [{
|
||||
kind: Project, // a host kind the app defines, any params
|
||||
entry: "./worker.ts",
|
||||
start: (address, ctx) => ({ id: address.params.id }),
|
||||
authorize: (principal, address) => mayReach(principal, address),
|
||||
idleMs: () => 5 * 60_000,
|
||||
}],
|
||||
onError: (error) => log(error),
|
||||
}, ctx);
|
||||
// app.host, app.http, app.workers, app.plugins, app.applyChanges(ctx), app.exportAddress(address, ctx),
|
||||
// app.importAddress(archive, address, ctx), app.ownerLink(), app.close(ctx)
|
||||
```
|
||||
|
||||
```ts
|
||||
// worker.ts
|
||||
await startWorker<P>({ api, kinds, onError }); // reads the start message, opens the worker's store, loads plugins
|
||||
```
|
||||
|
||||
- `startApp` creates the data directory layout (§4), opens the server store, creates the host (address `Server()`,
|
||||
the server principal), `createHttp` (settings as arguments), `createWorkers` per worker kind (`kind` named after the
|
||||
host kind; the worker's facet kind is `worker` unless the entry says otherwise), `createPlugins` with the roots of §5
|
||||
and the app's API (slice 12), loads the server's plugins with the kit's own facets (§2, §3, §4) as core, and resolves
|
||||
once loaded. `close` closes in the right order (http, workers, host, store).
|
||||
- `startWorker` is `workerMain` plus: the worker's directory and store under its kind and address, the host with the
|
||||
principal from the start message, `connect` first, plugins loaded with the kit's worker facets, `busy` from the app
|
||||
if given.
|
||||
- The start-data facet: the kit serves `AppInfo` (data directory, app name, version) on the server and `WorkerInfo`
|
||||
(address, start data) on workers as core objects plugins `get`.
|
||||
- Zero worker kinds: no workers component.
|
||||
|
||||
## 2. Principals and sessions
|
||||
|
||||
- The app's principal type `P`; the kit needs only `principal.server` and `principal.worker(address)`. The kit adds
|
||||
one variant for its owner: `{ kind: "owner" }` must be assignable to `P` (a type check).
|
||||
- **Sessions** (`app/sessions.ts`, a kit object on the server, persisted in the server store): `issue(principal,
|
||||
{ label, kind: "browser" | "device" }, ctx)` returns a token; tokens stored hashed; `revoke(id)`; `list` (watchable:
|
||||
label, kind, created, last used). The kit's authenticator (contributed to `Authenticators`) reads the session cookie
|
||||
(signed, `HttpOnly`, `SameSite=Strict`) or an `Authorization: Bearer` header, and gives its principal.
|
||||
- **Owner link:** at start the kit issues a one-time token (random, single use, valid 10 minutes, replaced at each
|
||||
start) and `app.ownerLink()` returns `http://127.0.0.1:8788/_chord/owner#<token>`; the start script prints it. The
|
||||
route `GET /_chord/owner` serves a page that posts the token (from the fragment, never sent in a URL to logs) to
|
||||
`POST /_chord/owner`, which consumes it and sets a browser session cookie for the owner. No owner by loopback alone.
|
||||
- **Owner token file:** `<dataDir>/owner-token` (mode 0600, created at first start, a device session of the owner) for
|
||||
command-line clients on the same machine.
|
||||
- **Device pairing** (for remote command-line and TUI clients): `POST /_chord/pair` (public) returns `{ code, poll }`;
|
||||
the owner approves the code in a browser (`GET /_chord/pair/<code>`, owner only, a confirm page); the client polls
|
||||
and receives a device token. Codes expire after 10 minutes.
|
||||
- **Deny by default** (an HTTP component change): a route or socket without a principal is refused (401) before plugin
|
||||
code, unless its contribution marks it `public: true` (routes: `{ handler, public: true }`; sockets likewise). The
|
||||
`/chord` links endpoint requires a principal. Foreign requests stay anonymous as before, so they reach only public
|
||||
routes. Slice 10's examples and tests mark their anonymous routes public.
|
||||
|
||||
## 3. Optional kit plugins
|
||||
|
||||
Shipped as plugins (in `packages/chord/plugins/` or exported facets the app adds to its root; choose one and say
|
||||
why): `users` (accounts keyed by id, a profile schema the app passes in, `self.get(Users, {})` with `get`, `update`,
|
||||
`list`), `invite` (invite links issuing a session for a principal the owner chooses), `ephemeral-login` (a public
|
||||
route taking a name, issuing a session for `{ kind: "user", id: <fresh>, name }` that ends with the connection; the
|
||||
app's `P` must allow it). Each contributes to `Authenticators` or issues sessions through §2.
|
||||
|
||||
## 4. Storage, files, export
|
||||
|
||||
- Layout: `<dataDir>/server/store.sqlite`, `<dataDir>/workers/<kind>/<encoded address key>/store.sqlite` plus that
|
||||
worker's files, `<dataDir>/plugins/` (the user's root), `<dataDir>/logs/`, `<dataDir>/owner-token`.
|
||||
- **Files** (a kit facet on workers, and on the server for server-scoped files): `Files` (`local`, params `{}`, one per
|
||||
host) with `put({ name, mime, bytes })` for small files, `begin`/`append`/`finish` for large ones (design §23 chunked
|
||||
transfers), `read({ id, offset, length })`, `remove`, `list` (watchable index: id, name, mime, size, created), caps
|
||||
per host (an option). Stored under the host's directory (`files/`), the index in the store.
|
||||
- **Export / import per address:** `app.exportAddress(address, ctx)` stops the worker (as `workers.stop`), writes one
|
||||
archive: the store's export archive (slice 6a) plus the worker's directory (files), and returns a readable stream or
|
||||
writes to a path; `importAddress(archive, address, ctx)` refuses an existing address, unpacks, and the next hold
|
||||
starts the worker. Format: a tar of `store.archive` and `files/`, with a small manifest (app name, API version,
|
||||
kit format version).
|
||||
|
||||
## 5. Plugins
|
||||
|
||||
- Roots: the app's `plugins/` (required, slice 12) and `<dataDir>/plugins/`. Installs (`app.plugins.install(source,
|
||||
ctx)`, slice 12's `installPlugin`) go to the user's root.
|
||||
- `app.applyChanges(ctx)`: build the dirty plugins and roll out (server step, then the workers step); returns the
|
||||
rollout result; `app.plugins.state` for the app's button. Never applied by itself.
|
||||
|
||||
## 6. Not in slice 13
|
||||
|
||||
Web and TUI clients (slice 11, which builds on this kit: `startApp` gains the web surface there); telemetry (9);
|
||||
project plugins (§26 #14).
|
||||
|
||||
## 7. Tests, examples, docs
|
||||
|
||||
- One test file per source file: start and close order; a server without workers; two worker kinds; the owner link
|
||||
(single use, expiry, the fragment), sessions (issue, revoke, list, hashed storage), the token file mode, device
|
||||
pairing, deny by default (a route without principal 401, a public route served, `/chord` refused anonymous), Files
|
||||
(small and chunked, caps, list, remove), export and import (a worker with documents, tables and files round-trips;
|
||||
importing over an existing address refused), `applyChanges`, required root failure stopping `startApp`.
|
||||
- Examples: `53-app` (a small app: start script, API, two plugins, a worker kind, the owner link), `54-app-sessions`
|
||||
(pairing a command-line client), `55-app-export` (export and import a worker's address).
|
||||
- `src/next-node/app/README.md`; the guide "Hosts and Plugins" gets a closing chapter on the app kit.
|
||||
|
|
@ -89,7 +89,7 @@ if (notes.variant === "reader") console.log(notes.notes.value); // narrowed by
|
|||
| u32 header length, big-endian | header JSON (UTF-8) | part 0 | part 1 | … |
|
||||
```
|
||||
|
||||
- **One format on every link.** The header is a JSON object, a tagged union over `kind`, defined as a TypeBox schema;
|
||||
- **One format on every link.** The header is a JSON object, a tagged union over `kind`, defined as a schema (TypeBox then, pi-schema since the schema switch);
|
||||
every received header is checked with the compiled schema (the wire is a trust boundary; skipping it on trusted IPC
|
||||
links comes with IPC in slice 4). The header lists its parts: `parts: [{ json, bytes }]`, the JSON text's byte length and
|
||||
the byte lengths of the segments that follow it (`bytes: []` for state parts and arguments without bytes).
|
||||
|
|
@ -125,7 +125,7 @@ if (notes.variant === "reader") console.log(notes.notes.value); // narrowed by
|
|||
Replaces the JSON-header framing of section 2 (`| u32 header length | header JSON | parts |`); the message kinds, ids,
|
||||
races and rules of section 2 stay.
|
||||
|
||||
- **`Message`** is a tagged union defined once as a TypeBox schema (`MESSAGE`; `type Message = Static<typeof
|
||||
- **`Message`** is a tagged union defined once as a schema (TypeBox then, pi-schema since the schema switch) (`MESSAGE`; `type Message = Static<typeof
|
||||
MESSAGE>`): `hello { protocol, incarnation }`, `hold { hold, type, version, fingerprint, params }`, `release { hold
|
||||
}`, `call { call, hold, method, args? }`, `cancel { call }`, `bound { hold, variant, members: [{ member, value }],
|
||||
failed: [{ member, error }] }`, `state { holds: [{ hold, members: [{ member, ops }], failed: [...] }] }`, `failed {
|
||||
|
|
|
|||
130
packages/chord/docs/next/slice-7a.md
Normal file
130
packages/chord/docs/next/slice-7a.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Slice 7a: the dependency model (spec)
|
||||
|
||||
Status: **ready to build** (owner, 2026-10-29: the model decided, "just build this"). Slice 7 is split: 7a builds the
|
||||
dependency model on what exists (one `load` per host, links, routes, relays); 7b builds reload and rollout on top of it
|
||||
(removal, dropped versions, installs, own-code reloads, loader D).
|
||||
|
||||
Read in full before writing code: `dependencies/model.md` (the model as decided; its section numbers are cited below as
|
||||
M§), `design.md`, `decisions.md` (the entries of 2026-10-28/29: "No client providers", "Every remote type declares its
|
||||
placement", "`hostAddress` instead of `placement`", "Only hosts with an address serve remote types", "Getters wait
|
||||
through `waiting`", "Cycles across hosts: no probe, a diagnostic", "The dependency model is decided"), `slices.md`
|
||||
(slice 7's carry-over: 7a takes none of it unless named here; 7b takes the rest), the explainer
|
||||
`dependencies/book.md` for the scenarios (its catalogs are test material).
|
||||
|
||||
## 1. Requests and the unbound index (M§1)
|
||||
|
||||
- Every reference is a request with one holder (attempt, call, outside code, a link's peer). It ends only when its
|
||||
holder ends it; never because its provider is missing or does not fit.
|
||||
- The destination by address is built (`host/destination.ts`, slice 7a's predecessor commit `5ec241882`). Add: version
|
||||
checks only against the selected provider (no `#assertRoute` comparison with a same-name registration on the
|
||||
consumer's host); a local type not served here is no longer a throw at `self.get`/`host.hold`/getters but an unbound
|
||||
request.
|
||||
- **The publication gate:** a request made on this host, or a hold arriving on its links, before the host's first
|
||||
publication is not placed: `loading`, placed by the publication. `pendingLoad` no longer gates `Hold.start` or
|
||||
`#hold` beyond the publication (it ends the startup deadlock: Sol's probe `/tmp/dep-graph-sol-barrier.log` becomes a
|
||||
regression test that passes with fake timers never advanced).
|
||||
- **Unbound reasons** (M§1's table): `unpublished` (loading), `not_served` and `version` (`failed { waiting }`),
|
||||
`compose` for registries (`failed { waiting }`), `no_link` on a path (loading); `authorize` refused or invalid params
|
||||
stay `broken`, never indexed. Indexes per host by name (local requests; refused holds that arrived over links, kept as
|
||||
today's refused state in `remote/hold.ts`); entries removed when the request ends (release, call settled, getter gave
|
||||
up, `closed { origin }`, link close, revocation). GC tests.
|
||||
|
||||
## 2. States (M§2)
|
||||
|
||||
- `FailureCause` gains `{ kind: "waiting"; error }` (no `retryAt`). The wire's `failed` cause schema carries it; the
|
||||
pinned list and its test change; `PROTOCOL_VERSION` stays 1.
|
||||
- The mapping of M§2's table: `loading` for activating, unpublished, no link (and `installing`, 7b); `failed
|
||||
{ waiting }` for not served, version, compose, and a dependent whose dependency failed.
|
||||
- A remote refusal for not served / version is `failed { waiting }` instead of `broken`; the provider keeps the refused
|
||||
hold (it does today) and the consumer never retries it by itself (no backoff).
|
||||
|
||||
## 3. Parked attempts (M§3)
|
||||
|
||||
- Order: factory, persisted singleton hydration (as today), requests made; the attempt is **parked** while any
|
||||
dependency (`self.get` local or remote, `self.registry`, keyed pins) is not live and hydrated. `self.follow` is not a
|
||||
dependency (its attach is an own step).
|
||||
- What it shows: `loading` while blockers are only `loading`; `failed { waiting, "waiting for X: <cause>" }` (nearest
|
||||
blocker plus root cause, 500-character rule) when one failed; `broken` when one failed with `invalid/cycle` (off-ring
|
||||
dependents) or ended.
|
||||
- **No timer while parked:** no dependency deadline, no identity backoff for dependency failures; the attempt's
|
||||
`readyTimeout` deadline is paused while parked (as eager migrations pause it) and resumes with the budget left. The
|
||||
identity backoff stays for the object's own `transient` failures only.
|
||||
- **The dependency check is the boundary:** once all are live, the own steps run (natives, follow attaches, member
|
||||
checks, derives) under the deadline; a dependency leaving `live` afterwards is treated as after live (R18): the
|
||||
attempt goes on.
|
||||
- **Wakes on every state change** of a dependency (live, loading, failed, a new cause compared by value, ended):
|
||||
re-evaluate what the object shows, leave parking when all are live; never re-run the factory while parked; coalesce
|
||||
per object after the batch that caused them; remember changes during own steps or teardown.
|
||||
- **Retry passes on:** `ref.retry()` on a `failed { waiting }` object retries its `broken` and `transient` blockers hop
|
||||
by hop (the existing `retry` message), once per operation (an id dedupes diamonds); `waiting` blockers pass it on.
|
||||
A `retryOn` value call while parked `waiting` does nothing.
|
||||
|
||||
## 4. Wakes (M§4)
|
||||
|
||||
Two events: a target changes state (the requests bound to it and their dependents); a name becomes usable on a host
|
||||
(in 7a: `load`'s publication serving types and registries, a registry's value turning usable at a version): the
|
||||
unbound requests indexed under it, and refused holds, which re-run their start and send `loading`, then `bound` or
|
||||
`failed`. No new message kind.
|
||||
|
||||
## 5. Startup (M§6)
|
||||
|
||||
- `load` runs the setups, publishes them in one batch (types, registries served and composed once, contributions),
|
||||
wakes after that batch, then activates host-lifetime objects.
|
||||
- **The barrier, decision (a):** `load` resolves when every host-lifetime object of the load has a first outcome:
|
||||
live, failed by its own cause, or parked with no blocker that is a local object still without its first outcome
|
||||
(`local` = executing on this host, keyed pins still loading here included). One `firstOutcome` signal per object,
|
||||
separate from the readiness dependents wait for; counted once; an object of the cohort that ends before its first
|
||||
outcome settles as ended; `host.close` settles a waiting `load`.
|
||||
|
||||
## 6. Getters, holds, calls (M§8, decision "Getters wait through `waiting`")
|
||||
|
||||
- `host.hold` returns a reference at once; only invalid params throw (also before `load`); a missing local type gives
|
||||
`loading` before the publication, `failed { waiting }` after it, before and after `load` alike.
|
||||
- `host.get`, `ctx.get`, `host.registry`: resolve when live; wait through `loading`, `failed { transient }` and
|
||||
`failed { waiting }` until their `readyTimeout`, then reject with the last cause and release; reject at once only on
|
||||
`failed { broken }`.
|
||||
- Calls: park on `loading` (bounded, as today); reject at once on `failed`; never resent.
|
||||
|
||||
## 7. Registries (M§9)
|
||||
|
||||
A reader's request: bound while R is served and has a usable value at its version; otherwise unbound `not_served`,
|
||||
`version` (S2's newer or unmapped reader: `waiting`, not `broken`) or `compose` (first compose or the version mapping
|
||||
threw), `loading` before the publication. Woken at every transition to usable (`#valued`'s latch goes). The last good
|
||||
value is kept only while every contribution it was built from is still in the table (decision 5): when one leaves,
|
||||
the last good value is dropped and `.value` throws until `compose` succeeds.
|
||||
|
||||
## 8. Keep-alive, revocation, cycles (M§7, M§10)
|
||||
|
||||
- A request over a link counts once in `host.activity.references` from arrival to end, bound or unbound;
|
||||
`host.references` lists bound references only.
|
||||
- Revocation ends the requests of a revoked principal, bound and unbound, at every hop.
|
||||
- `gone` only for revocation (a closing provider host sends nothing, as today).
|
||||
- **Cycles:** the walk at `self.get` on one host, through parked attempts too; the activation chains across hosts as
|
||||
today. Remove the dependency deadline (no fallback). Add `HostError` `parked_long { object, waitingFor, since }`:
|
||||
once per parking episode, when an attempt has been parked longer than its `readyTimeout` on a remote attempt that is
|
||||
itself only `loading`. The guide (host README) states the four authoring rules of M§7.
|
||||
|
||||
## 9. Not in 7a (7b)
|
||||
|
||||
Removal and re-serving of types (local re-binding of a `Binding`, a `failed` on a bound hold unbinding it, window
|
||||
slots), a dropped accepted version, `installing` and install ends, own-code reloads and the parked-attempt handover,
|
||||
pure swaps parking (decision 4), rollout, loader D. 7a's code must leave those paths possible, not build them.
|
||||
|
||||
## 10. Tests and cost
|
||||
|
||||
- One `describe` per mechanism; the four catalogs in `dependencies/book.md` (chapters XIV-XVII) are the scenario list:
|
||||
every row that 7a's scope reaches becomes a test (memory links; relays; fake timers where a test proves no timer
|
||||
runs). Must include: the startup deadlock without `readyTimeout`; a hold before the publication placed here, not via
|
||||
the parent; a missing local type on `self.get`, `host.hold`, `host.get` (waits, then rejects with the cause); a
|
||||
remote refusal kept and re-run when the provider's late `load` publishes; a parked three-level chain across hosts
|
||||
woken in order; wakes on every state change with causes updated; retry passing on through a diamond once; the
|
||||
barrier's first-outcome rule (CodeMode → Harness waits; a remote blocker ends the pass); a cohort object ending
|
||||
before its outcome; registries' waiting reader, wake after a provider arrives, last good dropped when a contribution
|
||||
leaves; keep-alive counting unbound holds; revocation of unbound holds; `parked_long` reported once; GC tests for the
|
||||
indexes and a parked chain released from the top.
|
||||
- Benchmarks (the bar of "The performance and memory bar"): calls, activation churn, a watched derive, links against
|
||||
HEAD, pinned, alternating, a HEAD copy at a path of the same length; results in `benchmarks.md`.
|
||||
- Examples: `31-parked-dependencies.ts` (a missing provider arrives; causes shown; no timers), `32-startup.ts` (two
|
||||
hosts that would have deadlocked).
|
||||
- Docs: fold the model into `design.md` (§3, §6, §7, §17, §21's 7a parts) in the same change as the build; mark
|
||||
`dependencies/model.md` as built for 7a.
|
||||
255
packages/chord/docs/next/slice-7b.md
Normal file
255
packages/chord/docs/next/slice-7b.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# Slice 7b: reload in the kernel (spec)
|
||||
|
||||
Status: **built** (2026-10-29; the rules folded into `design.md` §21, §6, §7, §15, §17, §22, §27). Spec: owner,
|
||||
2026-10-29, the brief `slice-7b/` and its six points decided as recommended, decision "Slice 7b's open points, as
|
||||
recommended"; decisions of the build, below each section where they apply (owner, 2026-10-29). 7b builds `host.reload` and everything an object, a reference, a job and a
|
||||
registry does across it, on one host. Facets are passed in imported (tests import them statically); the bundler and
|
||||
the dev loop are slice 7c, the rollout across hosts is the app's.
|
||||
|
||||
Read in full before writing code: this spec, the brief `slice-7b/book.md` (chapters "One Host Reloads" to "Across
|
||||
Hosts"), `dependencies/model.md` (decided; built for 7a, its 7b parts are listed in its status line), `design.md` (§5,
|
||||
§6, §9, §14, §15, §17, §21, §22, §27), `decisions.md` (the 2026-10-29 entries; R15, R16, Q10, "The seal fence", "Job runs
|
||||
belong to the object", "A native restart drains first", "Slice 8 D1"), `slices.md` (slice 7b's carry-over: every entry
|
||||
is in this spec or named as out of scope in §12).
|
||||
|
||||
## 1. The API
|
||||
|
||||
```ts
|
||||
interface Host<P> {
|
||||
load(facets: readonly PluginFacet<P>[], context: Context): Promise<void>; // once, first
|
||||
reload(facets: readonly PluginFacet<P>[], context: Context): Promise<ReloadReport>; // after load, any number
|
||||
}
|
||||
type PluginFacet<P> = { readonly name: string; readonly facet: Facet<P> };
|
||||
|
||||
type ReloadReport = {
|
||||
readonly plugins: { readonly added: readonly string[]; readonly changed: readonly string[]; readonly removed: readonly string[] };
|
||||
readonly objects: readonly ReloadedObject[]; // every live, activating, parked or failed object of a reloaded plugin
|
||||
};
|
||||
type ReloadedObject = { readonly object: string } & (
|
||||
| { readonly kind: "swapped" } // pure, in place
|
||||
| { readonly kind: "restarted" } // natives: drained, closed (`reload`), activated again
|
||||
| { readonly kind: "ended" } // its type is gone
|
||||
| { readonly kind: "parked"; readonly cause: FailureCause } // the new code waits for a dependency (decision 4)
|
||||
| { readonly kind: "retried" } // it was failed or parked; a new attempt runs on the new code
|
||||
);
|
||||
```
|
||||
|
||||
- `facets` is the host's **complete new plugin set**: a plugin in it and not loaded is added; one in it and loaded is
|
||||
changed (Chord compares no code), unless its facet is the very same object as the loaded one (`===`): that plugin
|
||||
is unchanged, untouched and not in the report (owner, 2026-10-29); one loaded and not in it is removed. The core
|
||||
facet is a plugin like the others; base code passes the same facet value every time.
|
||||
- `reload` resolves once the commit happened and every object has taken its fate (§4): swaps done, restarts and retries
|
||||
started (it does not wait for them to go live; their outcome is in their `ref.state`). It rejects with the code error
|
||||
when load or staging fails, and nothing has changed. Reloads are serialized per host (a second call waits for the
|
||||
first). Before `load` resolved: throws; after `host.close`: rejects `released/closed`. `context` bounds the reload
|
||||
(decision 4: no install watchdog); its abort before the commit rejects `aborted` and changes nothing; after the commit
|
||||
it only ends the wait.
|
||||
|
||||
## 2. Staging (all or nothing; **superseded** 2026-10-30 by "Per plugin at startup and reload": a failing plugin keeps its old code, slice 7c §4)
|
||||
|
||||
1. **Setups.** Every facet's setup runs into a **staged** table, beside the live one: served types (with their factory,
|
||||
options and `bindProvider(contract, options.accepts)`, which runs `checkAccepts`), served registries (provider
|
||||
with `compose` and `accepts`), facet contributions. The checks of `load` apply (plugin names, a type or registry
|
||||
served twice, a remote type on a host without principal or address).
|
||||
2. **Factories.** For every object of a changed or removed plugin's types that exists now (live, activating, parked,
|
||||
failed), and whose type is still served: the new factory runs against a **recording `self`**, with the object's
|
||||
params (and principal), and declares only: what it opens (`self.open`), the documents and tables it declares with
|
||||
their versions, its jobs with their versions, its contributions, its requests. Nothing is activated, bound or
|
||||
opened; handles from a recording `self` are inert. This gives each object's fate (§4) before the commit.
|
||||
3. **Any throw** (a setup, a factory, `checkAccepts`, a declaration error) rejects `reload` with that error, names the
|
||||
plugin (and object), and the host stays entirely on its old code: the staged table is dropped.
|
||||
4. **While staging,** the host is **installing** (model §1): a hold that arrives for a type or version that the running
|
||||
code does not serve waits `installing` (`loading`) instead of `not_served`/`version`, and is re-evaluated when the
|
||||
reload ends, by its commit or its rejection.
|
||||
|
||||
## 3. The commit (one batch)
|
||||
|
||||
In one `batch.commit`, synchronous:
|
||||
|
||||
- The served types table becomes the staged one: added and changed types replace their entries (factory, provider,
|
||||
`linger`); removed types are dropped.
|
||||
- **Registries** (slice 8 §6): a changed or added plugin's provider replaces the old one (`RegistryRecord.serve` refuses
|
||||
a second serve today: it becomes `replace` for a reload); a removed plugin's provider goes (its readers become
|
||||
unbound `not_served`); the reloaded plugins' facet contributions replace theirs, by plugin; object contributions stay
|
||||
with their objects (they follow the objects' fates); every changed table recomposes once, lazily, as today. The record
|
||||
keeps the provider, facet contributions by plugin and object contributions by object apart.
|
||||
- **The publication** (model §4): after the batch, the unbound requests waiting for the names the reload serves (types,
|
||||
registries, versions) are re-evaluated (`UnboundIndex.wake(names)`, per name: the carry-over "Publications wake per
|
||||
name"), refused holds for those names re-run their start, and `installing` holds are re-evaluated against the new
|
||||
code.
|
||||
- `host.references(Type)` of a removed type is dropped (carry-over from 4b).
|
||||
|
||||
## 4. Fates
|
||||
|
||||
Each object of a changed or removed plugin's types takes one fate, decided in staging (design §21, decisions above):
|
||||
|
||||
| The object | Fate |
|
||||
| --- | --- |
|
||||
| its type is removed | **ended**: as an object end today (natives close with reason `end`, callers of running calls rejected `released`, handlers drained); its requests are released; its holders' requests become unbound `not_served` (§6) |
|
||||
| failed or parked | **retried**: its attempt is replaced by one of the new factory (a parked attempt in one handover: the new requests bind before the old ones are released; the mechanism 7a removed comes back here, carry-over) |
|
||||
| live, old and new code open no natives, and no table of the new factory is at a newer version | **swapped** in place (§5) |
|
||||
| live, otherwise | **restarted**: new calls park (the gate closes, holders see `loading`), running calls drain (bounded by `closeTimeout`; a call outliving the drain is cut off by the seal fence), the attempt is sealed, natives close (reason `reload`), the new factory's attempt activates (documents stay; eager table migrations run as at activation) |
|
||||
| any of the above whose new factory declares a dependency that is not there | **parked** on it (decision 4): the gate stays closed, holders see `loading` or `failed { waiting }`; no overlap of old and new code |
|
||||
|
||||
- A restarted object gets a new instance id (an old activation chain must not match it, carry-over from 4b); a swapped
|
||||
object keeps it; a retried one keeps it, as any retry does (slice 4b; fix round 1, Opus F3).
|
||||
- Objects of plugins not in the reload are untouched; their requests to changed types follow §6.
|
||||
- Host-lifetime objects of an added plugin start after the commit as at `load` (no barrier: `reload` does not wait for
|
||||
first outcomes).
|
||||
|
||||
## 5. The pure swap
|
||||
|
||||
A pure swap is not a replacement (decision "The seal fence"): the old attempt is not sealed while its calls run.
|
||||
|
||||
- **Gate.** For the swap itself the gate closes; new calls park (bounded) until the new attempt is live.
|
||||
- **New attempt.** The new factory's attempt is made with the same object: same documents and line (they are the
|
||||
object's), same job records. Its requests are made and bound before the old attempt's are released: a request equal
|
||||
to an old one (same type, params, principal, version) binds to the same target with no gap. It parks if a new
|
||||
dependency is not live (§4, parked). It runs its own steps (no natives by definition: derives, member checks), its
|
||||
members re-bind by name (`#rebind` exists: one frame from the last value per member; a member the new variant lacks
|
||||
fails `invalid/member`), its contributions replace the old attempt's in the registries in one batch, its routines
|
||||
start after the handover (below), its jobs take over the job records (§8).
|
||||
- **Old calls.** Running calls finish on the old attempt with its handles valid (their transactions commit) until they
|
||||
settle, except R15: an old call touching a document whose version rose throws once (the document intact, the call
|
||||
not resent); the same for a table whose version rose (old table handles' version fence, carry-over from 6a: old code
|
||||
never issues SQL for a dropped column). Once the old attempt's calls settled, it is sealed and its references are
|
||||
released.
|
||||
- **Routines** hand over per object: the old attempt's routines are aborted; the new ones start once the old routine
|
||||
calls finished, bounded by `closeTimeout` (a stuck one is reported and the new ones start anyway); calls never wait for
|
||||
it; each old routine invocation is fenced on its own (carry-over from 6a).
|
||||
- **Derives** have no names: exposed derives are matched by member name (references move to the new instance's members, §13
|
||||
item 11); internal ones are rebuilt.
|
||||
- **In-memory documents** migrate through their definition's `migrate` when the new code declares a higher version
|
||||
(6a built the slot version check; carry-over).
|
||||
- **`self.writable`** starts at its new initial value (decision 2).
|
||||
- **`retryOn`** sources are re-declared by the new factory.
|
||||
|
||||
## 6. References across a reload
|
||||
|
||||
**Provider side** (holds this host serves, and bindings on this host):
|
||||
|
||||
| The reload | A hold or binding |
|
||||
| --- | --- |
|
||||
| the object is swapped, its version still accepted | kept; re-subscribed through the version's mappings; one `[["r", value]]` per member whose value changed |
|
||||
| the object is restarted, version still accepted | kept; `loading`, then a fresh `bound` (a `bound` on a bound hold, which 4a's consumer applies as a re-binding) |
|
||||
| the new code no longer accepts the hold's version | unbound `version` (`failed { waiting }`); the object and its other holds stay |
|
||||
| the type is removed | unbound `not_served` (`failed { waiting }`) |
|
||||
|
||||
- **Over a link,** a `failed` on a bound hold unbinds it (model §5): the consumer applies it as `#startOver` plus
|
||||
`failed`; the provider detaches the hold's sources, drops its unsent pending ops and a queued `bound`, keeps the ack
|
||||
slots of `bound`/`state` messages already sent until their `ack` or the link's loss, and rejects the calls in flight on
|
||||
it once. `Hold.moved` and `ProviderSide.unbind` handle bound and binding holds (carry-over from 3a/6a). A served hold
|
||||
whose object leaves `live` while its `bound` waits for a slot goes back to pending.
|
||||
- **On the same host,** a `Binding` whose target ended by removal becomes unbound and accepts a second `bind` (model
|
||||
§5): the reference moves its members to the next object by name (§13 item 11), keyed pins re-pin, parked calls flush or reject, presence lists it again.
|
||||
- **The consumer's own reload:** the new factory makes new requests (§5's handover). A reference made by outside code
|
||||
(`host.hold`) keeps the token its caller passed; Chord reloads no caller code.
|
||||
- **A remote object that leaves `live`** (`RemoteObject.failed` ignores a failure while live today): handled by the
|
||||
unbinding above.
|
||||
|
||||
## 7. Versions in use (`accepts`)
|
||||
|
||||
- `host.serve(Type, factory, { accepts, linger })`: today every provider is bound with an empty table
|
||||
(`bindProvider(contract, {})`); 7b passes `options.accepts`, checked by `checkAccepts` at the setup (a declaration
|
||||
error rejects the reload or the load).
|
||||
- **Binding an older consumer:** a hold whose version is accepted binds through the mappings of that version (Q10):
|
||||
state members through `(current) => old`, served like a derive of the provider's member per object and version
|
||||
(adapted bindings get diffs: the last adapted value per object and version, `diffRevisions`, never a whole value per
|
||||
commit; §13.11); method calls through
|
||||
`checkArgs`: `{ kind: "method", args, result? }` runs the handler and maps the result back with `result`; `{ kind:
|
||||
"call", args, call }` runs the provider's replacement (L1 built the check; 7b invokes it). Removed state members with
|
||||
`"refuse"` and removed methods with `"refuse"` fail the binding or the call `contract_mismatch`.
|
||||
- **Keyed members** are mapped per key (carry-over from 6a), only on `local` contracts.
|
||||
|
||||
## 8. Jobs across a reload
|
||||
|
||||
- **Job records** keep the definition of the version their runs were started with; the new attempt's `self.job(Def,
|
||||
impl)` at another version no longer throws (`ChordObject.jobRecords` today): the records take the new definition, and
|
||||
each run keeps its own version.
|
||||
- A run started on an older version and still running finishes on its old code across a pure swap (its invocation keeps
|
||||
the old implementation); `finish` runs on the new code with the outcome's args and result mapped forwards through
|
||||
`accepts[old]` (once per run); `running` shows its args mapped.
|
||||
- No adapter for a run's version, or a run of a newer version (a rollback): the run is `blocked { version }` until a
|
||||
reload brings matching code; blocked runs (also `finish_failed` ones) are retried on every reload.
|
||||
- A native restart aborts running runs (`aborted { reason: "reload" }`); `finish` runs on the new code.
|
||||
- A job the new code no longer declares: its runs are `blocked { version }` (kept, not invoked) until a reload declares
|
||||
it again or they are aborted; a persisted run of another version or fingerprint stays blocked with its record as
|
||||
stored and is resolved by the reload that brings its version.
|
||||
- A `finish` in flight across a reload completes on the code it started with; the next one runs on the new code.
|
||||
- A job's fingerprint covers args, result and state: a schema change without a version bump is refused at declaration
|
||||
(as documents).
|
||||
- An older run's state document migrates through its own `migrate` only once its run settled or moved to the new code
|
||||
(the old code keeps writing the version it knows).
|
||||
|
||||
## 9. Errors, cycles, presence
|
||||
|
||||
- A broken object of a reloaded plugin is retried (as `ref.retry()`), whatever broke it.
|
||||
- Cycles: the walk and chains as today, checked again for the new attempts; `parked_long` unchanged.
|
||||
- Presence: holds kept across a swap stay listed; a restart lists them again when they re-bind.
|
||||
|
||||
## 10. Telemetry and reports
|
||||
|
||||
`onError` gets what fails after the commit as today (`activation_failed`, `close_failed`, `routine_failed`, …). No new
|
||||
`HostError` kind; the report returned by `reload` is the summary.
|
||||
|
||||
## 11. Tests, examples, cost
|
||||
|
||||
- One `describe` per mechanism: the API (serialized, before load, after close, abort before and after the commit);
|
||||
staging (each kind of throw leaves the host unchanged; recorded declarations decide the fate); the commit (types,
|
||||
registries, per-name wakes, `installing` holds); each fate (swap, restart, end, retry, parked, a new dependency
|
||||
missing); the swap (old calls on old handles, R15 for documents and tables, the handover of references without a gap,
|
||||
member re-binding, routines handed over and fenced, derives, in-memory migrate, a writable back to initial);
|
||||
references (each provider-side row over a memory link and through a relay, the window full while unbinding, a
|
||||
removed-then-re-served type with a live local dependent and with a remote one); `accepts` (state mapped per object and version as
|
||||
diffs, old args mapped, a result mapped back, a replacement `call`, `"refuse"`, keyed members per key); jobs (each
|
||||
rule of §8, in memory and persisted); registries (provider replaced, contributions by plugin replaced, a reader of an
|
||||
older version); a three-host scenario (server, worker, tab) reloaded in the app's order, as an example's test.
|
||||
- Examples: `33-reload.ts` (a pure swap, a restart and a removal, with old calls and holders shown) and `34-versions.ts`
|
||||
(a contract and a job at v2 with `accepts`, an old tab and an old run carried across).
|
||||
- Benchmarks: the hot paths against HEAD (calls, commits through a watched derive, activation, links) within the bar;
|
||||
plus a reload benchmark (one plugin of 20 reloaded on a host with 1,000 live objects, swap and restart) reported in
|
||||
`benchmarks.md`.
|
||||
- GC tests: an old attempt and its closures collected after its calls settled; a removed type's objects, providers
|
||||
and registry entries collected; an old facet's code collected after a reload (the precondition for loader D).
|
||||
|
||||
## 12. Out of scope
|
||||
|
||||
Slice 7c: the bundler and loader (`chord/next/loader`), the dev loop (`chord/next/dev`), plugin discovery and npm
|
||||
dependencies. The app: the rollout across hosts (decision 3: it calls `host.reload` per host in order). Slices 10 and
|
||||
11: the app kit. `rebuild` of tables (key or column-type changes): only if a case needs it (carry-over stays).
|
||||
|
||||
## 13. Decisions of the build (owner, 2026-10-29; 11 is the builder's, reported for review)
|
||||
|
||||
1. An object of a reloaded plugin that is activating but not parked (its own steps), or waiting for its backoff, is
|
||||
`retried` (an activating attempt sealed, its natives closed `reload`).
|
||||
2. The report's `parked` is for swaps and retries whose new attempt's first dependency check is not live (`reload`
|
||||
waits for that check, not for live); restarts are reported `restarted` at once (their check follows the drain). A
|
||||
blocker that is only loading gives the cause `waiting` "waiting for X: X is loading".
|
||||
3. A swapped-out attempt is sealed once its running calls settled (bounded by `closeTimeout`, `drain_timeout`) and
|
||||
the swap ended; its references are released then. Its job invocations finish on their old code. Retiring
|
||||
attempts are bounded by `closeTimeout` each; a later swap does not wait for them (several may retire at once after
|
||||
quick reloads), a restart, an end and the host's close do (an end rejects their callers at once). (Fix round 1:
|
||||
Fable, Opus F1.)
|
||||
4. A stuck old routine call past the handover is reported `drain_timeout`; the old attempt's routine calls share one
|
||||
fence token, sealed when the new routines start.
|
||||
5. A swap whose new factory picks another variant sends its holds a fresh `bound`; so does one after which a hold's
|
||||
version is an older one (adapted members).
|
||||
6. A native restart: in-memory runs end `aborted { reason: "reload" }` and finish on the new code; persisted runs
|
||||
follow 6b R3 (a seal while the object continues is a clean stop): written back stopped, resumed per `resume` by the
|
||||
new attempt without counting a restart.
|
||||
7. Staging is synchronous; `installing` is built and tested from inside a staged factory.
|
||||
8. ~~A removed object's member sources and pinned keys are frozen and handed over to the next object of its identity.~~
|
||||
**Superseded** (owner, 2026-10-29, decision "References are the only indirection"): references keep their last
|
||||
values while unbound and move their subscriptions to the next object; nothing is handed between objects.
|
||||
9. A loaded plugin passed as the same facet object is unchanged (§1).
|
||||
10. Keyed members' versions (§7) are built with L1's keyed edits (`keyed`, `addKeyed`, `removeKeyed`) and an accepts
|
||||
section `keyed: { member: (current) => old | "refuse" }` (removed ones: `"refuse"`).
|
||||
11. Adapted state members are shared per object instance and version (not per binding): one cached value and one diff
|
||||
per commit for every consumer of a version; each hold encodes its own ops. Fix rounds 1 to 7 built sources owned by
|
||||
(identity, version) that outlived their object and moved to the next one (`host/handover.ts`), and kept finding
|
||||
edge cases in that hand-over. **Replaced** (owner, 2026-10-29, decision "References are the only indirection"): a
|
||||
consumer's watches and derives attach through the reference's member, which passes them straight to the object's
|
||||
own document or derive and moves them when the reference's target changes (one frame); while unbound the reference
|
||||
keeps its last values (watches get one `failed` call); an older consumer attaches to the instance's shared mapping
|
||||
for its version (made on first use, dropped when no reference of the version is left, gone with the instance).
|
||||
Nothing outlives its object instance.
|
||||
608
packages/chord/docs/next/slice-7b/book.md
Normal file
608
packages/chord/docs/next/slice-7b/book.md
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
---
|
||||
title: Slice 7b: reloading plugins in a running Chord app
|
||||
cover: Slice 7b / Reload
|
||||
kicker: A design brief · Chord v2 · slice 7b · 2026-10-29
|
||||
subtitle: What a plugin is, how it is built and loaded, how a mini p2 is assembled from Chord, and what happens when a plugin's code changes on one host or across many
|
||||
stats:
|
||||
- 4 | steps of a reload on one host
|
||||
- 3 | fates of a live object
|
||||
- 26 | carry-over items from slices 3a to 7a
|
||||
- 11 ms | to rebuild one facet after an edit
|
||||
- 6 | decisions open
|
||||
note: |
|
||||
A brief before the slice 7b spec, written by the redesign session from `design.md` §21, the dependency model
|
||||
(`dependencies/model.md`, decided and built for 7a in pi `339847eb1`), the carry-over in `slices.md` and the code at
|
||||
`339847eb1`. Marks: **Decided** points cite the decision; **Open** points are the owner's, with a recommendation.
|
||||
colophon: Slice 7b brief · 2026-10-29 · Chord v2
|
||||
---
|
||||
|
||||
# Before we begin {.preface}
|
||||
|
||||
Mario edits the notes plugin while a session is open: a new method, a renamed field in a document, a second state
|
||||
member. He presses Reload. The server, the session's worker and two browser tabs run the old notes code; within a
|
||||
second they should all run the new one, with the notes, the open views and the running export job intact, and without
|
||||
restarting a single process.
|
||||
|
||||
Chord v2 can do everything around that today: objects, references across hosts, the store, jobs, registries, and since
|
||||
7a a dependency model in which a missing provider is a wait, not an error. What it cannot do yet is change code. `load`
|
||||
runs once per host (`host.ts`: "load runs once per host"). Slice 7b adds the reload, on one host and across hosts.
|
||||
|
||||
## Eight things to know
|
||||
|
||||
:::findings
|
||||
1. **A reload is a transaction per host.** Load the bundles, stage the new declarations, commit atomically, then let
|
||||
each object follow; any code error before the commit leaves the host entirely on its old code.
|
||||
2. **A live object has three fates.** Pure objects swap in place; objects with natives drain and restart; objects of a
|
||||
removed type end, and their holders wait.
|
||||
3. **Old calls finish on old code.** A pure swap keeps the running calls' old handles valid until they settle; only new
|
||||
calls see the new code.
|
||||
4. **Versions are the provider's business.** A provider accepts older consumers through `accepts`; a consumer that does
|
||||
not fit waits (7a), it is not ended.
|
||||
5. **7a already decided most of the hard part.** Removal, dropped versions and installs are waits woken by a
|
||||
publication; a reload's commit is such a publication.
|
||||
6. **A plugin is a directory with a `package.json` and one file per surface.** Each facet is bundled alone; host
|
||||
packages are passed in, never copied; a whole plugin set builds in a few hundred milliseconds.
|
||||
7. **Chord should ship the app kit** (proposed): loader, HTTP, workers, web, TUI and dev reload as optional
|
||||
components, so a mini p2 is a page of code.
|
||||
8. **Six points are open:** the reload API, the rollout across hosts, a `writable` across a swap, the install
|
||||
watchdog, where the loader lives, and the app kit.
|
||||
:::
|
||||
|
||||
## Words used throughout
|
||||
|
||||
A **reload** changes the code of one or more plugins on one host. A **facet** is a plugin's code for one host kind; a
|
||||
**bundle** is the built JavaScript of a facet. **Staging** runs the new facets and factories against a recording
|
||||
`self` without activating anything. The **commit** switches the host to the new code at once and is a **publication**
|
||||
in 7a's sense. A **pure** object has no natives (`self.open`), in its old or its new code. A **rollout** is a reload
|
||||
of the same code set on every host, server first.
|
||||
|
||||
# A Plugin, File by File {#plugin lede="What a plugin looks like on disk, and what each file is for."}
|
||||
|
||||
A plugin is a directory. Its name is its `package.json` name; its code is one file per host kind it runs on, a
|
||||
**facet**; everything else is ordinary TypeScript the facets import. This is p2's layout today (`apps/p2/plugins/*`),
|
||||
plus the `package.json` that loader D needs (p2's `plugin-loading.md` §8; p2's built-in plugins have none yet and are
|
||||
named by their directory).
|
||||
|
||||
```text
|
||||
plugins/notes/
|
||||
package.json name = the plugin's name; peerDependencies = host packages; dependencies = bundled npm deps
|
||||
contract.ts the plugin's object types and registries (shared by its facets and by other plugins)
|
||||
server.ts facet for the server (default export: defineFacet(...))
|
||||
worker.ts facet for session workers
|
||||
web.tsx facet for browser tabs
|
||||
tui.ts facet for the TUI
|
||||
store.ts plain modules the facets import
|
||||
NoteList.tsx
|
||||
notes.css
|
||||
assets/
|
||||
template.md files the plugin ships (read at runtime, or offered for download)
|
||||
icon.svg
|
||||
skills/notes/SKILL.md optional resources (p2 §4.4)
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@pi/notes",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"peerDependencies": {
|
||||
"@earendil-works/chord": "^2",
|
||||
"@earendil-works/pi-durable": "^1",
|
||||
"react": "^19"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "16.4.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`name`** identifies the plugin everywhere: its facets, its registry contributions, diagnostics, reloads.
|
||||
- **`peerDependencies`** are **host packages**: the running app supplies them, one instance per process (one Chord,
|
||||
one React; `instanceof ChordError` and React hooks only work that way). The bundler never copies them.
|
||||
- **`dependencies`** are the plugin's own npm packages (`marked`): installed into the plugin's `node_modules` with
|
||||
`--ignore-scripts` and bundled into every facet that imports them. **Decided** (owner, 2026-10-29): bundled by
|
||||
default; on Node a plugin may keep named packages external by an explicit list, `"chord": { "external":
|
||||
["better-sqlite3"] }`. Server and worker facets then load them from the plugin's `node_modules` with `createRequire`,
|
||||
as pi 1.0 loads extension dependencies, native addons included; they stay in Node's module cache until the process
|
||||
ends, so a new version takes effect after a restart. A browser or TUI facet that imports one fails to build.
|
||||
- **No `tsconfig.json` is needed.** The bundler strips types and does not type-check; JSX is compiled with the
|
||||
automatic React runtime, a fixed setting of the loader. A plugin may keep a `tsconfig.json` for its editor and for
|
||||
`tsc --noEmit` (the SDK ships one to extend); the loader ignores it.
|
||||
|
||||
A facet only declares (chapter III of the model): it serves object types, serves or contributes to registries, and
|
||||
returns nothing.
|
||||
|
||||
```ts
|
||||
// plugins/notes/contract.ts
|
||||
import { defineRegistry, local, remote, str, obj, arr, method } from "@earendil-works/chord/next";
|
||||
import { Session } from "@app/core"; // the app's host kinds, a host package
|
||||
export const NoteStore = local("notes.store", { version: 1, params: {}, members: { /* … */ } });
|
||||
export const Notes = remote("notes", {
|
||||
version: 2,
|
||||
params: { session: str() },
|
||||
hostAddress: ({ session }) => Session({ id: session }),
|
||||
variants: { editor: { state: { list: arr(obj({ id: str(), text: str() })) }, methods: { add: method(obj({ text: str() })) } } },
|
||||
});
|
||||
|
||||
// plugins/notes/worker.ts
|
||||
import { defineFacet } from "@app/core"; // bound to the app's principal type
|
||||
import { NoteStore, Notes } from "./contract.ts";
|
||||
import { serveStore } from "./store.ts";
|
||||
export default defineFacet((host) => {
|
||||
host.serve(NoteStore, serveStore);
|
||||
host.serve(Notes, ({ session }, principal, self) => { /* … */ });
|
||||
});
|
||||
|
||||
// plugins/notes/web.tsx
|
||||
import { defineFacet } from "@app/core";
|
||||
import { NoteList } from "./NoteList.tsx"; // a React component, bundled
|
||||
import "./notes.css"; // bundled into the facet's stylesheet
|
||||
export default defineFacet((host, self) => {
|
||||
self.contribute(Panels, { notes: NoteList }); // the app's UI registry
|
||||
});
|
||||
```
|
||||
|
||||
# Building and Loading {#loading lede="How the bundler knows what to pull in, and how the code gets into a running process."}
|
||||
|
||||
Each facet is built on its own, as one script. The bundler starts at the facet file and follows its imports:
|
||||
|
||||
| An import of | Becomes |
|
||||
| --- | --- |
|
||||
| the plugin's own files (`./store.ts`, `./NoteList.tsx`) | inlined into the facet's script |
|
||||
| another plugin's contract (`../sessions/contract.ts`, or `@pi/sessions/contract` for an installed package) | inlined too: a copy, so a plugin carries the contract version it was built against (`accepts` needs that); only contracts and side-effect-free modules may be imported across plugins |
|
||||
| an npm package in `dependencies` (`marked`) | inlined, resolved from the plugin's own `node_modules` |
|
||||
| an npm package listed in `chord.external` (Node facets only) | external: `require`d from the plugin's own `node_modules` at evaluation |
|
||||
| a host package (`@earendil-works/chord/next`, `react`, `@app/core`, …) | **external**: a parameter of the script, the host's instance at load time |
|
||||
| a Node builtin (`node:fs`) | external, through the `require` the loader passes (server and worker facets only; a web facet that imports one fails to build) |
|
||||
| CSS (`import "./notes.css"`) | collected into one stylesheet per web facet |
|
||||
| an asset by URL (`import iconUrl from "./assets/icon.svg?url"`) | a content-hashed file served by the app; the import is its URL |
|
||||
|
||||
The built script is a function of its host packages. That is the whole trick of loader D: there is no module map, no
|
||||
second copy of Chord, and an old version is garbage-collected once nothing holds its closures.
|
||||
|
||||
```js
|
||||
// what the bundler emits for plugins/notes/worker.ts (simplified)
|
||||
(function (require, __files) {
|
||||
const { defineFacet } = require("@app/core"); // the host's instance
|
||||
const { local, remote, str } = require("@earendil-works/chord/next");
|
||||
const { marked } = /* marked's code, inlined */;
|
||||
/* contract.ts, store.ts, inlined */
|
||||
return defineFacet((host) => { /* … */ });
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
// the loader (proposed @earendil-works/chord/next/loader), Node side
|
||||
import { buildFacet, evaluateFacet } from "@earendil-works/chord/next/loader";
|
||||
|
||||
const HOST_PACKAGES = { // the app's base bundle exports these
|
||||
"@earendil-works/chord/next": chordNext, "@earendil-works/pi-durable": durable,
|
||||
"@app/core": core, react, "react/jsx-runtime": jsxRuntime,
|
||||
};
|
||||
const script = await buildFacet({ plugin: "/data/plugins/notes", facet: "worker.ts", hostPackages: Object.keys(HOST_PACKAGES), platform: "node" });
|
||||
const facet = evaluateFacet(script, { hostPackages: HOST_PACKAGES, require: createRequire(import.meta.url), dir: "/data/plugins/notes" });
|
||||
await host.reload([{ name: "@pi/notes", facet }], ctx); // or host.load the first time
|
||||
```
|
||||
|
||||
- `buildFacet` is esbuild with: the facet as entry, the host packages and `node:*` external, `format: "cjs"` wrapped
|
||||
as the function above, minified, an `onResolve` that refuses anything outside the plugin, its `node_modules`, a
|
||||
contract of another plugin or a host package. It returns the script, its content hash and esbuild's metafile (the
|
||||
dev loop knows which files a facet used).
|
||||
- `evaluateFacet` runs the script with `vm.Script` and a stable filename per (facet, hash) on Node, or as a classic
|
||||
`<script>` in the browser (the page fetches the script and its stylesheet by hash, which caches forever).
|
||||
- **Imports a plugin cannot use:** dynamic `import()` (resolved at build, or not at all), top-level `await` (a facet is a
|
||||
synchronous declaration), `require` of anything but builtins.
|
||||
|
||||
:::review | Open 5: where the loader lives
|
||||
(a) Chord ships `chord/next/loader` (discovery, `buildFacet`, `evaluateFacet`, `pluginFile`), an app chooses its host
|
||||
packages; (b) Chord knows only `host.reload(facets)` and the app bundles and evaluates. Recommended: (a); the kernel
|
||||
stays free of it, and Durable Objects (no `eval`) compose statically as before.
|
||||
:::
|
||||
|
||||
# Assets and Files {#assets lede="Files a plugin ships: bundled into the browser, read at runtime, or offered for download."}
|
||||
|
||||
A plugin has files that are not code. They fall into three cases.
|
||||
|
||||
**1. Imported by a web facet.** CSS, images, wasm. The bundler emits them as content-hashed files next to the facet's
|
||||
script, and the import evaluates to their URL. The app serves the bundle's files under one path; p2 does exactly this
|
||||
today for the terminal's wasm (`plugins/terminals/Terminal.tsx`: `import ghosttyWasm from
|
||||
"../../vendor/ghostty/ghostty-vt.wasm?url"`, served under `/__p2_web/`).
|
||||
|
||||
```tsx
|
||||
import iconUrl from "./assets/icon.svg?url";
|
||||
export const NoteIcon = () => <img src={iconUrl} alt="" />;
|
||||
```
|
||||
|
||||
**2. Read at runtime by a server or worker facet.** A template, a wasm module for a native, a script a native runs. A
|
||||
bundled script has no meaningful `import.meta.url` (today's `plugins/terminals/main.ts` reads its wasm with
|
||||
`new URL("../../vendor/…", import.meta.url)`). The loader passes the plugin's directory, and a helper resolves a path
|
||||
inside it, refusing `..` out of it:
|
||||
|
||||
```ts
|
||||
// plugins/notes/worker.ts
|
||||
import { pluginFile } from "@earendil-works/chord/next/loader"; // reads the directory the loader passed
|
||||
const template = await readFile(pluginFile("assets/template.md"), "utf8");
|
||||
```
|
||||
|
||||
**3. Offered for download over HTTP.** A plugin contributes a route to the app's HTTP registry; the handler opens the
|
||||
file through the same helper and streams it, with the plugin's own authorization:
|
||||
|
||||
```ts
|
||||
// plugins/notes/server.ts
|
||||
export default defineFacet((host, self) => {
|
||||
self.contribute(Http, {
|
||||
routes: {
|
||||
"GET /notes/template.md": async (request, ctx) => {
|
||||
if (ctx.principal.kind !== "owner") return { kind: "error", status: 403 };
|
||||
const path = pluginFile("assets/template.md");
|
||||
const { size } = await stat(path);
|
||||
return { kind: "bytes", name: "template.md", mime: "text/markdown", size, read: (offset, length) => readRange(path, offset, length) };
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
So yes: a plugin can offer its files for download, because the loader tells it where it lives. Each facet sees only its
|
||||
own plugin's directory; nothing gives it another plugin's files.
|
||||
|
||||
# HTTP {#http lede="What Chord provides, what p2 has, and what p3 needs."}
|
||||
|
||||
**Chord v2 has no HTTP server today.** Design §23 designs one (`@earendil-works/chord/next/http`) and defers it until
|
||||
"the kernel fixes and a first real proof" are done. p2's HTTP is its own, about 30 lines of helpers plus the server host:
|
||||
|
||||
```ts
|
||||
// apps/p2/src/runtime/server.ts (p2 today, Chord v1)
|
||||
export const Routes = defineRegistry<RouteHandler>("p2.routes", { conflict: "error" }); // "METHOD /path" → handler
|
||||
export const Upgrades = defineRegistry<UpgradeHandler>("p2.upgrades", { conflict: "error" }); // WebSocket upgrades
|
||||
export const RequestResolvers = defineRegistry<RequestResolver>("p2.request-resolvers", { conflict: "error" });
|
||||
type RouteHandler = (request: IncomingMessage, response: ServerResponse, access: RequestAccess, ctx: Ctx) => void | Promise<void>;
|
||||
```
|
||||
|
||||
Plugins contribute handlers that write to Node's `ServerResponse` themselves; the files plugin implements ranges,
|
||||
download headers and previews by hand (`plugins/files/server.ts`).
|
||||
|
||||
The designed component (§23) replaces raw responses with descriptions, so every plugin gets ranges (206/416), `HEAD`,
|
||||
safe download headers, streaming and abort on disconnect from one place:
|
||||
|
||||
```ts
|
||||
export const Http = defineRegistry<HttpContribution, Router>("chord.http", { version: 1 });
|
||||
type HttpContribution = { routes?: Record<string, Route>; wraps?: Wrap[] };
|
||||
type Route = (req: HttpRequest, ctx: Ctx) => Promise<HttpResponse>; // ctx.principal: the request's principal
|
||||
type HttpResponse =
|
||||
| { kind: "json"; value: JsonValue } | { kind: "page"; title: string; body: string }
|
||||
| { kind: "bytes"; name: string; mime: string; size: number; read(offset: number, length: number): Promise<Uint8Array> }
|
||||
| { kind: "redirect"; to: string } | { kind: "error"; status: number };
|
||||
```
|
||||
|
||||
- Routes compose by `keyedEntries`: a duplicate pattern is a reported conflict, the first by plugin name wins.
|
||||
- Wraps (middleware) run in phases the owner defines (`observe`, `headers`, `transform`), by default only around their
|
||||
own plugin's routes.
|
||||
- Request resolvers (who sent a request: cookie, loopback, relay) are hooks; WebSocket byte channels for terminals are
|
||||
a separate component.
|
||||
|
||||
# Performance {#perf lede="Measured on slayer, Node 24, esbuild from the repo, p2's plugins as they are."}
|
||||
|
||||
Built with the loader D settings above (each facet alone, host packages and `apps/p2/src` external, minified), on
|
||||
p2's real plugins:
|
||||
|
||||
| Measurement | Result |
|
||||
| --- | --- |
|
||||
| all 25 worker facets, one script each, cold | 237 ms, 294 KB in total, largest `models/worker.ts` 35 KB |
|
||||
| all 20 server facets, cold | 167 ms, 236 KB |
|
||||
| all 17 web facets, cold | 144 ms, 912 KB, of which 688 KB is the terminal's wasm |
|
||||
| one facet rebuilt after an edit (esbuild context, warm) | 11 ms |
|
||||
| compile and evaluate all 25 worker scripts (`vm.Script`) | 23 ms |
|
||||
| worker spawn to ready, p2 today (TypeScript through the source resolver, `nodeLoader`) | 549 ms (p2's measurement, `plugin-loading.md` §14.1) |
|
||||
| the same with one bundle per worker plugin set | 218 ms |
|
||||
| base bundle plus one script per plugin through ESM (model B) | 254 ms; D is expected near it |
|
||||
|
||||
The plugins are small; the time goes into the host packages and the runtime (85% of a worker's load time, p2 §14.1),
|
||||
which the base bundle loads once per process. A reload of one plugin costs one rebuild (about 11 ms warm) and one
|
||||
evaluation (about 1 ms per facet), then Chord's reload. The memory caveat is V8's compilation cache: every distinct
|
||||
compiled script stays cached, so scripts get a stable name per (facet, hash) and hosts that reload in place run with
|
||||
the cache off (+0.1 MiB instead of +5.6 MiB per 100 reloads, p2's measurement).
|
||||
|
||||
# A Mini p2 from Chord {#kit lede="The owner: building a small p2 with a web and a TUI surface and plugins should be trivial from Chord; optional components should come out of the box."}
|
||||
|
||||
p2 has about 10,700 lines of host code in `apps/p2/src/hosts` and its runtime: the server's HTTP and WebSocket
|
||||
entry, the worker supervisor, plugin discovery, the web and TUI bundles, the web shell, the TUI sandbox and launcher,
|
||||
the reload control. Almost none of it is p2-specific. The proposal: Chord ships it as optional components, each a
|
||||
subpath an app imports only if it wants it, none of them in Chord's kernel.
|
||||
|
||||
## What it would look like
|
||||
|
||||
```ts
|
||||
// server.ts: a whole app with plugins, a web and a TUI surface
|
||||
import { createHost } from "@earendil-works/chord/next";
|
||||
import { discoverPlugins, pluginLoader } from "@earendil-works/chord/next/loader";
|
||||
import { httpServer } from "@earendil-works/chord/next/http";
|
||||
import { workerRoute } from "@earendil-works/chord/next/workers";
|
||||
import { webSurface } from "@earendil-works/chord/next/web";
|
||||
import { tuiSurface } from "@earendil-works/chord/next/tui";
|
||||
import { devReload } from "@earendil-works/chord/next/dev";
|
||||
import { Server, Session, core } from "./core.ts"; // the app's base code: host kinds, principals, core facet
|
||||
|
||||
const plugins = await discoverPlugins(["./plugins", join(dataDir, "plugins")]);
|
||||
const loader = pluginLoader({ hostPackages: core.hostPackages });
|
||||
|
||||
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError });
|
||||
server.route(Session, workerRoute({ entry: "./worker.ts", idleMs: 300_000, authorize: core.authorize }), ctx);
|
||||
await server.load([core.serverFacet, ...(await loader.facets(plugins, "server"))], ctx);
|
||||
|
||||
const http = httpServer(server, { port: 4321, authenticate: core.authenticate }); // routes from the Http registry
|
||||
http.surface(webSurface({ loader, plugins, shell: core.webShell })); // /: the page, its bundle, its socket
|
||||
http.surface(tuiSurface({ loader, plugins })); // the TUI launcher's bundle and socket
|
||||
devReload({ server, loader, plugins, watch: true }); // a changed plugin reloads everywhere
|
||||
```
|
||||
|
||||
```ts
|
||||
// worker.ts
|
||||
import { workerMain } from "@earendil-works/chord/next/workers";
|
||||
await workerMain(async ({ address, init, parent }) => {
|
||||
const worker = createHost<P>({ address, principal: { kind: "worker", session: init.id }, onError });
|
||||
worker.connect(parent, { principal: { kind: "server" }, trusted: true }, ctx);
|
||||
await worker.load([core.workerFacet(init), ...(await loader.facets(init.plugins, "worker"))], ctx);
|
||||
return worker;
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// core.ts, the web shell: the app decides the layout; plugins fill registries
|
||||
export const Panels = defineRegistry<Record<string, ComponentType>, Panel[]>("app.panels", { version: 1 });
|
||||
export const webShell = ({ host }: ShellProps) => {
|
||||
const panels = useRegistry(host, Panels); // from chord/next/web: a hook over a registry value
|
||||
return <Tabs>{panels.map((p) => <p.component key={p.name} />)}</Tabs>;
|
||||
};
|
||||
```
|
||||
|
||||
And a plugin is what chapter I shows: a directory, a `package.json`, facets that serve types and contribute to the
|
||||
app's registries (`Http`, `Panels`, a TUI command list).
|
||||
|
||||
## The components
|
||||
|
||||
| Subpath | What it does | Replaces in p2 |
|
||||
| --- | --- | --- |
|
||||
| `chord/next/loader` | `discoverPlugins` (directory listing, `package.json` names, no parsing), `buildFacet` (esbuild, host packages external), `evaluateFacet` (Node `vm`, browser `<script>`), `pluginFile`, npm/git install with `--ignore-scripts` | `src/hosts/plugins.ts`, `bundles.ts`, the source resolver, `nodeLoader` |
|
||||
| `chord/next/http` | the Node HTTP server, the `Http` registry (routes, wraps, request resolvers, responses as descriptions: ranges, downloads, `HEAD`), WebSocket entry calling `host.accept` | `src/hosts/server.ts` (HTTP part), `runtime/http.ts`, `Routes`, `Upgrades`, `RequestResolvers` |
|
||||
| `chord/next/workers` | `workerRoute` (fork a worker per address, IPC link, start message, idle stop from `host.activity`, crash restart with backoff), `workerMain` (the child's side) | `SessionHosts` in `runtime/server.ts`, `hosts/worker.ts` |
|
||||
| `chord/next/web` | the page host (connect over a WebSocket with reconnect, load web facets from their scripts, React provider and hooks: `useObject` over `host.hold`, `useRegistry`, `useWatch`), serving the bundle by hash, reload on a new manifest | `hosts/web/*`, `runtime/web.tsx`, `src/web/reconnect.ts` |
|
||||
| `chord/next/tui` | the TUI launcher and sandbox (pi-tui, the bundle download, a link over the launcher's socket), slots and commands as registries | `hosts/tui-*.ts`, `runtime/tui.ts` |
|
||||
| `chord/next/dev` | the source watcher marking plugins dirty, the reload control (`reload(plugins)` on every host in order: server, workers, then a manifest bump for pages and the TUI) | `performReload` in `hosts/server.ts`, the dev protocol |
|
||||
|
||||
Each is ordinary code on top of Chord's public API: nothing in the kernel depends on it, a Durable Object deployment
|
||||
uses none of it, and an app can replace any one (its own HTTP server, its own shell). React is a host package of the web
|
||||
component, pi-tui of the TUI component; an app without them never imports them.
|
||||
|
||||
## What stays the app's
|
||||
|
||||
The principal type and authentication; the host kinds (`Server`, `Session`) and what a worker's start message holds;
|
||||
the core facet and core objects (catalog, harness); the web shell's layout and the TUI's look; which registries plugins
|
||||
contribute to beyond `Http`. That is roughly what p3's base code should be: a few hundred lines, not ten thousand.
|
||||
|
||||
:::review | Open 6: the app kit
|
||||
(a) Chord ships these six components as optional subpaths of `@earendil-works/chord`, built in this order: `loader`
|
||||
and `dev` with 7b (reload needs them), `http` and `workers` next (p3 needs them first), `web` and `tui` after (lifted
|
||||
from p2's code, which works today). (b) Chord ships only `loader`; the rest is p3's base code. Recommended: (a), as the
|
||||
owner asked; each component gets its own slice spec and review, after 7b and before slice 9 or alongside p3's base.
|
||||
:::
|
||||
|
||||
# One Host Reloads {#host lede="Four steps, all or nothing up to the commit."}
|
||||
|
||||
Design §21 fixes the shape. **Decided.**
|
||||
|
||||
```text
|
||||
1. load the new bundles; an import or syntax error rejects
|
||||
2. staging facet setups and every live object's new factory run with a recording `self`
|
||||
(declarations only, nothing activated); any throw rejects
|
||||
3. commit the host switches to the new code atomically: a publication (7a)
|
||||
4. after each object follows its fate (chapter II)
|
||||
```
|
||||
|
||||
Rollback is all or nothing per host, and only for code errors: an import error, a throwing setup or factory. A failed
|
||||
`open`, a registry conflict or a failing `migrate` after the commit are runtime states of single objects, never a
|
||||
reason to roll back. A host is at revision R or R−1, never in between.
|
||||
|
||||
Staging runs factories against a recording `self` because a factory only declares: it returns members and calls
|
||||
`self.state`, `self.get`, `self.open` and so on, and the recording tells Chord what changed (a new dependency, a native
|
||||
added or removed, a member gone) before anything happens.
|
||||
|
||||
:::sequence animate | A reload of the notes plugin on the worker. Nothing is visible to callers until the commit.
|
||||
actor app "base code" "reload control"
|
||||
actor host "worker W" ""
|
||||
actor objs "notes objects" "live"
|
||||
app -> host: reload([notes v2], ctx)
|
||||
host -> host: load bundles
|
||||
host -> host: staging: setups + new factories (recording self)
|
||||
host => host: commit: types, registries, contributions swapped; publication
|
||||
host => objs: each follows its fate
|
||||
objs --> app: resolved: { reloaded, restarted, ended, failed }
|
||||
:::
|
||||
|
||||
:::review | Open 1: the API
|
||||
Today a host has `load(facets, ctx)`, once. Proposal: `host.reload(facets, ctx)` with the plugins' new facets (by
|
||||
plugin name: added, changed; a plugin missing from the list is removed), resolving with a report of what each object
|
||||
did, rejecting with the code error and nothing changed. `load` stays the first call. Alternative: one
|
||||
`host.load` that may be called again. Recommended: the separate `reload`, because its result differs (a report, not a
|
||||
barrier) and its rollback rule does not apply to the first load (a throwing first setup closes the host).
|
||||
:::
|
||||
|
||||
# Three Fates {#fates lede="What happens to each live object at the commit."}
|
||||
|
||||
The commit compares each live object's old and new declarations. **Decided** (design §21, owner 2026-10-26, the
|
||||
dependency model).
|
||||
|
||||
| The object | Fate | Calls | Documents, jobs |
|
||||
| --- | --- | --- | --- |
|
||||
| pure, old and new (no `self.open`) | **swapped in place**: new methods, derives and routines; references kept, new ones made | running calls finish on old handles; new calls see new code | stay |
|
||||
| has natives, old or new | **restarted**: new calls park, running calls drain (`closeTimeout`), natives close (`reload`), the new factory runs, natives reopen | park during the restart | stay; running job runs end `aborted { reload }`, `finish` runs on new code |
|
||||
| its type is gone | **ended**: natives close (`end`), callers rejected, handlers drain | rejected | in-memory gone; persisted stay until retired |
|
||||
| a new dependency is missing (any of the above) | **parks** (decision 4): gate closed, `failed { waiting }` | reject | stay |
|
||||
|
||||
```ts
|
||||
// notes v1 // notes v2: pure, swapped in place
|
||||
host.serve(NoteStore, (_, self) => { host.serve(NoteStore, (_, self) => {
|
||||
const notes = self.state(Notes); const notes = self.state(Notes); // same document, v2 migrates it
|
||||
return { const count = self.derive([notes], ([n]) => n.list.length);
|
||||
notes, return {
|
||||
add: (a, ctx) => self.tx(…, ctx), notes, count, // a new exposed member
|
||||
}; add: (a, ctx) => self.tx(…, ctx),
|
||||
}); rename: (a, ctx) => self.tx(…, ctx), // a new method
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
Rules that come with the fates, all **decided**:
|
||||
|
||||
- **R15, old code and migrated documents:** an old call that touches a document whose version rose throws once; the
|
||||
document stays intact; the call is not resent.
|
||||
- **Derives** have no names: exposed derives are matched by their member name, internal ones are rebuilt.
|
||||
- **Routines** hand over per object: the old ones are aborted, the new ones start once the old calls finished, bounded
|
||||
by `closeTimeout`; calls do not wait for it.
|
||||
- **Instance ids:** a restarted object gets a new instance id (an old activation chain must not match it); a swapped one
|
||||
keeps it.
|
||||
- **Broken objects** of a reloaded plugin are retried, as `ref.retry()` does.
|
||||
- **A parked object** of a reloaded plugin is replaced by the new factory's attempt in one handover: the new requests
|
||||
bind before the old ones are released (the mechanism 7a removed comes back here).
|
||||
- **In-memory documents** migrate on reload through their definition's `migrate` (6a built the version check).
|
||||
|
||||
:::review | Open 2: a `writable` across a pure swap
|
||||
A pure swap runs the new factory, so `self.writable(initial)` makes a new writable at its initial value. An object that
|
||||
contributes MCP's tool list through a writable then contributes an empty list until its restarted routine sets it again:
|
||||
the tools vanish for a moment. Options: (a) accept it (the routine refills it within one call); (b) keep a writable's
|
||||
value across a swap when the new factory declares a writable at the same position (as members are matched by name).
|
||||
Recommended: (a); writables have no names, and position matching would be the first positional rule in Chord.
|
||||
:::
|
||||
|
||||
# Consumers Across a Reload {#consumers lede="Holds, versions and old calls; 7a decided that nothing is ended for a code change."}
|
||||
|
||||
A reference on another host points at an object whose code may change under it. **Decided** (design §21, §22 and the
|
||||
dependency model):
|
||||
|
||||
| The provider's reload | The consumer's hold | It sees |
|
||||
| --- | --- | --- |
|
||||
| still accepts the hold's version, object swapped | kept; re-subscribed through the version's mappings | one `[["r", value]]` per changed member |
|
||||
| still accepts it, object restarted | kept | `loading`, then a fresh `bound` |
|
||||
| stops accepting the version | unbound `version` | `failed { waiting }`; it binds again if a later reload accepts it |
|
||||
| removes the type | unbound `not_served` | `failed { waiting }`; it binds again when the type comes back |
|
||||
|
||||
A provider accepts older consumers through `accepts`, written once with the provider, never in the shared contract
|
||||
(Q10):
|
||||
|
||||
```ts
|
||||
export const NotesV2 = evolve(NotesV1, (e) => e.state("editor", "notes", arr(NoteV2)).args("add", NoteV2));
|
||||
|
||||
host.serve(NotesV2, factory, {
|
||||
accepts: {
|
||||
1: {
|
||||
state: { editor: { notes: (current) => current.map(toV1) } }, // what a v1 tab sees, as a diff per commit
|
||||
methods: { add: { args: (old) => toV2(old) } }, // a v1 tab's call, mapped forward
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- **Old-version calls:** `checkArgs` returns `{ kind: "method", args, result? }` or `{ kind: "call", args, call }`; Chord
|
||||
runs the handler or the provider's replacement and maps the result back (L1 built the check; 7b invokes it).
|
||||
- **Adapted bindings get diffs:** Chord keeps the last adapted value per binding and sends `diffRevisions`, so an old
|
||||
tab never receives a whole state per commit.
|
||||
- **On the wire** (7b's half of 7a's model): a `failed` on a bound hold unbinds it; the provider drops its unsent
|
||||
pending work and keeps the ack slots of messages already sent; a consumer whose own code moves to a new version
|
||||
releases its old hold and makes a new one.
|
||||
- **A consumer reloaded itself** gets new requests from its new factory; the handover binds them before it releases the
|
||||
old.
|
||||
|
||||
Today `host.serve` binds every provider with an empty accepts table (`host.ts`: `bindProvider(contract, {})`); 7b passes
|
||||
the provider's table. Small and **decided** in shape (Q10, `bindProvider`); the option's place is `host.serve(Type,
|
||||
factory, { accepts, linger })`.
|
||||
|
||||
# Jobs Across a Reload {#jobs lede="Runs belong to the object; their versions are mapped forward."}
|
||||
|
||||
**Decided** (design §15, owner 2026-10-24 and 2026-10-26):
|
||||
|
||||
```ts
|
||||
const ExportJob = defineJob("notes.export", {
|
||||
version: 2,
|
||||
args: obj({ format: oneOf("md", "html") }),
|
||||
result: obj({ path: str() }),
|
||||
accepts: { 1: { args: (old) => ({ format: "md" }), result: (old) => ({ path: old.file }) } },
|
||||
});
|
||||
```
|
||||
|
||||
- A run started on an older version finishes on its old code across a pure swap; `finish` and `running` see its args
|
||||
and result mapped forwards, once per run.
|
||||
- Without an adapter, or for a run of a newer version (a rollback), the run is `blocked { version }` until a reload
|
||||
brings matching code; blocked runs are retried on every reload. Nothing is aborted or discarded for a version change.
|
||||
- A native restart aborts running runs (`aborted { reload }`) and `finish` runs on the new code.
|
||||
- A job the new code no longer declares: its runs are `blocked { version }` (the same family), kept until a reload
|
||||
declares it again or they are aborted.
|
||||
- Persisted runs of another version or fingerprint stay blocked with their record as stored, resolved by the reload
|
||||
that brings their version.
|
||||
|
||||
# Registries Across a Reload {#registries lede="A provider and its contributions are swapped in one batch."}
|
||||
|
||||
**Decided** (slice 8 §6, design §17): the commit replaces a plugin's contributions and a registry's provider in one
|
||||
batch; staging composes nothing (D1). The record keeps the provider, facet contributions by plugin and object
|
||||
contributions by object apart, so a reload of one plugin replaces exactly its parts (today `RegistryRecord.serve`
|
||||
refuses a second serve). Older readers across a reload of the provider follow its new `accepts[old].value`. A reader
|
||||
waits (7a) when the new provider no longer serves its version. `retryOn` watches are re-declared by the new factory.
|
||||
|
||||
# Across Hosts {#rollout lede="The same reload on every host, in an order that cannot deadlock."}
|
||||
|
||||
**Decided** (design §21 and the dependency model): a revision is the complete code set; the server installs first,
|
||||
then workers, then clients; installs are serialized per host; a host that starts or reconnects installs the current
|
||||
revision first; a revision carries a code-set digest so an unchanged server restart reinstalls nothing; an install
|
||||
never waits for activations (a reverse-order rollout would deadlock); each host knows only its own install: while it
|
||||
installs, holds that do not fit its running code wait `installing`, re-evaluated when the install ends; a host whose
|
||||
own install was rejected fails its own requests with the reason.
|
||||
|
||||
:::steps | The notes contract goes from v1 to v2; the server installs first
|
||||
+++ Before
|
||||
S, W and T run revision R. T's notes view holds W's `Notes` v1. All live.
|
||||
+++ S installs R+1
|
||||
S has no notes objects of its own; its install commits. T, still on R, holds nothing on S that changed.
|
||||
+++ W installs R+1 with `accepts[1]`
|
||||
W's `Notes` v2 swaps in place and still accepts v1: T's hold is re-subscribed through the mapping and gets one diff.
|
||||
+++ W installs R+1 without `accepts[1]`
|
||||
T's hold becomes unbound `version`: `failed { waiting }`, last notes readable, calls reject with "update the page".
|
||||
+++ T reloads its page
|
||||
T's new code holds v2 with a new hold id; it binds; the old hold is released.
|
||||
:::
|
||||
|
||||
:::review | Open 3: who runs the rollout
|
||||
Chord's part is per host: `host.reload`, the `installing` state, and what a hold sees. The rest is distribution:
|
||||
building bundles, sending them to workers, telling tabs to reload, ordering, the digest. Options: (a) Chord ships a
|
||||
rollout protocol over its links (an `install` message from the server down, `installed`/`rejected` up, the digest in
|
||||
`hello`); (b) distribution is the app's (p3's base code calls `host.reload` on each host and orders them), as p2 does
|
||||
today (`apps/p2/src/hosts/server.ts` `performReload`: prepare every host, commit, publish bundles). Recommended: (b) for
|
||||
7b, with Chord's per-host guarantees; a protocol only if a second app needs the same code.
|
||||
:::
|
||||
|
||||
:::review | Open 4: the install watchdog (design §26 #24)
|
||||
An install that hangs (a bundle that never loads, a staging factory that awaits forever, which it may not) leaves its
|
||||
holds `installing` forever. Options: (a) a watchdog: `host.reload` rejects after `installTimeout` (default 60 s) and the
|
||||
host stays on its old code; (b) no Chord timer: the app's context bounds the reload. Recommended: (b): `reload` takes a
|
||||
context like everything else, and the app passes its deadline; a host option would be the only timer left in reload.
|
||||
:::
|
||||
|
||||
# Decisions {.epilogue lede="What is settled for the spec, and what the owner decides first."}
|
||||
|
||||
**Decided** (spec material): the four steps and rollback per host; the three fates and the parking of a swap with a
|
||||
missing dependency; old calls on old handles (R15); derives matched by member name; the routine handover; new instance
|
||||
ids on restart; broken objects retried on reload; the handover of a parked attempt; in-memory `migrate`; holds kept,
|
||||
re-subscribed or freshly bound; unbound `version` and `not_served` instead of `gone`; old-version calls and adapted
|
||||
bindings as diffs; `accepts` in `host.serve`; jobs' version mapping, `blocked { version }` and retries on reload, runs
|
||||
aborted on a native restart; registries swapped in one batch; rollout order, serialized installs, the digest,
|
||||
`installing`, installs never waiting for activations; loader D's design: one script per facet, host packages passed in,
|
||||
contracts bundled, no module map, stable script names, plugins' npm dependencies installed with `--ignore-scripts`.
|
||||
|
||||
**Open** for the owner:
|
||||
|
||||
| # | Point | Recommended |
|
||||
| --- | --- | --- |
|
||||
| 1 | the API: `host.reload(facets, ctx)` returning a report, or `load` callable again | `host.reload` |
|
||||
| 2 | a `writable` across a pure swap: back to its initial value, or kept by position | back to initial |
|
||||
| 3 | who runs the rollout: a Chord protocol, or the app calling `host.reload` per host | the app |
|
||||
| 4 | the install watchdog: a host option `installTimeout`, or the app's context | the app's context |
|
||||
| 5 | where loader D lives: Chord (`chord/next/loader`), or the app | Chord |
|
||||
| 6 | the app kit: Chord ships loader, HTTP, workers, web, TUI and dev reload as optional subpaths, or only the loader | Chord ships them, in that order |
|
||||
842
packages/chord/docs/next/slice-7b/index.html
Normal file
842
packages/chord/docs/next/slice-7b/index.html
Normal file
|
|
@ -0,0 +1,842 @@
|
|||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Slice 7b: reloading plugins in a running Chord app</title>
|
||||
<style>
|
||||
|
||||
:root{--paper:#fbf8f2;--ink:#2a2723;--muted:#7b7468;--rule:#e3dbcc;--accent:#b4472f;--blue:#2e6a8a;--sand:#c2b18c;--soft:#f4eee2}
|
||||
*{box-sizing:border-box}
|
||||
html{background:#efe9dd}
|
||||
body{margin:0;color:var(--ink);font:19px/1.68 'Iowan Old Style','Palatino Linotype',Palatino,'Book Antiqua',Georgia,serif;
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
||||
.book{max-width:860px;margin:0 auto;background:var(--paper);box-shadow:0 0 0 1px #e1d9c8,0 30px 80px -40px rgba(60,40,10,.35);padding:0 0 4rem}
|
||||
.inner{padding:0 90px}
|
||||
h1,h2,h3{font-weight:400;line-height:1.15}
|
||||
h2{font-size:2.1rem;margin:.2em 0 .3em;letter-spacing:-.01em}
|
||||
h3{font-size:1.05rem;text-transform:uppercase;letter-spacing:.14em;margin:2.4em 0 .6em;color:var(--ink);font-weight:600;
|
||||
font-family:'Avenir Next','Helvetica Neue',sans-serif}
|
||||
p{margin:0 0 1.05em;hyphens:auto;text-align:left}
|
||||
code{font:.8em/1 'SF Mono',Menlo,Consolas,monospace;background:#f1ebde;padding:.1em .35em;border-radius:3px}
|
||||
pre{font:.74rem/1.55 'SF Mono',Menlo,Consolas,monospace;background:#f4eee2;border-left:3px solid var(--sand);padding:1rem 1.2rem;margin:1.4rem 0 1.8rem;
|
||||
overflow-x:auto;border-radius:0 4px 4px 0;white-space:pre;tab-size:2}
|
||||
pre code{background:none;padding:0;font-size:1em}
|
||||
em{font-style:italic}
|
||||
a{color:var(--blue)}
|
||||
.cover{min-height:92vh;display:flex;flex-direction:column;justify-content:center;padding:90px;background:
|
||||
radial-gradient(circle at 85% 15%,rgba(180,71,47,.13),transparent 40%),radial-gradient(circle at 10% 90%,rgba(46,106,138,.12),transparent 45%),var(--paper);
|
||||
border-bottom:1px solid var(--rule)}
|
||||
.kicker{font:600 .78rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.22em;text-transform:uppercase;color:var(--accent)}
|
||||
.cover h1{font-size:5.2rem;line-height:.98;margin:.35em 0 .25em;letter-spacing:-.025em}
|
||||
.subtitle{font-size:1.45rem;font-style:italic;color:#4a453e;max-width:30em;text-align:left}
|
||||
.cover-stats,.stats{display:flex;flex-wrap:wrap;gap:1.2rem 2.4rem;margin:2.2rem 0}
|
||||
.stats{margin:1.6rem 0 1.8rem;padding:1.1rem 0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
|
||||
.stat b{display:block;font-size:2.1rem;font-weight:400;line-height:1.1;font-variant-numeric:lining-nums}
|
||||
.stat span{font:.78rem/1.3 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);letter-spacing:.03em;display:block;max-width:14em}
|
||||
.cover-note{font-size:.9rem;color:var(--muted);max-width:34em;text-align:left;margin-top:2rem}
|
||||
.toc{padding:4rem 0 2rem}
|
||||
.toc ol{list-style:none;padding:0;margin:1rem 0;columns:2;column-gap:3rem}
|
||||
.toc li{padding:.3rem 0;border-bottom:1px dotted var(--rule);break-inside:avoid}
|
||||
.toc a{color:var(--ink);text-decoration:none}
|
||||
.toc a:hover{color:var(--accent)}
|
||||
.preface{padding-top:2rem}
|
||||
.findings{padding-left:1.4em}
|
||||
.findings li{margin:.55em 0;padding-left:.3em}
|
||||
.findings li::marker{color:var(--accent);font-weight:600}
|
||||
.chapter{padding-top:4.5rem;margin-top:3rem;border-top:1px solid var(--rule)}
|
||||
.opener{text-align:center;margin-bottom:2.6rem}
|
||||
.numeral{font-size:4.2rem;color:var(--accent);line-height:1;font-variant:small-caps;letter-spacing:.05em}
|
||||
.opener h2{font-size:2.5rem;margin:.25em 0}
|
||||
.lede{font-style:italic;color:#5a544a;font-size:1.12rem;max-width:30em;margin:0 auto;text-align:center}
|
||||
.dropcap::first-letter{float:left;font-size:4.1em;line-height:.82;padding:.06em .09em 0 0;color:var(--accent)}
|
||||
.fig{margin:2rem 0 2.2rem}
|
||||
.fig.wide{margin-left:-50px;margin-right:-50px}
|
||||
.fig svg{display:block;margin:0 auto}
|
||||
figcaption{font:.8rem/1.45 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);margin-top:.7rem;padding-left:.8rem;border-left:2px solid var(--rule)}
|
||||
.fignum{color:var(--ink);font-weight:600}
|
||||
.note{font:.86rem/1.55 'Avenir Next','Helvetica Neue',sans-serif;color:#5b5549;background:#f4eee2;border-left:3px solid var(--sand);
|
||||
padding:.9rem 1.1rem;margin:1.8rem 0;border-radius:0 4px 4px 0}
|
||||
.note.review{border-left-color:var(--accent)}
|
||||
.note b.tag{font-weight:600;letter-spacing:.12em;text-transform:uppercase;font-size:.72rem;color:var(--accent);display:block;margin-bottom:.3rem}
|
||||
.note code{font-size:.85em}
|
||||
table{width:100%;border-collapse:collapse;margin:1.4rem 0 1.8rem;font:.8rem/1.4 'Avenir Next','Helvetica Neue',sans-serif}
|
||||
th{text-align:left;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-size:.68rem;border-bottom:1.5px solid var(--ink);padding:.45rem .5rem}
|
||||
td{padding:.42rem .5rem;border-bottom:1px solid var(--rule);vertical-align:top}
|
||||
td code{font-size:.92em}
|
||||
table.num td:not(:first-child),table.num th:not(:first-child){text-align:right}
|
||||
.epilogue p{font-size:1.04em}
|
||||
.appendix{font-size:.92em}
|
||||
.appendix ul,.chapter ul{padding-left:1.2em}
|
||||
.appendix li,.chapter li{margin:.4em 0}
|
||||
.colophon{text-align:center;font:.78rem 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);margin-top:4rem;letter-spacing:.05em}
|
||||
@media (max-width:760px){.inner{padding:0 22px}.cover{padding:40px 22px}.cover h1{font-size:3.2rem}.fig.wide{margin-left:0;margin-right:0}
|
||||
.toc ol{columns:1}p{text-align:left}body{font-size:17px}}
|
||||
@media print{html{background:#fff}.book{box-shadow:none}.chapter{break-before:page}.fig{break-inside:avoid}}
|
||||
.transcript p b{font:600 .78rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-right:.3em}
|
||||
pre .tk-c{color:#8d8576;font-style:italic}pre .tk-s{color:#5f7a3d}pre .tk-n{color:#a0662a}pre .tk-k{color:#b4472f}pre .tk-t{color:#2e6a8a}pre .tk-f{color:#7a3f6a}
|
||||
h4{font:600 .82rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.06em;margin:1.8em 0 .5em;color:var(--muted)}
|
||||
.inner ul,.inner ol:not(.findings){padding-left:1.2em}
|
||||
.inner li{margin:.35em 0}
|
||||
blockquote{margin:1.4rem 0;padding:.2rem 0 .2rem 1.2rem;border-left:3px solid var(--rule);color:#5a544a;font-style:italic}
|
||||
.fig svg text{dominant-baseline:auto}
|
||||
.fig .replay{font:600 .7rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);
|
||||
background:none;border:1px solid var(--rule);border-radius:3px;padding:.2rem .55rem;margin:0 0 0 1rem;cursor:pointer;float:right}
|
||||
.fig .replay:hover{border-color:var(--accent)}
|
||||
.anim.armed .step{opacity:0}
|
||||
.anim.armed.play .step{animation:stepIn .45s ease-out both;animation-delay:calc(var(--i) * var(--pace, .45s))}
|
||||
.anim.armed.play .step.edge{animation-name:edgeIn}
|
||||
@keyframes stepIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||
@keyframes edgeIn{from{opacity:0}to{opacity:1}}
|
||||
.steps{margin:2rem 0 2.2rem;border:1px solid var(--rule);border-radius:6px;background:#fffdf8}
|
||||
.steps-head{display:flex;align-items:center;gap:.8rem;padding:.6rem 1rem;border-bottom:1px solid var(--rule);
|
||||
font:600 .72rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
|
||||
.steps-head .title{flex:1}
|
||||
.steps-head .count{color:var(--muted);letter-spacing:.06em}
|
||||
.steps-head button{font:600 .72rem 'Avenir Next','Helvetica Neue',sans-serif;color:var(--ink);background:var(--soft);border:1px solid var(--rule);
|
||||
border-radius:3px;padding:.25rem .65rem;cursor:pointer}
|
||||
.steps-head button:disabled{opacity:.35;cursor:default}
|
||||
.step-panel{padding:1rem 1.2rem .4rem}
|
||||
.step-panel>h4:first-child{margin-top:.2rem;color:var(--ink)}
|
||||
.steps.js .step-panel{display:none}
|
||||
.steps.js .step-panel.on{display:block}
|
||||
.steps:not(.js) .steps-head button,.steps:not(.js) .steps-head .count{display:none}
|
||||
.steps:not(.js) .step-panel+.step-panel{border-top:1px dashed var(--rule)}
|
||||
.fig figcaption{display:flow-root}
|
||||
.fig svg{max-width:100%;height:auto}
|
||||
table{max-width:100%}
|
||||
@media (max-width:760px){.inner{padding:0 20px}.cover{padding:48px 22px}.cover h1{font-size:3.2rem}.fig.wide{margin-left:0;margin-right:0}table{display:block;overflow-x:auto}.toc ol{columns:1}}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body><div class="book">
|
||||
<section class="cover">
|
||||
<div class="kicker">A design brief · Chord v2 · slice 7b · 2026-10-29</div>
|
||||
<h1>Slice 7b<br>Reload</h1>
|
||||
<p class="subtitle">What a plugin is, how it is built and loaded, how a mini p2 is assembled from Chord, and what happens when a plugin's code changes on one host or across many</p>
|
||||
<div class="cover-stats"><div class="stat"><b>4</b><span>steps of a reload on one host</span></div><div class="stat"><b>3</b><span>fates of a live object</span></div><div class="stat"><b>26</b><span>carry-over items from slices 3a to 7a</span></div><div class="stat"><b>11 ms</b><span>to rebuild one facet after an edit</span></div><div class="stat"><b>6</b><span>decisions open</span></div></div>
|
||||
<div class="cover-note"><p>A brief before the slice 7b spec, written by the redesign session from <code>design.md</code> §21, the dependency model
|
||||
(<code>dependencies/model.md</code>, decided and built for 7a in pi <code>339847eb1</code>), the carry-over in <code>slices.md</code> and the code at
|
||||
<code>339847eb1</code>. Marks: <strong>Decided</strong> points cite the decision; <strong>Open</strong> points are the owner's, with a recommendation.</p>
|
||||
</div>
|
||||
</section><div class="inner">
|
||||
<nav class="toc"><h2>Contents</h2><ol>
|
||||
<li><a href="#before-we-begin">Before we begin</a></li>
|
||||
<li><a href="#plugin">I. A Plugin, File by File</a></li>
|
||||
<li><a href="#loading">II. Building and Loading</a></li>
|
||||
<li><a href="#assets">III. Assets and Files</a></li>
|
||||
<li><a href="#http">IV. HTTP</a></li>
|
||||
<li><a href="#perf">V. Performance</a></li>
|
||||
<li><a href="#kit">VI. A Mini p2 from Chord</a></li>
|
||||
<li><a href="#host">VII. One Host Reloads</a></li>
|
||||
<li><a href="#fates">VIII. Three Fates</a></li>
|
||||
<li><a href="#consumers">IX. Consumers Across a Reload</a></li>
|
||||
<li><a href="#jobs">X. Jobs Across a Reload</a></li>
|
||||
<li><a href="#registries">XI. Registries Across a Reload</a></li>
|
||||
<li><a href="#rollout">XII. Across Hosts</a></li>
|
||||
<li><a href="#decisions">Decisions</a></li>
|
||||
</ol></nav>
|
||||
|
||||
<section class="preface" id="before-we-begin">
|
||||
<h2>Before we begin</h2>
|
||||
<p class="dropcap">Mario edits the notes plugin while a session is open: a new method, a renamed field in a document, a second state
|
||||
member. He presses Reload. The server, the session's worker and two browser tabs run the old notes code; within a
|
||||
second they should all run the new one, with the notes, the open views and the running export job intact, and without
|
||||
restarting a single process.</p>
|
||||
<p>Chord v2 can do everything around that today: objects, references across hosts, the store, jobs, registries, and since
|
||||
7a a dependency model in which a missing provider is a wait, not an error. What it cannot do yet is change code. <code>load</code>
|
||||
runs once per host (<code>host.ts</code>: "load runs once per host"). Slice 7b adds the reload, on one host and across hosts.</p>
|
||||
<h3 id="eight-things-to-know">Eight things to know</h3>
|
||||
<ol class="findings">
|
||||
<li><strong>A reload is a transaction per host.</strong> Load the bundles, stage the new declarations, commit atomically, then let
|
||||
each object follow; any code error before the commit leaves the host entirely on its old code.</li>
|
||||
<li><strong>A live object has three fates.</strong> Pure objects swap in place; objects with natives drain and restart; objects of a
|
||||
removed type end, and their holders wait.</li>
|
||||
<li><strong>Old calls finish on old code.</strong> A pure swap keeps the running calls' old handles valid until they settle; only new
|
||||
calls see the new code.</li>
|
||||
<li><strong>Versions are the provider's business.</strong> A provider accepts older consumers through <code>accepts</code>; a consumer that does
|
||||
not fit waits (7a), it is not ended.</li>
|
||||
<li><strong>7a already decided most of the hard part.</strong> Removal, dropped versions and installs are waits woken by a
|
||||
publication; a reload's commit is such a publication.</li>
|
||||
<li><strong>A plugin is a directory with a <code>package.json</code> and one file per surface.</strong> Each facet is bundled alone; host
|
||||
packages are passed in, never copied; a whole plugin set builds in a few hundred milliseconds.</li>
|
||||
<li><strong>Chord should ship the app kit</strong> (proposed): loader, HTTP, workers, web, TUI and dev reload as optional
|
||||
components, so a mini p2 is a page of code.</li>
|
||||
<li><strong>Six points are open:</strong> the reload API, the rollout across hosts, a <code>writable</code> across a swap, the install
|
||||
watchdog, where the loader lives, and the app kit.</li>
|
||||
</ol>
|
||||
<h3 id="words-used-throughout">Words used throughout</h3>
|
||||
<p>A <strong>reload</strong> changes the code of one or more plugins on one host. A <strong>facet</strong> is a plugin's code for one host kind; a
|
||||
<strong>bundle</strong> is the built JavaScript of a facet. <strong>Staging</strong> runs the new facets and factories against a recording
|
||||
<code>self</code> without activating anything. The <strong>commit</strong> switches the host to the new code at once and is a <strong>publication</strong>
|
||||
in 7a's sense. A <strong>pure</strong> object has no natives (<code>self.open</code>), in its old or its new code. A <strong>rollout</strong> is a reload
|
||||
of the same code set on every host, server first.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="plugin"><header class="opener"><div class="numeral">I</div><h2>A Plugin, File by File</h2><p class="lede">What a plugin looks like on disk, and what each file is for.</p></header>
|
||||
<p class="dropcap">A plugin is a directory. Its name is its <code>package.json</code> name; its code is one file per host kind it runs on, a
|
||||
<strong>facet</strong>; everything else is ordinary TypeScript the facets import. This is p2's layout today (<code>apps/p2/plugins/*</code>),
|
||||
plus the <code>package.json</code> that loader D needs (p2's <code>plugin-loading.md</code> §8; p2's built-in plugins have none yet and are
|
||||
named by their directory).</p>
|
||||
<pre class="plain"><code>plugins/notes/
|
||||
package.json name = the plugin's name; peerDependencies = host packages; dependencies = bundled npm deps
|
||||
contract.ts the plugin's object types and registries (shared by its facets and by other plugins)
|
||||
server.ts facet for the server (default export: defineFacet(...))
|
||||
worker.ts facet for session workers
|
||||
web.tsx facet for browser tabs
|
||||
tui.ts facet for the TUI
|
||||
store.ts plain modules the facets import
|
||||
NoteList.tsx
|
||||
notes.css
|
||||
assets/
|
||||
template.md files the plugin ships (read at runtime, or offered for download)
|
||||
icon.svg
|
||||
skills/notes/SKILL.md optional resources (p2 §4.4)</code></pre>
|
||||
<pre><code>{
|
||||
<span class="tk-s">"name"</span>: <span class="tk-s">"@pi/notes"</span>,
|
||||
<span class="tk-s">"version"</span>: <span class="tk-s">"1.4.0"</span>,
|
||||
<span class="tk-s">"type"</span>: <span class="tk-s">"module"</span>,
|
||||
<span class="tk-s">"peerDependencies"</span>: {
|
||||
<span class="tk-s">"@earendil-works/chord"</span>: <span class="tk-s">"^2"</span>,
|
||||
<span class="tk-s">"@earendil-works/pi-durable"</span>: <span class="tk-s">"^1"</span>,
|
||||
<span class="tk-s">"react"</span>: <span class="tk-s">"^19"</span>
|
||||
},
|
||||
<span class="tk-s">"dependencies"</span>: {
|
||||
<span class="tk-s">"marked"</span>: <span class="tk-s">"16.4.1"</span>
|
||||
}
|
||||
}</code></pre>
|
||||
<ul>
|
||||
<li><strong><code>name</code></strong> identifies the plugin everywhere: its facets, its registry contributions, diagnostics, reloads.</li>
|
||||
<li><strong><code>peerDependencies</code></strong> are <strong>host packages</strong>: the running app supplies them, one instance per process (one Chord,
|
||||
one React; <code>instanceof ChordError</code> and React hooks only work that way). The bundler never copies them.</li>
|
||||
<li><strong><code>dependencies</code></strong> are the plugin's own npm packages (<code>marked</code>): installed into the plugin's <code>node_modules</code> with
|
||||
<code>--ignore-scripts</code> and bundled into every facet that imports them. <strong>Decided</strong> (owner, 2026-10-29): bundled by
|
||||
default; on Node a plugin may keep named packages external by an explicit list, <code>"chord": { "external": ["better-sqlite3"] }</code>. Server and worker facets then load them from the plugin's <code>node_modules</code> with <code>createRequire</code>,
|
||||
as pi 1.0 loads extension dependencies, native addons included; they stay in Node's module cache until the process
|
||||
ends, so a new version takes effect after a restart. A browser or TUI facet that imports one fails to build.</li>
|
||||
<li><strong>No <code>tsconfig.json</code> is needed.</strong> The bundler strips types and does not type-check; JSX is compiled with the
|
||||
automatic React runtime, a fixed setting of the loader. A plugin may keep a <code>tsconfig.json</code> for its editor and for
|
||||
<code>tsc --noEmit</code> (the SDK ships one to extend); the loader ignores it.</li>
|
||||
</ul>
|
||||
<p>A facet only declares (chapter III of the model): it serves object types, serves or contributes to registries, and
|
||||
returns nothing.</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/contract.ts</span>
|
||||
<span class="tk-k">import</span> { defineRegistry, local, remote, str, obj, arr, method } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||||
<span class="tk-k">import</span> { <span class="tk-t">Session</span> } <span class="tk-k">from</span> <span class="tk-s">"@app/core"</span>; <span class="tk-c">// the app's host kinds, a host package</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NoteStore</span> = <span class="tk-f">local</span>(<span class="tk-s">"notes.store"</span>, { version: <span class="tk-n">1</span>, params: {}, members: { <span class="tk-c">/* … */</span> } });
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Notes</span> = <span class="tk-f">remote</span>(<span class="tk-s">"notes"</span>, {
|
||||
version: <span class="tk-n">2</span>,
|
||||
params: { session: <span class="tk-f">str</span>() },
|
||||
hostAddress: ({ session }) => <span class="tk-t">Session</span>({ id: session }),
|
||||
variants: { editor: { state: { list: <span class="tk-f">arr</span>(<span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>(), text: <span class="tk-f">str</span>() })) }, methods: { add: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ text: <span class="tk-f">str</span>() })) } } },
|
||||
});
|
||||
|
||||
<span class="tk-c">// plugins/notes/worker.ts</span>
|
||||
<span class="tk-k">import</span> { defineFacet } <span class="tk-k">from</span> <span class="tk-s">"@app/core"</span>; <span class="tk-c">// bound to the app's principal type</span>
|
||||
<span class="tk-k">import</span> { <span class="tk-t">NoteStore</span>, <span class="tk-t">Notes</span> } <span class="tk-k">from</span> <span class="tk-s">"./contract.ts"</span>;
|
||||
<span class="tk-k">import</span> { serveStore } <span class="tk-k">from</span> <span class="tk-s">"./store.ts"</span>;
|
||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host) => {
|
||||
host.<span class="tk-f">serve</span>(<span class="tk-t">NoteStore</span>, serveStore);
|
||||
host.<span class="tk-f">serve</span>(<span class="tk-t">Notes</span>, ({ session }, principal, self) => { <span class="tk-c">/* … */</span> });
|
||||
});
|
||||
|
||||
<span class="tk-c">// plugins/notes/web.tsx</span>
|
||||
<span class="tk-k">import</span> { defineFacet } <span class="tk-k">from</span> <span class="tk-s">"@app/core"</span>;
|
||||
<span class="tk-k">import</span> { <span class="tk-t">NoteList</span> } <span class="tk-k">from</span> <span class="tk-s">"./NoteList.tsx"</span>; <span class="tk-c">// a React component, bundled</span>
|
||||
<span class="tk-k">import</span> <span class="tk-s">"./notes.css"</span>; <span class="tk-c">// bundled into the facet's stylesheet</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host, self) => {
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Panels</span>, { notes: <span class="tk-t">NoteList</span> }); <span class="tk-c">// the app's UI registry</span>
|
||||
});</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="loading"><header class="opener"><div class="numeral">II</div><h2>Building and Loading</h2><p class="lede">How the bundler knows what to pull in, and how the code gets into a running process.</p></header>
|
||||
<p class="dropcap">Each facet is built on its own, as one script. The bundler starts at the facet file and follows its imports:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>An import of</th>
|
||||
<th>Becomes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td>the plugin's own files (<code>./store.ts</code>, <code>./NoteList.tsx</code>)</td>
|
||||
<td>inlined into the facet's script</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>another plugin's contract (<code>../sessions/contract.ts</code>, or <code>@pi/sessions/contract</code> for an installed package)</td>
|
||||
<td>inlined too: a copy, so a plugin carries the contract version it was built against (<code>accepts</code> needs that); only contracts and side-effect-free modules may be imported across plugins</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>an npm package in <code>dependencies</code> (<code>marked</code>)</td>
|
||||
<td>inlined, resolved from the plugin's own <code>node_modules</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>an npm package listed in <code>chord.external</code> (Node facets only)</td>
|
||||
<td>external: <code>require</code>d from the plugin's own <code>node_modules</code> at evaluation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>a host package (<code>@earendil-works/chord/next</code>, <code>react</code>, <code>@app/core</code>, …)</td>
|
||||
<td><strong>external</strong>: a parameter of the script, the host's instance at load time</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>a Node builtin (<code>node:fs</code>)</td>
|
||||
<td>external, through the <code>require</code> the loader passes (server and worker facets only; a web facet that imports one fails to build)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CSS (<code>import "./notes.css"</code>)</td>
|
||||
<td>collected into one stylesheet per web facet</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>an asset by URL (<code>import iconUrl from "./assets/icon.svg?url"</code>)</td>
|
||||
<td>a content-hashed file served by the app; the import is its URL</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<p>The built script is a function of its host packages. That is the whole trick of loader D: there is no module map, no
|
||||
second copy of Chord, and an old version is garbage-collected once nothing holds its closures.</p>
|
||||
<pre><code><span class="tk-c">// what the bundler emits for plugins/notes/worker.ts (simplified)</span>
|
||||
(<span class="tk-k">function</span> (require, __files) {
|
||||
<span class="tk-k">const</span> { defineFacet } = <span class="tk-f">require</span>(<span class="tk-s">"@app/core"</span>); <span class="tk-c">// the host's instance</span>
|
||||
<span class="tk-k">const</span> { local, remote, str } = <span class="tk-f">require</span>(<span class="tk-s">"@earendil-works/chord/next"</span>);
|
||||
<span class="tk-k">const</span> { marked } = <span class="tk-c">/* marked's code, inlined */</span>;
|
||||
<span class="tk-c">/* contract.ts, store.ts, inlined */</span>
|
||||
<span class="tk-k">return</span> <span class="tk-f">defineFacet</span>((host) => { <span class="tk-c">/* … */</span> });
|
||||
})</code></pre>
|
||||
<pre><code><span class="tk-c">// the loader (proposed @earendil-works/chord/next/loader), Node side</span>
|
||||
<span class="tk-k">import</span> { buildFacet, evaluateFacet } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/loader"</span>;
|
||||
|
||||
<span class="tk-k">const</span> HOST_PACKAGES = { <span class="tk-c">// the app's base bundle exports these</span>
|
||||
<span class="tk-s">"@earendil-works/chord/next"</span>: chordNext, <span class="tk-s">"@earendil-works/pi-durable"</span>: durable,
|
||||
<span class="tk-s">"@app/core"</span>: core, react, <span class="tk-s">"react/jsx-runtime"</span>: jsxRuntime,
|
||||
};
|
||||
<span class="tk-k">const</span> script = <span class="tk-k">await</span> <span class="tk-f">buildFacet</span>({ plugin: <span class="tk-s">"/data/plugins/notes"</span>, facet: <span class="tk-s">"worker.ts"</span>, hostPackages: <span class="tk-t">Object</span>.<span class="tk-f">keys</span>(HOST_PACKAGES), platform: <span class="tk-s">"node"</span> });
|
||||
<span class="tk-k">const</span> facet = <span class="tk-f">evaluateFacet</span>(script, { hostPackages: HOST_PACKAGES, require: <span class="tk-f">createRequire</span>(<span class="tk-k">import</span>.meta.url), dir: <span class="tk-s">"/data/plugins/notes"</span> });
|
||||
<span class="tk-k">await</span> host.<span class="tk-f">reload</span>([{ name: <span class="tk-s">"@pi/notes"</span>, facet }], ctx); <span class="tk-c">// or host.load the first time</span></code></pre>
|
||||
<ul>
|
||||
<li><code>buildFacet</code> is esbuild with: the facet as entry, the host packages and <code>node:*</code> external, <code>format: "cjs"</code> wrapped
|
||||
as the function above, minified, an <code>onResolve</code> that refuses anything outside the plugin, its <code>node_modules</code>, a
|
||||
contract of another plugin or a host package. It returns the script, its content hash and esbuild's metafile (the
|
||||
dev loop knows which files a facet used).</li>
|
||||
<li><code>evaluateFacet</code> runs the script with <code>vm.Script</code> and a stable filename per (facet, hash) on Node, or as a classic
|
||||
<code><script></code> in the browser (the page fetches the script and its stylesheet by hash, which caches forever).</li>
|
||||
<li><strong>Imports a plugin cannot use:</strong> dynamic <code>import()</code> (resolved at build, or not at all), top-level <code>await</code> (a facet is a
|
||||
synchronous declaration), <code>require</code> of anything but builtins.</li>
|
||||
</ul>
|
||||
<div class="note review"><b class="tag">Open 5: where the loader lives</b><p>(a) Chord ships <code>chord/next/loader</code> (discovery, <code>buildFacet</code>, <code>evaluateFacet</code>, <code>pluginFile</code>), an app chooses its host
|
||||
packages; (b) Chord knows only <code>host.reload(facets)</code> and the app bundles and evaluates. Recommended: (a); the kernel
|
||||
stays free of it, and Durable Objects (no <code>eval</code>) compose statically as before.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="assets"><header class="opener"><div class="numeral">III</div><h2>Assets and Files</h2><p class="lede">Files a plugin ships: bundled into the browser, read at runtime, or offered for download.</p></header>
|
||||
<p class="dropcap">A plugin has files that are not code. They fall into three cases.</p>
|
||||
<p><strong>1. Imported by a web facet.</strong> CSS, images, wasm. The bundler emits them as content-hashed files next to the facet's
|
||||
script, and the import evaluates to their URL. The app serves the bundle's files under one path; p2 does exactly this
|
||||
today for the terminal's wasm (<code>plugins/terminals/Terminal.tsx</code>: <code>import ghosttyWasm from "../../vendor/ghostty/ghostty-vt.wasm?url"</code>, served under <code>/__p2_web/</code>).</p>
|
||||
<pre><code><span class="tk-k">import</span> iconUrl <span class="tk-k">from</span> <span class="tk-s">"./assets/icon.svg?url"</span>;
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NoteIcon</span> = () => <img src={iconUrl} alt=<span class="tk-s">""</span> />;</code></pre>
|
||||
<p><strong>2. Read at runtime by a server or worker facet.</strong> A template, a wasm module for a native, a script a native runs. A
|
||||
bundled script has no meaningful <code>import.meta.url</code> (today's <code>plugins/terminals/main.ts</code> reads its wasm with
|
||||
<code>new URL("../../vendor/…", import.meta.url)</code>). The loader passes the plugin's directory, and a helper resolves a path
|
||||
inside it, refusing <code>..</code> out of it:</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/worker.ts</span>
|
||||
<span class="tk-k">import</span> { pluginFile } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/loader"</span>; <span class="tk-c">// reads the directory the loader passed</span>
|
||||
<span class="tk-k">const</span> template = <span class="tk-k">await</span> <span class="tk-f">readFile</span>(<span class="tk-f">pluginFile</span>(<span class="tk-s">"assets/template.md"</span>), <span class="tk-s">"utf8"</span>);</code></pre>
|
||||
<p><strong>3. Offered for download over HTTP.</strong> A plugin contributes a route to the app's HTTP registry; the handler opens the
|
||||
file through the same helper and streams it, with the plugin's own authorization:</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/server.ts</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host, self) => {
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, {
|
||||
routes: {
|
||||
<span class="tk-s">"GET /notes/template.md"</span>: <span class="tk-k">async</span> (request, ctx) => {
|
||||
<span class="tk-k">if</span> (ctx.principal.kind !== <span class="tk-s">"owner"</span>) <span class="tk-k">return</span> { kind: <span class="tk-s">"error"</span>, status: <span class="tk-n">403</span> };
|
||||
<span class="tk-k">const</span> path = <span class="tk-f">pluginFile</span>(<span class="tk-s">"assets/template.md"</span>);
|
||||
<span class="tk-k">const</span> { size } = <span class="tk-k">await</span> <span class="tk-f">stat</span>(path);
|
||||
<span class="tk-k">return</span> { kind: <span class="tk-s">"bytes"</span>, name: <span class="tk-s">"template.md"</span>, mime: <span class="tk-s">"text/markdown"</span>, size, read: (offset, length) => <span class="tk-f">readRange</span>(path, offset, length) };
|
||||
},
|
||||
},
|
||||
});
|
||||
});</code></pre>
|
||||
<p>So yes: a plugin can offer its files for download, because the loader tells it where it lives. Each facet sees only its
|
||||
own plugin's directory; nothing gives it another plugin's files.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="http"><header class="opener"><div class="numeral">IV</div><h2>HTTP</h2><p class="lede">What Chord provides, what p2 has, and what p3 needs.</p></header>
|
||||
<p class="dropcap"><strong>Chord v2 has no HTTP server today.</strong> Design §23 designs one (<code>@earendil-works/chord/next/http</code>) and defers it until
|
||||
"the kernel fixes and a first real proof" are done. p2's HTTP is its own, about 30 lines of helpers plus the server host:</p>
|
||||
<pre><code><span class="tk-c">// apps/p2/src/runtime/server.ts (p2 today, Chord v1)</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Routes</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">RouteHandler</span>>(<span class="tk-s">"p2.routes"</span>, { conflict: <span class="tk-s">"error"</span> }); <span class="tk-c">// "METHOD /path" → handler</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Upgrades</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">UpgradeHandler</span>>(<span class="tk-s">"p2.upgrades"</span>, { conflict: <span class="tk-s">"error"</span> }); <span class="tk-c">// WebSocket upgrades</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">RequestResolvers</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">RequestResolver</span>>(<span class="tk-s">"p2.request-resolvers"</span>, { conflict: <span class="tk-s">"error"</span> });
|
||||
<span class="tk-k">type</span> <span class="tk-t">RouteHandler</span> = (request: <span class="tk-t">IncomingMessage</span>, response: <span class="tk-t">ServerResponse</span>, access: <span class="tk-t">RequestAccess</span>, ctx: <span class="tk-t">Ctx</span>) => <span class="tk-k">void</span> | <span class="tk-t">Promise</span><<span class="tk-k">void</span>>;</code></pre>
|
||||
<p>Plugins contribute handlers that write to Node's <code>ServerResponse</code> themselves; the files plugin implements ranges,
|
||||
download headers and previews by hand (<code>plugins/files/server.ts</code>).</p>
|
||||
<p>The designed component (§23) replaces raw responses with descriptions, so every plugin gets ranges (206/416), <code>HEAD</code>,
|
||||
safe download headers, streaming and abort on disconnect from one place:</p>
|
||||
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Http</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">HttpContribution</span>, <span class="tk-t">Router</span>>(<span class="tk-s">"chord.http"</span>, { version: <span class="tk-n">1</span> });
|
||||
<span class="tk-k">type</span> <span class="tk-t">HttpContribution</span> = { routes?: <span class="tk-t">Record</span><string, <span class="tk-t">Route</span>>; wraps?: <span class="tk-t">Wrap</span>[] };
|
||||
<span class="tk-k">type</span> <span class="tk-t">Route</span> = (req: <span class="tk-t">HttpRequest</span>, ctx: <span class="tk-t">Ctx</span>) => <span class="tk-t">Promise</span><<span class="tk-t">HttpResponse</span>>; <span class="tk-c">// ctx.principal: the request's principal</span>
|
||||
<span class="tk-k">type</span> <span class="tk-t">HttpResponse</span> =
|
||||
| { kind: <span class="tk-s">"json"</span>; value: <span class="tk-t">JsonValue</span> } | { kind: <span class="tk-s">"page"</span>; title: string; body: string }
|
||||
| { kind: <span class="tk-s">"bytes"</span>; name: string; mime: string; size: number; <span class="tk-f">read</span>(offset: number, length: number): <span class="tk-t">Promise</span><<span class="tk-t">Uint8Array</span>> }
|
||||
| { kind: <span class="tk-s">"redirect"</span>; to: string } | { kind: <span class="tk-s">"error"</span>; status: number };</code></pre>
|
||||
<ul>
|
||||
<li>Routes compose by <code>keyedEntries</code>: a duplicate pattern is a reported conflict, the first by plugin name wins.</li>
|
||||
<li>Wraps (middleware) run in phases the owner defines (<code>observe</code>, <code>headers</code>, <code>transform</code>), by default only around their
|
||||
own plugin's routes.</li>
|
||||
<li>Request resolvers (who sent a request: cookie, loopback, relay) are hooks; WebSocket byte channels for terminals are
|
||||
a separate component.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="perf"><header class="opener"><div class="numeral">V</div><h2>Performance</h2><p class="lede">Measured on slayer, Node 24, esbuild from the repo, p2's plugins as they are.</p></header>
|
||||
<p class="dropcap">Built with the loader D settings above (each facet alone, host packages and <code>apps/p2/src</code> external, minified), on
|
||||
p2's real plugins:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Measurement</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td>all 25 worker facets, one script each, cold</td>
|
||||
<td>237 ms, 294 KB in total, largest <code>models/worker.ts</code> 35 KB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>all 20 server facets, cold</td>
|
||||
<td>167 ms, 236 KB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>all 17 web facets, cold</td>
|
||||
<td>144 ms, 912 KB, of which 688 KB is the terminal's wasm</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>one facet rebuilt after an edit (esbuild context, warm)</td>
|
||||
<td>11 ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>compile and evaluate all 25 worker scripts (<code>vm.Script</code>)</td>
|
||||
<td>23 ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>worker spawn to ready, p2 today (TypeScript through the source resolver, <code>nodeLoader</code>)</td>
|
||||
<td>549 ms (p2's measurement, <code>plugin-loading.md</code> §14.1)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>the same with one bundle per worker plugin set</td>
|
||||
<td>218 ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>base bundle plus one script per plugin through ESM (model B)</td>
|
||||
<td>254 ms; D is expected near it</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<p>The plugins are small; the time goes into the host packages and the runtime (85% of a worker's load time, p2 §14.1),
|
||||
which the base bundle loads once per process. A reload of one plugin costs one rebuild (about 11 ms warm) and one
|
||||
evaluation (about 1 ms per facet), then Chord's reload. The memory caveat is V8's compilation cache: every distinct
|
||||
compiled script stays cached, so scripts get a stable name per (facet, hash) and hosts that reload in place run with
|
||||
the cache off (+0.1 MiB instead of +5.6 MiB per 100 reloads, p2's measurement).</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="kit"><header class="opener"><div class="numeral">VI</div><h2>A Mini p2 from Chord</h2><p class="lede">The owner: building a small p2 with a web and a TUI surface and plugins should be trivial from Chord; optional components should come out of the box.</p></header>
|
||||
<p class="dropcap">p2 has about 10,700 lines of host code in <code>apps/p2/src/hosts</code> and its runtime: the server's HTTP and WebSocket
|
||||
entry, the worker supervisor, plugin discovery, the web and TUI bundles, the web shell, the TUI sandbox and launcher,
|
||||
the reload control. Almost none of it is p2-specific. The proposal: Chord ships it as optional components, each a
|
||||
subpath an app imports only if it wants it, none of them in Chord's kernel.</p>
|
||||
<h3 id="what-it-would-look-like">What it would look like</h3>
|
||||
<pre><code><span class="tk-c">// server.ts: a whole app with plugins, a web and a TUI surface</span>
|
||||
<span class="tk-k">import</span> { createHost } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||||
<span class="tk-k">import</span> { discoverPlugins, pluginLoader } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/loader"</span>;
|
||||
<span class="tk-k">import</span> { httpServer } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/http"</span>;
|
||||
<span class="tk-k">import</span> { workerRoute } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/workers"</span>;
|
||||
<span class="tk-k">import</span> { webSurface } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/web"</span>;
|
||||
<span class="tk-k">import</span> { tuiSurface } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/tui"</span>;
|
||||
<span class="tk-k">import</span> { devReload } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/dev"</span>;
|
||||
<span class="tk-k">import</span> { <span class="tk-t">Server</span>, <span class="tk-t">Session</span>, core } <span class="tk-k">from</span> <span class="tk-s">"./core.ts"</span>; <span class="tk-c">// the app's base code: host kinds, principals, core facet</span>
|
||||
|
||||
<span class="tk-k">const</span> plugins = <span class="tk-k">await</span> <span class="tk-f">discoverPlugins</span>([<span class="tk-s">"./plugins"</span>, <span class="tk-f">join</span>(dataDir, <span class="tk-s">"plugins"</span>)]);
|
||||
<span class="tk-k">const</span> loader = <span class="tk-f">pluginLoader</span>({ hostPackages: core.hostPackages });
|
||||
|
||||
<span class="tk-k">const</span> server = <span class="tk-f">createHost</span><P>({ address: <span class="tk-t">Server</span>(), principal: { kind: <span class="tk-s">"server"</span> }, onError });
|
||||
server.<span class="tk-f">route</span>(<span class="tk-t">Session</span>, <span class="tk-f">workerRoute</span>({ entry: <span class="tk-s">"./worker.ts"</span>, idleMs: <span class="tk-n">300_000</span>, authorize: core.authorize }), ctx);
|
||||
<span class="tk-k">await</span> server.<span class="tk-f">load</span>([core.serverFacet, ...(<span class="tk-k">await</span> loader.<span class="tk-f">facets</span>(plugins, <span class="tk-s">"server"</span>))], ctx);
|
||||
|
||||
<span class="tk-k">const</span> http = <span class="tk-f">httpServer</span>(server, { port: <span class="tk-n">4321</span>, authenticate: core.authenticate }); <span class="tk-c">// routes from the Http registry</span>
|
||||
http.<span class="tk-f">surface</span>(<span class="tk-f">webSurface</span>({ loader, plugins, shell: core.webShell })); <span class="tk-c">// /: the page, its bundle, its socket</span>
|
||||
http.<span class="tk-f">surface</span>(<span class="tk-f">tuiSurface</span>({ loader, plugins })); <span class="tk-c">// the TUI launcher's bundle and socket</span>
|
||||
<span class="tk-f">devReload</span>({ server, loader, plugins, watch: <span class="tk-k">true</span> }); <span class="tk-c">// a changed plugin reloads everywhere</span></code></pre>
|
||||
<pre><code><span class="tk-c">// worker.ts</span>
|
||||
<span class="tk-k">import</span> { workerMain } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/workers"</span>;
|
||||
<span class="tk-k">await</span> <span class="tk-f">workerMain</span>(<span class="tk-k">async</span> ({ address, init, parent }) => {
|
||||
<span class="tk-k">const</span> worker = <span class="tk-f">createHost</span><P>({ address, principal: { kind: <span class="tk-s">"worker"</span>, session: init.id }, onError });
|
||||
worker.<span class="tk-f">connect</span>(parent, { principal: { kind: <span class="tk-s">"server"</span> }, trusted: <span class="tk-k">true</span> }, ctx);
|
||||
<span class="tk-k">await</span> worker.<span class="tk-f">load</span>([core.<span class="tk-f">workerFacet</span>(init), ...(<span class="tk-k">await</span> loader.<span class="tk-f">facets</span>(init.plugins, <span class="tk-s">"worker"</span>))], ctx);
|
||||
<span class="tk-k">return</span> worker;
|
||||
});</code></pre>
|
||||
<pre><code><span class="tk-c">// core.ts, the web shell: the app decides the layout; plugins fill registries</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Panels</span> = defineRegistry<<span class="tk-t">Record</span><string, <span class="tk-t">ComponentType</span>>, <span class="tk-t">Panel</span>[]>(<span class="tk-s">"app.panels"</span>, { version: <span class="tk-n">1</span> });
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> webShell = ({ host }: <span class="tk-t">ShellProps</span>) => {
|
||||
<span class="tk-k">const</span> panels = <span class="tk-f">useRegistry</span>(host, <span class="tk-t">Panels</span>); <span class="tk-c">// from chord/next/web: a hook over a registry value</span>
|
||||
<span class="tk-k">return</span> <<span class="tk-t">Tabs</span>>{panels.<span class="tk-f">map</span>((p) => <p.component key={p.name} />)}</<span class="tk-t">Tabs</span>>;
|
||||
};</code></pre>
|
||||
<p>And a plugin is what chapter I shows: a directory, a <code>package.json</code>, facets that serve types and contribute to the
|
||||
app's registries (<code>Http</code>, <code>Panels</code>, a TUI command list).</p>
|
||||
<h3 id="the-components">The components</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subpath</th>
|
||||
<th>What it does</th>
|
||||
<th>Replaces in p2</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td><code>chord/next/loader</code></td>
|
||||
<td><code>discoverPlugins</code> (directory listing, <code>package.json</code> names, no parsing), <code>buildFacet</code> (esbuild, host packages external), <code>evaluateFacet</code> (Node <code>vm</code>, browser <code><script></code>), <code>pluginFile</code>, npm/git install with <code>--ignore-scripts</code></td>
|
||||
<td><code>src/hosts/plugins.ts</code>, <code>bundles.ts</code>, the source resolver, <code>nodeLoader</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/http</code></td>
|
||||
<td>the Node HTTP server, the <code>Http</code> registry (routes, wraps, request resolvers, responses as descriptions: ranges, downloads, <code>HEAD</code>), WebSocket entry calling <code>host.accept</code></td>
|
||||
<td><code>src/hosts/server.ts</code> (HTTP part), <code>runtime/http.ts</code>, <code>Routes</code>, <code>Upgrades</code>, <code>RequestResolvers</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/workers</code></td>
|
||||
<td><code>workerRoute</code> (fork a worker per address, IPC link, start message, idle stop from <code>host.activity</code>, crash restart with backoff), <code>workerMain</code> (the child's side)</td>
|
||||
<td><code>SessionHosts</code> in <code>runtime/server.ts</code>, <code>hosts/worker.ts</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/web</code></td>
|
||||
<td>the page host (connect over a WebSocket with reconnect, load web facets from their scripts, React provider and hooks: <code>useObject</code> over <code>host.hold</code>, <code>useRegistry</code>, <code>useWatch</code>), serving the bundle by hash, reload on a new manifest</td>
|
||||
<td><code>hosts/web/*</code>, <code>runtime/web.tsx</code>, <code>src/web/reconnect.ts</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/tui</code></td>
|
||||
<td>the TUI launcher and sandbox (pi-tui, the bundle download, a link over the launcher's socket), slots and commands as registries</td>
|
||||
<td><code>hosts/tui-*.ts</code>, <code>runtime/tui.ts</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/dev</code></td>
|
||||
<td>the source watcher marking plugins dirty, the reload control (<code>reload(plugins)</code> on every host in order: server, workers, then a manifest bump for pages and the TUI)</td>
|
||||
<td><code>performReload</code> in <code>hosts/server.ts</code>, the dev protocol</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<p>Each is ordinary code on top of Chord's public API: nothing in the kernel depends on it, a Durable Object deployment
|
||||
uses none of it, and an app can replace any one (its own HTTP server, its own shell). React is a host package of the web
|
||||
component, pi-tui of the TUI component; an app without them never imports them.</p>
|
||||
<h3 id="what-stays-the-app-39-s">What stays the app's</h3>
|
||||
<p>The principal type and authentication; the host kinds (<code>Server</code>, <code>Session</code>) and what a worker's start message holds;
|
||||
the core facet and core objects (catalog, harness); the web shell's layout and the TUI's look; which registries plugins
|
||||
contribute to beyond <code>Http</code>. That is roughly what p3's base code should be: a few hundred lines, not ten thousand.</p>
|
||||
<div class="note review"><b class="tag">Open 6: the app kit</b><p>(a) Chord ships these six components as optional subpaths of <code>@earendil-works/chord</code>, built in this order: <code>loader</code>
|
||||
and <code>dev</code> with 7b (reload needs them), <code>http</code> and <code>workers</code> next (p3 needs them first), <code>web</code> and <code>tui</code> after (lifted
|
||||
from p2's code, which works today). (b) Chord ships only <code>loader</code>; the rest is p3's base code. Recommended: (a), as the
|
||||
owner asked; each component gets its own slice spec and review, after 7b and before slice 9 or alongside p3's base.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="host"><header class="opener"><div class="numeral">VII</div><h2>One Host Reloads</h2><p class="lede">Four steps, all or nothing up to the commit.</p></header>
|
||||
<p class="dropcap">Design §21 fixes the shape. <strong>Decided.</strong></p>
|
||||
<pre class="plain"><code>1. load the new bundles; an import or syntax error rejects
|
||||
2. staging facet setups and every live object's new factory run with a recording `self`
|
||||
(declarations only, nothing activated); any throw rejects
|
||||
3. commit the host switches to the new code atomically: a publication (7a)
|
||||
4. after each object follows its fate (chapter II)</code></pre>
|
||||
<p>Rollback is all or nothing per host, and only for code errors: an import error, a throwing setup or factory. A failed
|
||||
<code>open</code>, a registry conflict or a failing <code>migrate</code> after the commit are runtime states of single objects, never a
|
||||
reason to roll back. A host is at revision R or R−1, never in between.</p>
|
||||
<p>Staging runs factories against a recording <code>self</code> because a factory only declares: it returns members and calls
|
||||
<code>self.state</code>, <code>self.get</code>, <code>self.open</code> and so on, and the recording tells Chord what changed (a new dependency, a native
|
||||
added or removed, a member gone) before anything happens.</p>
|
||||
<figure class="fig anim"><svg viewBox="0 0 720 294" width="100%" role="img" aria-label="A reload of the notes plugin on the worker. Nothing is visible to callers until the commit." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f1m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f1a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f1b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><line x1="130.7" y1="52" x2="130.7" y2="286" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="45.7" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="130.7" y="27" font-size="11" fill="#2a2723" text-anchor="middle">base code</text><text x="130.7" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">reload control</text><line x1="360" y1="52" x2="360" y2="286" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="275" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="360" y="30" font-size="11" fill="#2a2723" text-anchor="middle">worker W</text><line x1="589.3" y1="52" x2="589.3" y2="286" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="504.3" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="589.3" y="27" font-size="11" fill="#2a2723" text-anchor="middle">notes objects</text><text x="589.3" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">live</text><g class="step edge" style="--i:1"><line x1="130.7" y1="78" x2="357" y2="78" stroke="#7b7468" marker-end="url(#f1m)"/><text x="245.3" y="72" font-size="10" fill="#2a2723" text-anchor="middle">reload([notes v2], ctx)</text></g><g class="step edge" style="--i:2"><path d="M360 104 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f1m)"/><text x="402" y="114" font-size="10" fill="#2a2723" text-anchor="start">load bundles</text></g><g class="step edge" style="--i:3"><path d="M360 144 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f1m)"/><text x="402" y="154" font-size="10" fill="#2a2723" text-anchor="start">staging: setups + new factories (recording self)</text></g><g class="step edge" style="--i:4"><path d="M360 184 h34 v14 h-32" fill="none" stroke="#b4472f" marker-end="url(#f1a)"/><text x="402" y="194" font-size="10" fill="#b4472f" text-anchor="start">commit: types, registries, contributions swapped; publication</text></g><g class="step edge" style="--i:5"><line x1="360" y1="230" x2="586.3" y2="230" stroke="#b4472f" marker-end="url(#f1a)"/><text x="474.7" y="224" font-size="10" fill="#b4472f" text-anchor="middle">each follows its fate</text></g><g class="step edge" style="--i:6"><line x1="589.3" y1="262" x2="133.7" y2="262" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f1m)"/><text x="360" y="256" font-size="10" fill="#2a2723" text-anchor="middle">resolved: { reloaded, restarted, ended, failed }</text></g></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 1.</span> A reload of the notes plugin on the worker. Nothing is visible to callers until the commit.</figcaption></figure>
|
||||
<div class="note review"><b class="tag">Open 1: the API</b><p>Today a host has <code>load(facets, ctx)</code>, once. Proposal: <code>host.reload(facets, ctx)</code> with the plugins' new facets (by
|
||||
plugin name: added, changed; a plugin missing from the list is removed), resolving with a report of what each object
|
||||
did, rejecting with the code error and nothing changed. <code>load</code> stays the first call. Alternative: one
|
||||
<code>host.load</code> that may be called again. Recommended: the separate <code>reload</code>, because its result differs (a report, not a
|
||||
barrier) and its rollback rule does not apply to the first load (a throwing first setup closes the host).</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="fates"><header class="opener"><div class="numeral">VIII</div><h2>Three Fates</h2><p class="lede">What happens to each live object at the commit.</p></header>
|
||||
<p class="dropcap">The commit compares each live object's old and new declarations. <strong>Decided</strong> (design §21, owner 2026-10-26, the
|
||||
dependency model).</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>The object</th>
|
||||
<th>Fate</th>
|
||||
<th>Calls</th>
|
||||
<th>Documents, jobs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td>pure, old and new (no <code>self.open</code>)</td>
|
||||
<td><strong>swapped in place</strong>: new methods, derives and routines; references kept, new ones made</td>
|
||||
<td>running calls finish on old handles; new calls see new code</td>
|
||||
<td>stay</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>has natives, old or new</td>
|
||||
<td><strong>restarted</strong>: new calls park, running calls drain (<code>closeTimeout</code>), natives close (<code>reload</code>), the new factory runs, natives reopen</td>
|
||||
<td>park during the restart</td>
|
||||
<td>stay; running job runs end <code>aborted { reload }</code>, <code>finish</code> runs on new code</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>its type is gone</td>
|
||||
<td><strong>ended</strong>: natives close (<code>end</code>), callers rejected, handlers drain</td>
|
||||
<td>rejected</td>
|
||||
<td>in-memory gone; persisted stay until retired</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>a new dependency is missing (any of the above)</td>
|
||||
<td><strong>parks</strong> (decision 4): gate closed, <code>failed { waiting }</code></td>
|
||||
<td>reject</td>
|
||||
<td>stay</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<pre><code><span class="tk-c">// notes v1 // notes v2: pure, swapped in place</span>
|
||||
host.<span class="tk-f">serve</span>(<span class="tk-t">NoteStore</span>, (_, self) => { host.<span class="tk-f">serve</span>(<span class="tk-t">NoteStore</span>, (_, self) => {
|
||||
<span class="tk-k">const</span> notes = self.<span class="tk-f">state</span>(<span class="tk-t">Notes</span>); <span class="tk-k">const</span> notes = self.<span class="tk-f">state</span>(<span class="tk-t">Notes</span>); <span class="tk-c">// same document, v2 migrates it</span>
|
||||
<span class="tk-k">return</span> { <span class="tk-k">const</span> count = self.<span class="tk-f">derive</span>([notes], ([n]) => n.list.length);
|
||||
notes, <span class="tk-k">return</span> {
|
||||
add: (a, ctx) => self.<span class="tk-f">tx</span>(…, ctx), notes, count, <span class="tk-c">// a new exposed member</span>
|
||||
}; add: (a, ctx) => self.<span class="tk-f">tx</span>(…, ctx),
|
||||
}); rename: (a, ctx) => self.<span class="tk-f">tx</span>(…, ctx), <span class="tk-c">// a new method</span>
|
||||
};
|
||||
});</code></pre>
|
||||
<p>Rules that come with the fates, all <strong>decided</strong>:</p>
|
||||
<ul>
|
||||
<li><strong>R15, old code and migrated documents:</strong> an old call that touches a document whose version rose throws once; the
|
||||
document stays intact; the call is not resent.</li>
|
||||
<li><strong>Derives</strong> have no names: exposed derives are matched by their member name, internal ones are rebuilt.</li>
|
||||
<li><strong>Routines</strong> hand over per object: the old ones are aborted, the new ones start once the old calls finished, bounded
|
||||
by <code>closeTimeout</code>; calls do not wait for it.</li>
|
||||
<li><strong>Instance ids:</strong> a restarted object gets a new instance id (an old activation chain must not match it); a swapped one
|
||||
keeps it.</li>
|
||||
<li><strong>Broken objects</strong> of a reloaded plugin are retried, as <code>ref.retry()</code> does.</li>
|
||||
<li><strong>A parked object</strong> of a reloaded plugin is replaced by the new factory's attempt in one handover: the new requests
|
||||
bind before the old ones are released (the mechanism 7a removed comes back here).</li>
|
||||
<li><strong>In-memory documents</strong> migrate on reload through their definition's <code>migrate</code> (6a built the version check).</li>
|
||||
</ul>
|
||||
<div class="note review"><b class="tag">Open 2: a <code>writable</code> across a pure swap</b><p>A pure swap runs the new factory, so <code>self.writable(initial)</code> makes a new writable at its initial value. An object that
|
||||
contributes MCP's tool list through a writable then contributes an empty list until its restarted routine sets it again:
|
||||
the tools vanish for a moment. Options: (a) accept it (the routine refills it within one call); (b) keep a writable's
|
||||
value across a swap when the new factory declares a writable at the same position (as members are matched by name).
|
||||
Recommended: (a); writables have no names, and position matching would be the first positional rule in Chord.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="consumers"><header class="opener"><div class="numeral">IX</div><h2>Consumers Across a Reload</h2><p class="lede">Holds, versions and old calls; 7a decided that nothing is ended for a code change.</p></header>
|
||||
<p class="dropcap">A reference on another host points at an object whose code may change under it. <strong>Decided</strong> (design §21, §22 and the
|
||||
dependency model):</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>The provider's reload</th>
|
||||
<th>The consumer's hold</th>
|
||||
<th>It sees</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td>still accepts the hold's version, object swapped</td>
|
||||
<td>kept; re-subscribed through the version's mappings</td>
|
||||
<td>one <code>[["r", value]]</code> per changed member</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>still accepts it, object restarted</td>
|
||||
<td>kept</td>
|
||||
<td><code>loading</code>, then a fresh <code>bound</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>stops accepting the version</td>
|
||||
<td>unbound <code>version</code></td>
|
||||
<td><code>failed { waiting }</code>; it binds again if a later reload accepts it</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>removes the type</td>
|
||||
<td>unbound <code>not_served</code></td>
|
||||
<td><code>failed { waiting }</code>; it binds again when the type comes back</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<p>A provider accepts older consumers through <code>accepts</code>, written once with the provider, never in the shared contract
|
||||
(Q10):</p>
|
||||
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NotesV2</span> = <span class="tk-f">evolve</span>(<span class="tk-t">NotesV1</span>, (e) => e.<span class="tk-f">state</span>(<span class="tk-s">"editor"</span>, <span class="tk-s">"notes"</span>, <span class="tk-f">arr</span>(<span class="tk-t">NoteV2</span>)).<span class="tk-f">args</span>(<span class="tk-s">"add"</span>, <span class="tk-t">NoteV2</span>));
|
||||
|
||||
host.<span class="tk-f">serve</span>(<span class="tk-t">NotesV2</span>, factory, {
|
||||
accepts: {
|
||||
<span class="tk-n">1</span>: {
|
||||
state: { editor: { notes: (current) => current.<span class="tk-f">map</span>(toV1) } }, <span class="tk-c">// what a v1 tab sees, as a diff per commit</span>
|
||||
methods: { add: { args: (old) => <span class="tk-f">toV2</span>(old) } }, <span class="tk-c">// a v1 tab's call, mapped forward</span>
|
||||
},
|
||||
},
|
||||
});</code></pre>
|
||||
<ul>
|
||||
<li><strong>Old-version calls:</strong> <code>checkArgs</code> returns <code>{ kind: "method", args, result? }</code> or <code>{ kind: "call", args, call }</code>; Chord
|
||||
runs the handler or the provider's replacement and maps the result back (L1 built the check; 7b invokes it).</li>
|
||||
<li><strong>Adapted bindings get diffs:</strong> Chord keeps the last adapted value per binding and sends <code>diffRevisions</code>, so an old
|
||||
tab never receives a whole state per commit.</li>
|
||||
<li><strong>On the wire</strong> (7b's half of 7a's model): a <code>failed</code> on a bound hold unbinds it; the provider drops its unsent
|
||||
pending work and keeps the ack slots of messages already sent; a consumer whose own code moves to a new version
|
||||
releases its old hold and makes a new one.</li>
|
||||
<li><strong>A consumer reloaded itself</strong> gets new requests from its new factory; the handover binds them before it releases the
|
||||
old.</li>
|
||||
</ul>
|
||||
<p>Today <code>host.serve</code> binds every provider with an empty accepts table (<code>host.ts</code>: <code>bindProvider(contract, {})</code>); 7b passes
|
||||
the provider's table. Small and <strong>decided</strong> in shape (Q10, <code>bindProvider</code>); the option's place is <code>host.serve(Type, factory, { accepts, linger })</code>.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="jobs"><header class="opener"><div class="numeral">X</div><h2>Jobs Across a Reload</h2><p class="lede">Runs belong to the object; their versions are mapped forward.</p></header>
|
||||
<p class="dropcap"><strong>Decided</strong> (design §15, owner 2026-10-24 and 2026-10-26):</p>
|
||||
<pre><code><span class="tk-k">const</span> <span class="tk-t">ExportJob</span> = <span class="tk-f">defineJob</span>(<span class="tk-s">"notes.export"</span>, {
|
||||
version: <span class="tk-n">2</span>,
|
||||
args: <span class="tk-f">obj</span>({ format: <span class="tk-f">oneOf</span>(<span class="tk-s">"md"</span>, <span class="tk-s">"html"</span>) }),
|
||||
result: <span class="tk-f">obj</span>({ path: <span class="tk-f">str</span>() }),
|
||||
accepts: { <span class="tk-n">1</span>: { args: (old) => ({ format: <span class="tk-s">"md"</span> }), result: (old) => ({ path: old.file }) } },
|
||||
});</code></pre>
|
||||
<ul>
|
||||
<li>A run started on an older version finishes on its old code across a pure swap; <code>finish</code> and <code>running</code> see its args
|
||||
and result mapped forwards, once per run.</li>
|
||||
<li>Without an adapter, or for a run of a newer version (a rollback), the run is <code>blocked { version }</code> until a reload
|
||||
brings matching code; blocked runs are retried on every reload. Nothing is aborted or discarded for a version change.</li>
|
||||
<li>A native restart aborts running runs (<code>aborted { reload }</code>) and <code>finish</code> runs on the new code.</li>
|
||||
<li>A job the new code no longer declares: its runs are <code>blocked { version }</code> (the same family), kept until a reload
|
||||
declares it again or they are aborted.</li>
|
||||
<li>Persisted runs of another version or fingerprint stay blocked with their record as stored, resolved by the reload
|
||||
that brings their version.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="registries"><header class="opener"><div class="numeral">XI</div><h2>Registries Across a Reload</h2><p class="lede">A provider and its contributions are swapped in one batch.</p></header>
|
||||
<p class="dropcap"><strong>Decided</strong> (slice 8 §6, design §17): the commit replaces a plugin's contributions and a registry's provider in one
|
||||
batch; staging composes nothing (D1). The record keeps the provider, facet contributions by plugin and object
|
||||
contributions by object apart, so a reload of one plugin replaces exactly its parts (today <code>RegistryRecord.serve</code>
|
||||
refuses a second serve). Older readers across a reload of the provider follow its new <code>accepts[old].value</code>. A reader
|
||||
waits (7a) when the new provider no longer serves its version. <code>retryOn</code> watches are re-declared by the new factory.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="rollout"><header class="opener"><div class="numeral">XII</div><h2>Across Hosts</h2><p class="lede">The same reload on every host, in an order that cannot deadlock.</p></header>
|
||||
<p class="dropcap"><strong>Decided</strong> (design §21 and the dependency model): a revision is the complete code set; the server installs first,
|
||||
then workers, then clients; installs are serialized per host; a host that starts or reconnects installs the current
|
||||
revision first; a revision carries a code-set digest so an unchanged server restart reinstalls nothing; an install
|
||||
never waits for activations (a reverse-order rollout would deadlock); each host knows only its own install: while it
|
||||
installs, holds that do not fit its running code wait <code>installing</code>, re-evaluated when the install ends; a host whose
|
||||
own install was rejected fails its own requests with the reason.</p>
|
||||
<div class="steps"><div class="steps-head"><span class="title">The notes contract goes from v1 to v2; the server installs first</span><span class="count"></span><button class="prev" type="button">Back</button><button class="next" type="button">Next</button></div><div class="step-panel"><h4>Before</h4><p>S, W and T run revision R. T's notes view holds W's <code>Notes</code> v1. All live.</p>
|
||||
</div><div class="step-panel"><h4>S installs R+1</h4><p>S has no notes objects of its own; its install commits. T, still on R, holds nothing on S that changed.</p>
|
||||
</div><div class="step-panel"><h4>W installs R+1 with <code>accepts[1]</code></h4><p>W's <code>Notes</code> v2 swaps in place and still accepts v1: T's hold is re-subscribed through the mapping and gets one diff.</p>
|
||||
</div><div class="step-panel"><h4>W installs R+1 without <code>accepts[1]</code></h4><p>T's hold becomes unbound <code>version</code>: <code>failed { waiting }</code>, last notes readable, calls reject with "update the page".</p>
|
||||
</div><div class="step-panel"><h4>T reloads its page</h4><p>T's new code holds v2 with a new hold id; it binds; the old hold is released.</p>
|
||||
</div></div>
|
||||
<div class="note review"><b class="tag">Open 3: who runs the rollout</b><p>Chord's part is per host: <code>host.reload</code>, the <code>installing</code> state, and what a hold sees. The rest is distribution:
|
||||
building bundles, sending them to workers, telling tabs to reload, ordering, the digest. Options: (a) Chord ships a
|
||||
rollout protocol over its links (an <code>install</code> message from the server down, <code>installed</code>/<code>rejected</code> up, the digest in
|
||||
<code>hello</code>); (b) distribution is the app's (p3's base code calls <code>host.reload</code> on each host and orders them), as p2 does
|
||||
today (<code>apps/p2/src/hosts/server.ts</code> <code>performReload</code>: prepare every host, commit, publish bundles). Recommended: (b) for
|
||||
7b, with Chord's per-host guarantees; a protocol only if a second app needs the same code.</p>
|
||||
</div>
|
||||
<div class="note review"><b class="tag">Open 4: the install watchdog (design §26 #24)</b><p>An install that hangs (a bundle that never loads, a staging factory that awaits forever, which it may not) leaves its
|
||||
holds <code>installing</code> forever. Options: (a) a watchdog: <code>host.reload</code> rejects after <code>installTimeout</code> (default 60 s) and the
|
||||
host stays on its old code; (b) no Chord timer: the app's context bounds the reload. Recommended: (b): <code>reload</code> takes a
|
||||
context like everything else, and the app passes its deadline; a host option would be the only timer left in reload.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chapter epilogue" id="decisions"><header class="opener"><div class="numeral">∴</div><h2>Decisions</h2><p class="lede">What is settled for the spec, and what the owner decides first.</p></header>
|
||||
<p class="dropcap"><strong>Decided</strong> (spec material): the four steps and rollback per host; the three fates and the parking of a swap with a
|
||||
missing dependency; old calls on old handles (R15); derives matched by member name; the routine handover; new instance
|
||||
ids on restart; broken objects retried on reload; the handover of a parked attempt; in-memory <code>migrate</code>; holds kept,
|
||||
re-subscribed or freshly bound; unbound <code>version</code> and <code>not_served</code> instead of <code>gone</code>; old-version calls and adapted
|
||||
bindings as diffs; <code>accepts</code> in <code>host.serve</code>; jobs' version mapping, <code>blocked { version }</code> and retries on reload, runs
|
||||
aborted on a native restart; registries swapped in one batch; rollout order, serialized installs, the digest,
|
||||
<code>installing</code>, installs never waiting for activations; loader D's design: one script per facet, host packages passed in,
|
||||
contracts bundled, no module map, stable script names, plugins' npm dependencies installed with <code>--ignore-scripts</code>.</p>
|
||||
<p><strong>Open</strong> for the owner:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Point</th>
|
||||
<th>Recommended</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td>1</td>
|
||||
<td>the API: <code>host.reload(facets, ctx)</code> returning a report, or <code>load</code> callable again</td>
|
||||
<td><code>host.reload</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>a <code>writable</code> across a pure swap: back to its initial value, or kept by position</td>
|
||||
<td>back to initial</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td>who runs the rollout: a Chord protocol, or the app calling <code>host.reload</code> per host</td>
|
||||
<td>the app</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>4</td>
|
||||
<td>the install watchdog: a host option <code>installTimeout</code>, or the app's context</td>
|
||||
<td>the app's context</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td>where loader D lives: Chord (<code>chord/next/loader</code>), or the app</td>
|
||||
<td>Chord</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td>the app kit: Chord ships loader, HTTP, workers, web, TUI and dev reload as optional subpaths, or only the loader</td>
|
||||
<td>Chord ships them, in that order</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<p class="colophon">Slice 7b brief · 2026-10-29 · Chord v2</p>
|
||||
</div></div>
|
||||
<script>(function activate(root) {
|
||||
const replay = (f) => {
|
||||
f.classList.remove("play");
|
||||
void f.getBoundingClientRect();
|
||||
f.classList.add("play");
|
||||
};
|
||||
const io = new IntersectionObserver(
|
||||
(es) => {
|
||||
for (const e of es)
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add("play");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.3 },
|
||||
);
|
||||
root.querySelectorAll(".anim:not(.armed)").forEach((f) => {
|
||||
f.classList.add("armed");
|
||||
io.observe(f);
|
||||
const b = f.querySelector(".replay");
|
||||
if (b) b.onclick = () => replay(f);
|
||||
});
|
||||
root.querySelectorAll(".steps:not(.js)").forEach((s) => {
|
||||
s.classList.add("js");
|
||||
const ps = [...s.querySelectorAll(":scope>.step-panel")];
|
||||
let i = 0;
|
||||
const c = s.querySelector(".count"),
|
||||
p = s.querySelector(".prev"),
|
||||
n = s.querySelector(".next");
|
||||
const show = () => {
|
||||
ps.forEach((x, j) => x.classList.toggle("on", j === i));
|
||||
c.textContent = `${i + 1} / ${ps.length}`;
|
||||
p.disabled = i === 0;
|
||||
n.disabled = i === ps.length - 1;
|
||||
ps[i].querySelectorAll(".anim").forEach(replay);
|
||||
};
|
||||
p.onclick = () => {
|
||||
i--;
|
||||
show();
|
||||
};
|
||||
n.onclick = () => {
|
||||
i++;
|
||||
show();
|
||||
};
|
||||
show();
|
||||
});
|
||||
})(document)</script>
|
||||
</body></html>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue