diff --git a/.agents/skills/deploy-to-cloudflare/SKILL.md b/.agents/skills/deploy-to-cloudflare/SKILL.md index 14fa461dc1..745ecd27e2 100644 --- a/.agents/skills/deploy-to-cloudflare/SKILL.md +++ b/.agents/skills/deploy-to-cloudflare/SKILL.md @@ -27,7 +27,7 @@ Run Wrangler through `pnpm wrangler` and preserve its live terminal output. Read 4. Run `pnpm jiti scripts/require-wrangler-config.ts`. 5. Run `pnpm wrangler deployments status --json`. Continue only when Wrangler explicitly reports that the configured Worker does not exist. Stop when it reports an existing deployment or any authentication, network, account, or ambiguous failure. 6. Run `pnpm run build:web`, because the shared Wrangler configuration includes `apps/web/dist/client` as its Static Assets directory. -7. Publish the bootstrap probe through a reviewed command file, retaining the final Worker name, bindings, Durable Object migration, assets, cron, and routes. This first version installs the configured `new_sqlite_classes` migration: +7. Publish the bootstrap probe through a reviewed command file, retaining the final Worker name, bindings, Durable Object migration, assets, cron, and routes. This first version installs the configured Durable Object class migrations: ```bash pnpm wrangler deploy .agents/skills/deploy-to-cloudflare/assets/binding-probe.js \ @@ -52,7 +52,7 @@ Run Wrangler through `pnpm wrangler` and preserve its live terminal output. Read /api/deployment-probe ``` -10. Require HTTP 200, the exact body `Hello World`, and `x-floway-binding-probe: DB,FILES,IMAGES,KV,EXECUTION_DO`. The probe performs a D1 query, an R2 read, a KV read, an Images inspection, and a Durable Object request. Stop before migrations when any check fails. +10. Require HTTP 200, the exact body `Hello World`, and `x-floway-binding-probe: DB,FILES,IMAGES,KV,EXECUTION_DO,LOG_STREAM_DO`. The probe performs a D1 query, an R2 read, a KV read, an Images inspection, an execution-cell request, and a SQLite-backed log-stream request. Stop before migrations when any check fails. 11. Set `ADMIN_KEY` with `pnpm wrangler secret put ADMIN_KEY`; accept the value only through Wrangler's secret prompt or redirected secure input. Confirm its name in `pnpm wrangler secret list`. 12. Continue with the deployment-state collection below. Treat the probe deployment as the current code version and omit the historical Floway `CHANGELOG.md` diff because no previous Floway deployment exists at this Worker name. diff --git a/.agents/skills/deploy-to-cloudflare/assets/binding-probe.d.ts b/.agents/skills/deploy-to-cloudflare/assets/binding-probe.d.ts new file mode 100644 index 0000000000..ad33106f20 --- /dev/null +++ b/.agents/skills/deploy-to-cloudflare/assets/binding-probe.d.ts @@ -0,0 +1,10 @@ +export class ExecutionDO { + constructor(context: DurableObjectState, env: unknown); + fetch(): Response; +} +export class LogStreamDO { + constructor(context: DurableObjectState, env: unknown); + fetch(): Response; +} +declare const probe: { fetch(request: Request, env: Record): Promise }; +export default probe; diff --git a/.agents/skills/deploy-to-cloudflare/assets/binding-probe.js b/.agents/skills/deploy-to-cloudflare/assets/binding-probe.js index f10cbc18e4..8e19dbccb2 100644 --- a/.agents/skills/deploy-to-cloudflare/assets/binding-probe.js +++ b/.agents/skills/deploy-to-cloudflare/assets/binding-probe.js @@ -37,6 +37,13 @@ const checks = { throw new Error(`Durable Object probe returned HTTP ${response.status}`); } }, + LOG_STREAM_DO: async (env) => { + const id = env.LOG_STREAM_DO.idFromName(PROBE_KEY); + const response = await env.LOG_STREAM_DO.get(id).fetch(new Request('https://deployment-probe.internal/')); + if (!response.ok || await response.text() !== 'Hello World') { + throw new Error(`LogStream probe returned HTTP ${response.status}`); + } + }, }; const probe = async env => Promise.all(Object.values(checks).map(check => check(env))); @@ -47,6 +54,14 @@ export class ExecutionDO extends DurableObject { } } +export class LogStreamDO extends DurableObject { + fetch() { + const row = this.ctx.storage.sql.exec('SELECT 1 AS value').one(); + if (row.value !== 1) throw new Error('LogStream SQLite probe failed'); + return new Response('Hello World'); + } +} + export default { async fetch(request, env) { if (new URL(request.url).pathname !== '/api/deployment-probe') return new Response('Not Found', { status: 404 }); @@ -54,7 +69,7 @@ export default { try { await probe(env); return new Response('Hello World', { - headers: { 'x-floway-binding-probe': 'DB,FILES,IMAGES,KV,EXECUTION_DO' }, + headers: { 'x-floway-binding-probe': Object.keys(checks).join(',') }, }); } catch (error) { const detail = error instanceof Error diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index 8fe88fac46..1a6aa159d5 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -114,7 +114,7 @@ jobs: - name: lint (gateway) targets: packages/gateway - name: lint (other packages) - targets: eslint.config.ts vitest.config.ts packages/agent-setup packages/http packages/interceptor packages/platform packages/protocols packages/provider packages/provider-azure packages/provider-claude-code packages/provider-codex packages/provider-copilot packages/provider-custom packages/provider-ollama packages/proxy packages/test-utils packages/translate scripts tools + targets: eslint.config.ts vitest.config.ts packages/agent-setup packages/dump packages/http packages/interceptor packages/pipeline packages/platform packages/protocols packages/provider packages/provider-azure packages/provider-claude-code packages/provider-codex packages/provider-copilot packages/provider-custom packages/provider-ollama packages/proxy packages/test-utils packages/translate scripts tools name: ${{ matrix.name }} steps: - name: Checkout diff --git a/AGENTS.md b/AGENTS.md index bfc241dabb..1c6cde2fc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,9 +58,11 @@ | Package | `apps/platform-node` | Hosts Floway on Node. | | Package | `apps/web` | Provides the operator dashboard. | | Package | `packages/agent-setup` | Configures supported coding agents. | +| Package | `packages/dump` | Records pipeline runs and publishes diagnostics. | | Package | `packages/gateway` | Composes gateway services. | | Package | `packages/http` | Provides HTTP transport primitives. | | Package | `packages/interceptor` | Intercepts gateway traffic. | +| Package | `packages/pipeline` | Runs stages over a fact record. | | Package | `packages/platform` | Defines portable runtime contracts. | | Package | `packages/protocols` | Defines protocol contracts. | | Package | `packages/provider` | Defines provider contracts. | diff --git a/apps/platform-cloudflare/__tests__/binding-probe_test.ts b/apps/platform-cloudflare/__tests__/binding-probe_test.ts new file mode 100644 index 0000000000..04721eff65 --- /dev/null +++ b/apps/platform-cloudflare/__tests__/binding-probe_test.ts @@ -0,0 +1,32 @@ +import { expect, test } from 'vitest'; + +import { LogStreamState } from './test-utils/log-stream.ts'; +import probe, { ExecutionDO, LogStreamDO } from '../../../.agents/skills/deploy-to-cloudflare/assets/binding-probe.js'; + +test('the first-deployment probe exercises every current platform binding', async () => { + const observed: string[] = []; + const execution = new ExecutionDO(new LogStreamState(), {}); + const log = new LogStreamDO(new LogStreamState(), {}); + const namespace = (name: string, actor: { fetch(): Response }) => ({ + idFromName: (id: string) => id, + get: () => ({ fetch: async () => { observed.push(name); return actor.fetch(); } }), + }); + const env = { + DB: { prepare: () => ({ first: async () => { observed.push('DB'); return { value: 1 }; } }) }, + FILES: { head: async () => { observed.push('FILES'); return null; } }, + IMAGES: { + info: async (stream: ReadableStream) => { + expect((await new Response(stream).arrayBuffer()).byteLength).toBeGreaterThan(0); + observed.push('IMAGES'); return { width: 1, height: 1 }; + }, + }, + KV: { get: async () => { observed.push('KV'); return null; } }, + EXECUTION_DO: namespace('EXECUTION_DO', execution), + LOG_STREAM_DO: namespace('LOG_STREAM_DO', log), + }; + const response = await probe.fetch(new Request('https://probe.test/api/deployment-probe'), env); + expect(response.status).toBe(200); + expect(await response.text()).toBe('Hello World'); + expect(response.headers.get('x-floway-binding-probe')).toBe('DB,FILES,IMAGES,KV,EXECUTION_DO,LOG_STREAM_DO'); + expect(observed.sort()).toEqual(Object.keys(env).sort()); +}); diff --git a/apps/platform-cloudflare/__tests__/execution-do_test.ts b/apps/platform-cloudflare/__tests__/execution-do_test.ts index 4b4ce9c38f..3bb9b5cffb 100644 --- a/apps/platform-cloudflare/__tests__/execution-do_test.ts +++ b/apps/platform-cloudflare/__tests__/execution-do_test.ts @@ -23,6 +23,14 @@ class FakeWebSocket implements WebSocket { readonly sent: string[] = []; closed: { code: number; reason: string } | null = null; + private attachment: unknown = null; + + serializeAttachment(value: unknown): void { + this.attachment = value; + } + deserializeAttachment(): unknown { + return this.attachment; + } send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { // Broadcast protocol payloads are text; reject binary test input loudly. @@ -37,7 +45,9 @@ class FakeWebSocket implements WebSocket { } class FakeState { + async blockConcurrencyWhile(callback: () => Promise): Promise { return await callback(); } readonly sockets: FakeWebSocket[] = []; + readonly storage = null as unknown as DurableObjectStorage; readonly exports = { ExecutionOperationEntrypoint: { fetch: async (_request: Request) => new Response('executed'), diff --git a/apps/platform-cloudflare/__tests__/log-stream-do_test.ts b/apps/platform-cloudflare/__tests__/log-stream-do_test.ts new file mode 100644 index 0000000000..2f7732a6af --- /dev/null +++ b/apps/platform-cloudflare/__tests__/log-stream-do_test.ts @@ -0,0 +1,123 @@ +import { afterEach, expect, test, vi } from 'vitest'; + +import { DurableObjectLogStreamStore } from '../src/durable-object-log-stream.ts'; +import { LogStreamDO } from '../src/log-stream-do.ts'; +import { LogStreamResponse, LogStreamState, LogStreamWebSocketPair } from './test-utils/log-stream.ts'; +import { LOG_STREAM_IDLE_MS, LogStreamEndedError, LogStreamExpiredError, LogStreamHoleError } from '@floway-dev/platform'; + +const setup = () => { + const state = new LogStreamState(); + const actor = new LogStreamDO(state, {}); + vi.stubGlobal('Response', LogStreamResponse); + vi.stubGlobal('WebSocketPair', LogStreamWebSocketPair); + const store = new DurableObjectLogStreamStore({ idFromName: name => name, get: () => actor }); + return { state, actor, store }; +}; + +afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); + +test('read-only attachment does not create missing actor state', async () => { + const { actor, store, state } = setup(); + expect(await store.get('missing')).toBeNull(); + expect(await actor.fetch(new Request('https://log-stream/read?fromOffset=0'))).toHaveProperty('status', 404); + expect(state.storage.alarm).toBeNull(); + expect([...state.storage.sql.exec("SELECT name FROM sqlite_master WHERE type = 'table'")]).toEqual([]); +}); + +test('the RPC adapter reconstructs hole and ended errors and reads exact retry bytes', async () => { + const { store } = setup(); + const stream = await store.open('run'); + await stream.append(0, new Uint8Array([1, 2])); + await expect(stream.append(5, new Uint8Array([3]))).rejects.toBeInstanceOf(LogStreamHoleError); + await stream.append(0, new Uint8Array([1, 2, 3])); + await stream.end(); + await expect(stream.append(3, new Uint8Array([4]))).rejects.toBeInstanceOf(LogStreamEndedError); + const chunks: Uint8Array[] = []; + for await (const chunk of stream.read(1, new AbortController().signal)) chunks.push(chunk); + expect(chunks.map(chunk => [...chunk]).flat()).toEqual([2, 3]); +}); + +test('heartbeats keep an otherwise silent writer alive beyond two idle intervals', async () => { + vi.useFakeTimers(); + const { store, actor } = setup(); + const stream = await store.open('quiet'); + for (let elapsed = 0; elapsed < 120_000; elapsed += LOG_STREAM_IDLE_MS / 3) { + await vi.advanceTimersByTimeAsync(LOG_STREAM_IDLE_MS / 3); + await stream.append(0, new Uint8Array()); + await actor.alarm(); + expect(await store.get('quiet')).not.toBeNull(); + } + await stream.append(0, new Uint8Array([9])); + await stream.end(); +}); + +test('an abandoned writer is reclaimed and existing handles see expiration', async () => { + vi.useFakeTimers(); + const { store, actor, state } = setup(); + const stream = await store.open('abandoned'); + await stream.append(0, new Uint8Array([9])); + await vi.advanceTimersByTimeAsync(LOG_STREAM_IDLE_MS); + await actor.alarm(); + expect(await store.get('abandoned')).toBeNull(); + expect(state.storage.alarm).toBeNull(); + await expect(stream.append(1, new Uint8Array([10]))).rejects.toBeInstanceOf(LogStreamExpiredError); +}); + +test('expiration interrupts an attached reader instead of signaling clean completion', async () => { + vi.useFakeTimers(); + const { store, actor } = setup(); + const stream = await store.open('reading'); + const reader = stream.read(0, new AbortController().signal)[Symbol.asyncIterator](); + const pending = reader.next(); + const rejected = expect(pending).rejects.toBeInstanceOf(LogStreamExpiredError); + await vi.advanceTimersByTimeAsync(LOG_STREAM_IDLE_MS); + await actor.alarm(); + await rejected; +}); + +test('a paused reader fetches one segment and queues no backlog on the WebSocket hop', async () => { + const { store, actor, state } = setup(); + const stream = await store.open('backpressure'); + const reads = vi.spyOn(actor, 'readChunk'); + const reader = stream.read(0, new AbortController().signal)[Symbol.asyncIterator](); + const first = reader.next(); + await Promise.resolve(); + await stream.append(0, new Uint8Array(4 * 1024 * 1024)); + expect((await first).value?.byteLength).toBe(64 * 1024); + const fetched = reads.mock.calls.length; + await new Promise(resolve => { setTimeout(resolve, 0); }); + expect(reads).toHaveBeenCalledTimes(fetched); + expect(state.sockets.flatMap(socket => socket.sent)).toEqual(['changed']); + expect((await reader.next()).value?.byteLength).toBe(64 * 1024); + expect(reads).toHaveBeenCalledTimes(fetched + 1); + await reader.return?.(); +}); + +test('actual pull progress keeps a finished backlog alive across idle intervals', async () => { + vi.useFakeTimers(); + const { store, actor } = setup(); + const stream = await store.open('finished'); + await stream.append(0, new Uint8Array(3 * 64 * 1024)); + await stream.end(); + const reader = stream.read(0, new AbortController().signal)[Symbol.asyncIterator](); + for (let index = 0; index < 3; index++) { + await vi.advanceTimersByTimeAsync(LOG_STREAM_IDLE_MS / 2); + expect((await reader.next()).value?.byteLength).toBe(64 * 1024); + await actor.alarm(); + expect(await store.get('finished')).not.toBeNull(); + } + expect((await reader.next()).done).toBe(true); + await vi.advanceTimersByTimeAsync(LOG_STREAM_IDLE_MS); + await actor.alarm(); + expect(await store.get('finished')).toBeNull(); +}); + +test('a server WebSocket error interrupts the reader and retains its original exception', async () => { + const { store, actor, state } = setup(); + const stream = await store.open('failed-reader'); + const pending = stream.read(0, new AbortController().signal)[Symbol.asyncIterator]().next(); + await Promise.resolve(); + const error = new Error('socket failed'); + await expect(actor.webSocketError(state.getWebSockets()[0]!, error)).rejects.toBe(error); + await expect(pending).rejects.toThrow('interrupted'); +}); diff --git a/apps/platform-cloudflare/__tests__/test-utils/log-stream.ts b/apps/platform-cloudflare/__tests__/test-utils/log-stream.ts new file mode 100644 index 0000000000..9a220d60b5 --- /dev/null +++ b/apps/platform-cloudflare/__tests__/test-utils/log-stream.ts @@ -0,0 +1,84 @@ +import { DatabaseSync, type SQLInputValue } from 'node:sqlite'; + +export class LogStreamStorage { + readonly database = new DatabaseSync(':memory:'); + alarm: number | null = null; + readonly sql: SqlStorage = { + exec: (query: string, ...bindings: unknown[]) => { + const statements = query.split(';').map(statement => statement.trim()).filter(Boolean); + for (const statement of statements.slice(0, -1)) this.database.exec(statement); + const statement = this.database.prepare(statements.at(-1)!); + const values = bindings.map(value => value instanceof ArrayBuffer ? new Uint8Array(value) : value) as SQLInputValue[]; + const rows = statement.all(...values) as T[]; + return { + [Symbol.iterator]: () => rows[Symbol.iterator](), + one: () => { + if (rows.length !== 1) throw new Error(`Expected one SQL row, got ${rows.length}`); + return rows[0]; + }, + }; + }, + }; + async getAlarm(): Promise { return this.alarm; } + async setAlarm(at: number): Promise { this.alarm = at; } + async deleteAlarm(): Promise { this.alarm = null; } + async deleteAll(): Promise { this.database.exec('DROP TABLE IF EXISTS segments; DROP TABLE IF EXISTS stream_state;'); } +} + +export class LogStreamSocket extends EventTarget { + readonly sent: (string | Uint8Array)[] = []; + closed: { code: number; reason: string } | null = null; + private attachment: unknown; + private accepted = false; + private readonly queue: Event[] = []; + peer: LogStreamSocket | undefined; + + serializeAttachment(value: unknown): void { this.attachment = value; } + deserializeAttachment(): unknown { return this.attachment; } + accept(): void { this.accepted = true; this.flush(); } + send(value: string | ArrayBufferView): void { + const data = typeof value === 'string' ? value : new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice(); + this.sent.push(data); + this.peer?.receive(new MessageEvent('message', { data: typeof data === 'string' ? data : data.buffer })); + } + close(code = 1000, reason = ''): void { + if (this.closed !== null) return; + this.closed = { code, reason }; + const event = Object.assign(new Event('close'), { code, reason }); + this.peer?.receive(event); + } + private receive(event: Event): void { this.queue.push(event); this.flush(); } + private flush(): void { + if (!this.accepted) return; + queueMicrotask(() => { + while (this.queue.length > 0) this.dispatchEvent(this.queue.shift()!); + }); + } +} + +export class LogStreamState implements DurableObjectState { + readonly storage = new LogStreamStorage(); + readonly sockets: LogStreamSocket[] = []; + readonly exports = { ExecutionOperationEntrypoint: { fetch: async () => new Response() } }; + async blockConcurrencyWhile(callback: () => Promise): Promise { return await callback(); } + acceptWebSocket(socket: WebSocket): void { this.sockets.push(socket as unknown as LogStreamSocket); } + getWebSockets(): WebSocket[] { return this.sockets.filter(socket => socket.closed === null) as unknown as WebSocket[]; } +} + +export class LogStreamWebSocketPair { + readonly 0 = new LogStreamSocket(); + readonly 1 = new LogStreamSocket(); + constructor() { this[0].peer = this[1]; this[1].peer = this[0]; } +} + +const NativeResponse = Response; +export class LogStreamResponse extends NativeResponse { + readonly webSocket?: WebSocket; + private readonly responseStatus: number; + constructor(body?: BodyInit | null, init: ResponseInit = {}) { + super(body, init.status === 101 ? { ...init, status: 200 } : init); + this.responseStatus = init.status ?? 200; + this.webSocket = init.webSocket; + } + override get status(): number { return this.responseStatus; } +} diff --git a/apps/platform-cloudflare/entry.ts b/apps/platform-cloudflare/entry.ts index 084bd3a017..8437142f6b 100644 --- a/apps/platform-cloudflare/entry.ts +++ b/apps/platform-cloudflare/entry.ts @@ -14,6 +14,7 @@ import { // Re-exported here because the current binding and the rename migration's // target resolve the class by its Worker-module export name. export { ExecutionDO } from './src/execution-do.ts'; +export { LogStreamDO } from './src/log-stream-do.ts'; export class ExecutionOperationEntrypoint extends WorkerEntrypoint { async fetch(request: Request): Promise { diff --git a/apps/platform-cloudflare/package.json b/apps/platform-cloudflare/package.json index 57d354da79..12b8e471ee 100644 --- a/apps/platform-cloudflare/package.json +++ b/apps/platform-cloudflare/package.json @@ -7,6 +7,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@floway-dev/dump": "workspace:*", "@floway-dev/gateway": "workspace:*", "@floway-dev/http": "workspace:*", "@floway-dev/platform": "workspace:*", diff --git a/apps/platform-cloudflare/src/bootstrap.ts b/apps/platform-cloudflare/src/bootstrap.ts index 68ac1c339f..24d7804076 100644 --- a/apps/platform-cloudflare/src/bootstrap.ts +++ b/apps/platform-cloudflare/src/bootstrap.ts @@ -1,4 +1,5 @@ import { DurableObjectExecutionCellNamespace, type ExecutionDurableObjectNamespace } from './durable-object-execution-cell.ts'; +import { DurableObjectLogStreamStore, type LogStreamNamespace } from './durable-object-log-stream.ts'; import { ExecutionCellChannelBroker } from './execution-cell-channel-broker.ts'; import { createCloudflareExternalResourceFetcher } from './external-resource-fetcher.ts'; import { cloudflareFetch } from './fetch.ts'; @@ -8,9 +9,9 @@ import { KvImageCacheStore, type KvNamespace } from './kv-image-cache-store.ts'; import { R2FileStore, type R2BucketLike } from './r2-file-store.ts'; import { cloudflareSocketDial } from './socket-dial.ts'; import { timingSafeEqual } from './timing-safe-equal.ts'; +import { dumpCodec } from '@floway-dev/dump/codec'; +import type { DumpMetadata } from '@floway-dev/dump/types'; import { FileDumpStore, initDumpBroker, initDumpStore, initExecutionCellNamespace } from '@floway-dev/gateway'; -import { dumpCodec } from '@floway-dev/gateway/dump-codec'; -import type { DumpMetadata } from '@floway-dev/gateway/dump-types'; import { IMAGE_CACHE_POLICY, initEnv, @@ -19,6 +20,7 @@ import { initFileStore, initImageCacheStore, initImageProcessor, + initLogStreamStore, initRuntimeKind, initSocketDial, initTimingSafeEqual, @@ -31,16 +33,18 @@ export interface CloudflareEnv { IMAGES: ImagesBinding; KV: KvNamespace; EXECUTION_DO: ExecutionDurableObjectNamespace; + LOG_STREAM_DO: LogStreamNamespace; [key: string]: unknown; } // Every binding declared on `CloudflareEnv` is load-bearing — D1 holds all // config and telemetry, R2 stores file-backed response payloads and dump bodies, // Images re-encodes images, KV memoises the results, and EXECUTION_DO hosts -// WebSocket fan-out plus per-use execution cells. A missing binding means +// WebSocket fan-out plus per-use execution cells. LOG_STREAM_DO carries live +// run bytes. A missing binding means // wrangler.jsonc drifted from the code, so we refuse to initialise rather // than 503 on first use of the absent binding. -const REQUIRED_BINDINGS = ['DB', 'FILES', 'IMAGES', 'KV', 'EXECUTION_DO'] as const; +const REQUIRED_BINDINGS = ['DB', 'FILES', 'IMAGES', 'KV', 'EXECUTION_DO', 'LOG_STREAM_DO'] as const; export const bootstrapCloudflarePlatform = (env: CloudflareEnv): { db: SqlDatabase } => { const missing = REQUIRED_BINDINGS.filter(name => env[name] === undefined); @@ -69,5 +73,6 @@ export const bootstrapCloudflarePlatform = (env: CloudflareEnv): { db: SqlDataba const executionCells = new DurableObjectExecutionCellNamespace(env.EXECUTION_DO); initExecutionCellNamespace(executionCells); initDumpBroker(new ExecutionCellChannelBroker(executionCells, dumpCodec)); + initLogStreamStore(new DurableObjectLogStreamStore(env.LOG_STREAM_DO)); return { db: env.DB }; }; diff --git a/apps/platform-cloudflare/src/cf-websocket.d.ts b/apps/platform-cloudflare/src/cf-websocket.d.ts index 946a77ae0b..a8b0ec4947 100644 --- a/apps/platform-cloudflare/src/cf-websocket.d.ts +++ b/apps/platform-cloudflare/src/cf-websocket.d.ts @@ -17,6 +17,9 @@ declare global { interface WebSocket { // The server side of a WebSocketPair must opt into receiving frames. accept(): void; + // Per-reader state that survives hibernation; 16 KiB cap, and a byte offset is nothing. + serializeAttachment(value: unknown): void; + deserializeAttachment(): unknown; } } diff --git a/apps/platform-cloudflare/src/cloudflare-workers.d.ts b/apps/platform-cloudflare/src/cloudflare-workers.d.ts index 828dad405b..592edd8d31 100644 --- a/apps/platform-cloudflare/src/cloudflare-workers.d.ts +++ b/apps/platform-cloudflare/src/cloudflare-workers.d.ts @@ -23,9 +23,10 @@ declare module 'cloudflare:workers' { } } -// The runtime surface used for hibernatable WebSockets and the loopback -// WorkerEntrypoint that executes database-owning operations outside the DO. +// Hibernatable WebSockets and log segments live in the actors; database-owning +// operations execute through the loopback WorkerEntrypoint outside the DO. interface DurableObjectState { + blockConcurrencyWhile(callback: () => Promise): Promise; readonly exports: { readonly ExecutionOperationEntrypoint: { fetch(request: Request): Promise; @@ -33,6 +34,21 @@ interface DurableObjectState { }; acceptWebSocket(server: WebSocket): void; getWebSockets(): WebSocket[]; + readonly storage: DurableObjectStorage; +} + +interface DurableObjectStorage { + readonly sql: SqlStorage; + getAlarm(): Promise; + setAlarm(scheduledTime: number): Promise; + deleteAlarm(): Promise; + deleteAll(): Promise; +} + +// `exec` is synchronous, which is what lets an append read the current length, slice off what +// is already stored and write the remainder inside one JavaScript turn. +interface SqlStorage { + exec>(query: string, ...bindings: unknown[]): Iterable & { one(): T }; } // Cloudflare extends Web Crypto with a constant-time comparison primitive. diff --git a/apps/platform-cloudflare/src/durable-object-log-stream.ts b/apps/platform-cloudflare/src/durable-object-log-stream.ts new file mode 100644 index 0000000000..407dfc7e8e --- /dev/null +++ b/apps/platform-cloudflare/src/durable-object-log-stream.ts @@ -0,0 +1,106 @@ +import type { LogStreamStub } from './log-stream-contract.ts'; +import { LogStreamEndedError, LogStreamExpiredError, LogStreamHoleError, type LogStream, type LogStreamStore } from '@floway-dev/platform'; + +export interface LogStreamNamespace { + idFromName(name: string): unknown; + get(id: unknown): LogStreamStub; +} + +export class DurableObjectLogStreamStore implements LogStreamStore { + constructor(private readonly namespace: LogStreamNamespace) {} + + async open(streamId: string): Promise { + const stream = this.stream(streamId); + await stream.open(); + return stream; + } + + async get(streamId: string): Promise { + const stream = this.stream(streamId); + return await stream.exists() ? stream : null; + } + + private stream(streamId: string): DurableObjectLogStream { + return new DurableObjectLogStream(() => this.namespace.get(this.namespace.idFromName(streamId))); + } +} + +class DurableObjectLogStream implements LogStream { + // Each operation obtains a fresh stub because an RPC error breaks that stub. + constructor(private readonly stub: () => LogStreamStub) {} + + async open(): Promise { await this.stub().open(); } + async exists(): Promise { return await this.stub().exists(); } + + async append(atOffset: number, bytes: Uint8Array): Promise { + const result = await this.stub().append(atOffset, bytes.slice().buffer as ArrayBuffer); + if (result.kind === 'hole') throw new LogStreamHoleError(atOffset, result.length); + if (result.kind === 'ended') throw new LogStreamEndedError(); + if (result.kind === 'expired') throw new LogStreamExpiredError(); + } + + async end(): Promise { + if (await this.stub().end() === 'expired') throw new LogStreamExpiredError(); + } + + read(fromOffset: number, signal: AbortSignal): AsyncIterable { + const stream = this; + return { + async *[Symbol.asyncIterator]() { + signal.throwIfAborted(); + const response = await stream.stub().fetch(new Request(`https://log-stream/read?fromOffset=${fromOffset}`, { + headers: { upgrade: 'websocket' }, + })); + if (response.status === 404) throw new LogStreamExpiredError(); + const socket = response.webSocket; + if (!socket) throw new Error('LogStream read did not upgrade to a WebSocket'); + socket.accept(); + let revision = 0; + let wake: (() => void) | null = null; + let outcome: 'open' | 'ended' | 'interrupted' | 'expired' = 'open'; + const currentOutcome = (): typeof outcome => outcome; + const settle = (next: 'ended' | 'interrupted' | 'expired') => { + if (outcome === 'open') outcome = next; + wake?.(); + }; + const interrupt = () => { settle('interrupted'); }; + // Workerd delivers WebSocket messages while an async iterator is paused. + // Only tail notifications cross that hop; each pull fetches one SQL segment. + socket.addEventListener('message', () => { + revision++; + wake?.(); + }); + socket.addEventListener('close', event => { + settle(event.code === 1000 ? 'ended' : event.code === 1012 && event.reason === 'expired' ? 'expired' : 'interrupted'); + }); + socket.addEventListener('error', interrupt); + signal.addEventListener('abort', interrupt, { once: true }); + if (signal.aborted) interrupt(); + try { + for (;;) { + signal.throwIfAborted(); + if (currentOutcome() === 'expired') throw new LogStreamExpiredError(); + if (currentOutcome() === 'interrupted') throw new Error('LogStream read was interrupted'); + const observed = revision; + const result = await stream.stub().readChunk(fromOffset); + signal.throwIfAborted(); + if (result.kind === 'expired') throw new LogStreamExpiredError(); + if (result.kind === 'chunk') { + const bytes = new Uint8Array(result.bytes); + fromOffset += bytes.byteLength; + yield bytes; + continue; + } + if (result.ended) return; + if (revision !== observed || outcome !== 'open') continue; + await new Promise(resolve => { wake = resolve; }); + wake = null; + } + } finally { + signal.removeEventListener('abort', interrupt); + socket.close(); + } + }, + }; + } +} diff --git a/apps/platform-cloudflare/src/log-stream-contract.ts b/apps/platform-cloudflare/src/log-stream-contract.ts new file mode 100644 index 0000000000..e1231d8cc0 --- /dev/null +++ b/apps/platform-cloudflare/src/log-stream-contract.ts @@ -0,0 +1,21 @@ +// RPC does not preserve custom Error prototypes, so expected stream outcomes +// cross this boundary as data and are reconstructed by the client adapter. +// https://developers.cloudflare.com/workers/runtime-apis/rpc/error-handling/ +export type LogStreamAppendResult = + | { kind: 'appended' } + | { kind: 'hole'; length: number } + | { kind: 'ended' | 'expired' }; + +export type LogStreamReadResult = + | { kind: 'chunk'; bytes: ArrayBuffer } + | { kind: 'tail'; ended: boolean } + | { kind: 'expired' }; + +export interface LogStreamStub { + open(): Promise; + exists(): Promise; + append(atOffset: number, bytes: ArrayBuffer): Promise; + end(): Promise<'ended' | 'expired'>; + readChunk(fromOffset: number): Promise; + fetch(request: Request): Promise; +} diff --git a/apps/platform-cloudflare/src/log-stream-do.ts b/apps/platform-cloudflare/src/log-stream-do.ts new file mode 100644 index 0000000000..00e91c6d54 --- /dev/null +++ b/apps/platform-cloudflare/src/log-stream-do.ts @@ -0,0 +1,122 @@ +import { DurableObject } from 'cloudflare:workers'; + +import type { LogStreamAppendResult, LogStreamReadResult } from './log-stream-contract.ts'; +import { LOG_STREAM_IDLE_MS } from '@floway-dev/platform'; + +const SEGMENT_BYTES = 64 * 1024; +interface StreamState { length: number; ended: number; last_activity: number } + +export class LogStreamDO extends DurableObject { + private sql(): SqlStorage { return this.ctx.storage.sql; } + + private state(): StreamState | null { + const tables = [...this.sql().exec<{ name: string }>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'stream_state'")]; + if (tables.length === 0) return null; + return this.sql().exec('SELECT length, ended, last_activity FROM stream_state WHERE id = 0').one(); + } + + async open(): Promise { + const state = this.state(); + if (state !== null && Date.now() - state.last_activity < LOG_STREAM_IDLE_MS) return; + if (state !== null) await this.expire(); + this.sql().exec(` + CREATE TABLE segments (start_offset INTEGER PRIMARY KEY, bytes BLOB NOT NULL); + CREATE TABLE stream_state ( + id INTEGER PRIMARY KEY CHECK (id = 0), length INTEGER NOT NULL, + ended INTEGER NOT NULL, last_activity INTEGER NOT NULL + ); + INSERT INTO stream_state (id, length, ended, last_activity) VALUES (0, 0, 0, ?); + `, Date.now()); + await this.armAlarm(Date.now() + LOG_STREAM_IDLE_MS); + } + + async exists(): Promise { + const state = this.state(); + if (state === null) return false; + if (Date.now() - state.last_activity < LOG_STREAM_IDLE_MS) return true; + await this.expire(); + return false; + } + + async append(atOffset: number, bytes: ArrayBuffer): Promise { + const state = this.state(); + if (state === null) return { kind: 'expired' }; + if (atOffset > state.length) return { kind: 'hole', length: state.length }; + const incoming = new Uint8Array(bytes); + const fresh = incoming.subarray(state.length - atOffset); + if (state.ended === 1 && fresh.byteLength > 0) return { kind: 'ended' }; + let length = state.length; + for (let cursor = 0; cursor < fresh.byteLength; cursor += SEGMENT_BYTES) { + const segment = fresh.subarray(cursor, cursor + SEGMENT_BYTES); + this.sql().exec('INSERT INTO segments (start_offset, bytes) VALUES (?, ?)', length, segment); + length += segment.byteLength; + } + this.sql().exec('UPDATE stream_state SET length = ?, last_activity = ? WHERE id = 0', length, Date.now()); + for (const socket of this.ctx.getWebSockets()) socket.send('changed'); + return { kind: 'appended' }; + } + + async end(): Promise<'ended' | 'expired'> { + if (this.state() === null) return 'expired'; + this.sql().exec('UPDATE stream_state SET ended = 1, last_activity = ? WHERE id = 0', Date.now()); + for (const socket of this.ctx.getWebSockets()) { + socket.close(1000, 'ended'); + } + return 'ended'; + } + + async fetch(request: Request): Promise { + const fromOffset = Number(new URL(request.url).searchParams.get('fromOffset') ?? '0'); + if (!Number.isSafeInteger(fromOffset) || fromOffset < 0) return new Response('fromOffset must be a non-negative safe integer', { status: 400 }); + if (!await this.exists()) return new Response('LogStream not found', { status: 404 }); + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + this.ctx.acceptWebSocket(server); + if (this.state()!.ended === 1) server.close(1000, 'ended'); + return new Response(null, { status: 101, webSocket: client }); + } + + async readChunk(fromOffset: number): Promise { + const state = this.state(); + if (state === null) return { kind: 'expired' }; + this.sql().exec('UPDATE stream_state SET last_activity = ? WHERE id = 0', Date.now()); + if (fromOffset >= state.length) return { kind: 'tail', ended: state.ended === 1 }; + const segment = this.sql().exec<{ start_offset: number; bytes: ArrayBuffer }>( + 'SELECT start_offset, bytes FROM segments WHERE start_offset <= ? ORDER BY start_offset DESC LIMIT 1', fromOffset, + ).one(); + return { kind: 'chunk', bytes: new Uint8Array(segment.bytes).slice(fromOffset - segment.start_offset).buffer }; + } + + private async armAlarm(at: number): Promise { + if (await this.ctx.storage.getAlarm() === null) await this.ctx.storage.setAlarm(at); + } + + async alarm(): Promise { + const state = this.state(); + if (state === null) return; + const expiresAt = state.last_activity + LOG_STREAM_IDLE_MS; + if (Date.now() < expiresAt) { + await this.ctx.storage.setAlarm(expiresAt); + return; + } + await this.expire(); + } + + private async expire(): Promise { + // Reset is serialized against new opens, and both calls are necessary on + // compatibility dates where deleteAll does not also delete the alarm. + // https://developers.cloudflare.com/durable-objects/api/alarms/ + await this.ctx.blockConcurrencyWhile(async () => { + for (const socket of this.ctx.getWebSockets()) socket.close(1012, 'expired'); + await this.ctx.storage.deleteAll(); + await this.ctx.storage.deleteAlarm(); + }); + } + + async webSocketClose(socket: WebSocket, code: number, reason: string, _wasClean: boolean): Promise { socket.close(code, reason); } + async webSocketError(socket: WebSocket, error: unknown): Promise { + socket.close(1011, 'interrupted'); + throw error; + } +} diff --git a/apps/platform-node/__tests__/in-process-log-stream_test.ts b/apps/platform-node/__tests__/in-process-log-stream_test.ts new file mode 100644 index 0000000000..64cc4242d7 --- /dev/null +++ b/apps/platform-node/__tests__/in-process-log-stream_test.ts @@ -0,0 +1,169 @@ +import { expect, test, vi } from 'vitest'; + +import { InProcessLogStreamStore } from '../src/in-process-log-stream.ts'; +import { LOG_STREAM_IDLE_MS, LogStreamEndedError, LogStreamExpiredError, LogStreamHoleError } from '@floway-dev/platform'; +import { assertEquals } from '@floway-dev/test-utils'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const drain = async (stream: AsyncIterable): Promise => { + let out = ''; + for await (const chunk of stream) out += decoder.decode(chunk, { stream: true }); + return out + decoder.decode(); +}; + +test('a reader attaching after end still gets everything', async () => { + const store = new InProcessLogStreamStore(); + const stream = await store.open('run-1'); + await stream.append(0, encoder.encode('one\n')); + await stream.append(4, encoder.encode('two\n')); + await stream.end(); + + assertEquals(await drain(stream.read(0, new AbortController().signal)), 'one\ntwo\n'); +}); + +test('a reader follows the tail and completes when the stream ends', async () => { + const store = new InProcessLogStreamStore(); + const stream = await store.open('run-2'); + const reading = drain(stream.read(0, new AbortController().signal)); + + await stream.append(0, encoder.encode('a\n')); + await stream.append(2, encoder.encode('b\n')); + await stream.end(); + + assertEquals(await reading, 'a\nb\n'); +}); + +test('fromOffset resumes where an interrupted reader stopped', async () => { + const store = new InProcessLogStreamStore(); + const stream = await store.open('run-3'); + await stream.append(0, encoder.encode('first\n')); + await stream.append(6, encoder.encode('second\n')); + await stream.end(); + + // The offset lives in the caller, which is what keeps re-reading cheap without a + // server-side cursor and without a seam between backlog and live tail. + assertEquals(await drain(stream.read(6, new AbortController().signal)), 'second\n'); +}); + +test('a retry at an offset already written contributes only what is new', async () => { + const store = new InProcessLogStreamStore(); + const stream = await store.open('run-4'); + await stream.append(0, encoder.encode('hello')); + // The writer could not tell whether the first append landed, so it retries — re-chunked, + // subsuming the earlier attempt. Resolving by position is what makes that the same bytes in + // the same places rather than a duplicated splice. + await stream.append(0, encoder.encode('hello world')); + await stream.end(); + + assertEquals(await drain(stream.read(0, new AbortController().signal)), 'hello world'); +}); + +test('an append beyond the end is a hole and is refused', async () => { + const store = new InProcessLogStreamStore(); + const stream = await store.open('run-5'); + await stream.append(0, encoder.encode('abc')); + + let raised: unknown; + try { + await stream.append(10, encoder.encode('xyz')); + } catch (error) { + raised = error; + } + assertEquals(raised instanceof LogStreamHoleError, true); +}); + +test('an aborted read throws rather than completing', async () => { + const store = new InProcessLogStreamStore(); + const stream = await store.open('run-6'); + await stream.append(0, encoder.encode('partial\n')); + const controller = new AbortController(); + const reading = drain(stream.read(0, controller.signal)); + controller.abort(); + + // Completing normally would say "the stream ended", which is the one thing an interrupted + // read must not claim. + let raised: unknown; + try { + await reading; + } catch (error) { + raised = error; + } + assertEquals(raised instanceof Error, true); +}); + +test('concurrent writers open the same stream and absent readers create nothing', async () => { + const store = new InProcessLogStreamStore(); + expect(await store.get('missing')).toBeNull(); + const [first, second] = await Promise.all([store.open('same'), store.open('same')]); + expect(first).toBe(second); + await first.end(); +}); + +test('an empty heartbeat keeps a quiet live writer through long first-token and mid-stream waits', async () => { + vi.useFakeTimers(); + try { + const store = new InProcessLogStreamStore(); + const stream = await store.open('long-model'); + for (let elapsed = 0; elapsed < 120_000; elapsed += LOG_STREAM_IDLE_MS / 3) { + await vi.advanceTimersByTimeAsync(LOG_STREAM_IDLE_MS / 3); + await stream.append(0, new Uint8Array()); + expect(await store.get('long-model')).toBe(stream); + } + await stream.append(0, encoder.encode('first')); + for (let elapsed = 0; elapsed < 120_000; elapsed += LOG_STREAM_IDLE_MS / 3) { + await vi.advanceTimersByTimeAsync(LOG_STREAM_IDLE_MS / 3); + await stream.append(5, new Uint8Array()); + } + await stream.append(5, encoder.encode(' last')); + await stream.end(); + expect(await drain(stream.read(0, new AbortController().signal))).toBe('first last'); + await vi.advanceTimersByTimeAsync(LOG_STREAM_IDLE_MS); + expect(await store.get('long-model')).toBeNull(); + } finally { vi.useRealTimers(); } +}); + +test('abandoned writers expire and interrupt attached readers', async () => { + vi.useFakeTimers(); + try { + const store = new InProcessLogStreamStore(); + const stream = await store.open('abandoned'); + const reading = drain(stream.read(0, new AbortController().signal)); + const rejected = expect(reading).rejects.toBeInstanceOf(LogStreamExpiredError); + await vi.advanceTimersByTimeAsync(LOG_STREAM_IDLE_MS); + await rejected; + expect(await store.get('abandoned')).toBeNull(); + await expect(stream.append(0, encoder.encode('late'))).rejects.toBeInstanceOf(LogStreamExpiredError); + } finally { vi.useRealTimers(); } +}); + +test('read progress keeps a finished backlog available until reading completes', async () => { + vi.useFakeTimers(); + try { + const store = new InProcessLogStreamStore(); + const stream = await store.open('slow-reader'); + const bytes = new Uint8Array(3 * 64 * 1024); + await stream.append(0, bytes); + await stream.end(); + const reader = stream.read(0, new AbortController().signal)[Symbol.asyncIterator](); + for (let index = 0; index < 3; index++) { + await vi.advanceTimersByTimeAsync(LOG_STREAM_IDLE_MS / 2); + expect((await reader.next()).value?.byteLength).toBe(64 * 1024); + expect(await store.get('slow-reader')).toBe(stream); + } + expect((await reader.next()).done).toBe(true); + await vi.advanceTimersByTimeAsync(LOG_STREAM_IDLE_MS); + expect(await store.get('slow-reader')).toBeNull(); + } finally { vi.useRealTimers(); } +}); + +test('ending seals new bytes while accepting a duplicate append acknowledgement retry', async () => { + const store = new InProcessLogStreamStore(); + const stream = await store.open('sealed'); + await stream.append(0, encoder.encode('done')); + await stream.end(); + await stream.append(0, encoder.encode('done')); + await expect(stream.append(4, encoder.encode('new'))).rejects.toBeInstanceOf(LogStreamEndedError); + expect(await drain(stream.read(0, new AbortController().signal))).toBe('done'); +}); diff --git a/apps/platform-node/package.json b/apps/platform-node/package.json index 25ea2e8290..d055c9e3dd 100644 --- a/apps/platform-node/package.json +++ b/apps/platform-node/package.json @@ -12,6 +12,7 @@ "start:built": "tsx entry.ts" }, "dependencies": { + "@floway-dev/dump": "workspace:*", "@floway-dev/gateway": "workspace:*", "@floway-dev/http": "workspace:*", "@floway-dev/platform": "workspace:*", diff --git a/apps/platform-node/src/bootstrap.ts b/apps/platform-node/src/bootstrap.ts index bc7be7de08..515cffcd83 100644 --- a/apps/platform-node/src/bootstrap.ts +++ b/apps/platform-node/src/bootstrap.ts @@ -3,14 +3,15 @@ import { createNodeExternalResourceFetcher } from './external-resource-fetcher.t import { nodeFetch } from './fetch.ts'; import { FsFileStore } from './fs-file-store.ts'; import { nodeHttpContentDecoders } from './http-content-decoders.ts'; +import { InProcessLogStreamStore } from './in-process-log-stream.ts'; import { createNodeSqliteDatabase } from './node-sqlite-database.ts'; import { createSharpImageProcessor } from './sharp-image-processor.ts'; import { nodeSocketDial } from './socket-dial.ts'; import { SqliteImageCacheStore } from './sqlite-image-cache-store.ts'; import { timingSafeEqual } from './timing-safe-equal.ts'; +import { dumpCodec } from '@floway-dev/dump/codec'; +import type { DumpMetadata } from '@floway-dev/dump/types'; import { FileDumpStore, initDumpBroker, initDumpStore } from '@floway-dev/gateway'; -import { dumpCodec } from '@floway-dev/gateway/dump-codec'; -import type { DumpMetadata } from '@floway-dev/gateway/dump-types'; import { getEnvOptional, IMAGE_CACHE_POLICY, @@ -20,6 +21,7 @@ import { initFileStore, initImageCacheStore, initImageProcessor, + initLogStreamStore, initRuntimeKind, initSocketDial, initTimingSafeEqual, @@ -44,5 +46,6 @@ export const bootstrapNodePlatform = (): { db: SqlDatabase } => { initImageProcessor(createSharpImageProcessor()); initDumpStore(new FileDumpStore(db, files)); initDumpBroker(new EventTargetChannelBroker(dumpCodec)); + initLogStreamStore(new InProcessLogStreamStore()); return { db }; }; diff --git a/apps/platform-node/src/in-process-log-stream.ts b/apps/platform-node/src/in-process-log-stream.ts new file mode 100644 index 0000000000..a03c43c103 --- /dev/null +++ b/apps/platform-node/src/in-process-log-stream.ts @@ -0,0 +1,138 @@ +import { LOG_STREAM_IDLE_MS, LogStreamEndedError, LogStreamExpiredError, LogStreamHoleError, type LogStream, type LogStreamStore } from '@floway-dev/platform'; + +// Node's database, files and channel broker share one process boundary, so its +// transient run streams use that same boundary. +const SEGMENT_BYTES = 64 * 1024; + +class InProcessLogStream implements LogStream { + private readonly segments: { offset: number; bytes: Uint8Array }[] = []; + private length = 0; + private ended = false; + private expired = false; + private lastActivity = Date.now(); + private readonly waiters = new Set<() => void>(); + + get idleSince(): number { return this.lastActivity; } + + touch(): void { this.lastActivity = Date.now(); } + + expire(): void { + this.expired = true; + this.wake(); + } + + private requireLive(): void { + if (this.expired) throw new LogStreamExpiredError(); + } + + private wake(): void { + for (const resolve of this.waiters) resolve(); + this.waiters.clear(); + } + + async append(atOffset: number, bytes: Uint8Array): Promise { + this.requireLive(); + if (atOffset > this.length) throw new LogStreamHoleError(atOffset, this.length); + const fresh = bytes.subarray(this.length - atOffset); + if (this.ended && fresh.byteLength > 0) throw new LogStreamEndedError(); + this.touch(); + for (let cursor = 0; cursor < fresh.byteLength; cursor += SEGMENT_BYTES) { + const segment = fresh.slice(cursor, cursor + SEGMENT_BYTES); + this.segments.push({ offset: this.length, bytes: segment }); + this.length += segment.byteLength; + } + this.wake(); + } + + async end(): Promise { + this.requireLive(); + this.ended = true; + this.touch(); + this.wake(); + } + + private segmentAt(offset: number): { offset: number; bytes: Uint8Array } { + let low = 0; + let high = this.segments.length; + while (low + 1 < high) { + const middle = Math.floor((low + high) / 2); + if (this.segments[middle].offset <= offset) low = middle; + else high = middle; + } + return this.segments[low]; + } + + read(fromOffset: number, signal: AbortSignal): AsyncIterable { + const stream = this; + return { + async *[Symbol.asyncIterator]() { + let offset = fromOffset; + for (;;) { + stream.requireLive(); + signal.throwIfAborted(); + stream.touch(); + if (offset < stream.length) { + const segment = stream.segmentAt(offset); + const bytes = segment.bytes.slice(offset - segment.offset); + offset += bytes.byteLength; + yield bytes; + continue; + } + if (stream.ended) return; + await new Promise(resolve => { + const wake = () => { + stream.waiters.delete(wake); + signal.removeEventListener('abort', wake); + resolve(); + }; + stream.waiters.add(wake); + signal.addEventListener('abort', wake, { once: true }); + }); + } + }, + }; + } +} + +export class InProcessLogStreamStore implements LogStreamStore { + private readonly streams = new Map(); + private sweeper: ReturnType | null = null; + + async open(streamId: string): Promise { + const existing = this.lookup(streamId); + if (existing !== null) return existing; + const created = new InProcessLogStream(); + this.streams.set(streamId, created); + this.startSweeping(); + return created; + } + + async get(streamId: string): Promise { + return this.lookup(streamId); + } + + private lookup(streamId: string): InProcessLogStream | null { + const stream = this.streams.get(streamId); + if (stream === undefined) return null; + if (Date.now() - stream.idleSince < LOG_STREAM_IDLE_MS) return stream; + stream.expire(); + this.streams.delete(streamId); + return null; + } + + private startSweeping(): void { + if (this.sweeper !== null) return; + this.sweeper = setInterval(() => { + for (const [id, stream] of this.streams) { + if (Date.now() - stream.idleSince < LOG_STREAM_IDLE_MS) continue; + stream.expire(); + this.streams.delete(id); + } + if (this.streams.size === 0 && this.sweeper !== null) { + clearInterval(this.sweeper); + this.sweeper = null; + } + }, LOG_STREAM_IDLE_MS); + this.sweeper.unref?.(); + } +} diff --git a/eslint.config.ts b/eslint.config.ts index bc8b1ff363..85cf1f618d 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -67,9 +67,11 @@ const projectList = [ './apps/web/tsconfig.json', './packages/agent-setup/tsconfig.json', './packages/agent-setup/tsconfig.scripts.json', + './packages/dump/tsconfig.json', './packages/gateway/tsconfig.json', './packages/http/tsconfig.json', './packages/interceptor/tsconfig.json', + './packages/pipeline/tsconfig.json', './packages/platform/tsconfig.json', './packages/protocols/tsconfig.json', './packages/provider/tsconfig.json', @@ -116,6 +118,15 @@ const commonConfig: Linter.Config = { zones: [ { target: './apps/platform-cloudflare', from: './apps/platform-node', message: 'Platform-target apps cannot import each other; share via packages/.' }, { target: './apps/platform-node', from: './apps/platform-cloudflare', message: 'Platform-target apps cannot import each other; share via packages/.' }, + ...['gateway', 'provider-azure', 'provider-claude-code', 'provider-codex', 'provider-copilot', 'provider-custom', 'provider-ollama'].map(name => ({ + target: './packages/dump/src', from: `./packages/${name}/src`, + message: 'Run recording receives application ports; gateway and vendor composition stay outside the dump package.', + })), + { target: './packages/dump/src', from: './apps', message: 'Deployment and UI composition stay outside the dump package.' }, + ...['pipeline', 'platform', 'provider', 'protocols', 'http', 'proxy'].map(name => ({ + target: `./packages/${name}/src`, from: './packages/dump/src', + message: 'Foundation packages cannot depend on run recording; recording depends on their contracts.', + })), // components/ui holds generic primitives. `no-restricted-imports` only // sees package specifiers, so the relative route into a domain module // is closed here instead. @@ -232,6 +243,34 @@ const config: Linter.Config[] = [ parserOptions, }, }, + { + files: ['packages/dump/src/**/*.ts'], + rules: { + 'no-restricted-imports': ['error', { + patterns: [ + ...RESTRICTED_IMPORT_PATTERNS, + { + group: ['@floway-dev/gateway', '@floway-dev/gateway/*', '@floway-dev/provider-*', '@floway-dev/provider-*/*', '@floway-dev/web', '@floway-dev/web/*'], + message: 'Run recording receives application ports; gateway, vendor, and UI composition stay outside the dump package.', + }, + ], + }], + }, + }, + { + files: ['packages/{pipeline,platform,provider,protocols,http,proxy}/src/**/*.ts'], + rules: { + 'no-restricted-imports': ['error', { + patterns: [ + ...RESTRICTED_IMPORT_PATTERNS, + { + group: ['@floway-dev/dump', '@floway-dev/dump/*'], + message: 'Foundation packages cannot depend on run recording; recording depends on their contracts.', + }, + ], + }], + }, + }, { // Server-side production sources only. `apps/web` runs in a browser, where // Blob is the ordinary way to hand bytes to a download, and tests construct diff --git a/packages/dump/__tests__/codec_test.ts b/packages/dump/__tests__/codec_test.ts new file mode 100644 index 0000000000..e251ec2619 --- /dev/null +++ b/packages/dump/__tests__/codec_test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'vitest'; + +import { metadata } from './fixtures.ts'; +import { dumpCodec } from '../src/index.ts'; + +test('broker frames round-trip the shared Floway metadata contract', () => { + const meta = metadata({ + upstream: { id: 'upstream', name: 'Configured upstream', kind: 'custom', hue: 42 }, + targetApi: 'openaiResponses', + ttftMs: 12, + inputTokens: 0, + error: { kind: 'failed', reason: 'source interrupted' }, + }); + expect(dumpCodec.decode(dumpCodec.encode(meta))).toEqual(meta); + expect(JSON.parse(dumpCodec.encode(meta))).toEqual({ event: 'appended', data: meta }); +}); + +test('broker decoding rejects valid JSON with malformed metadata', () => { + expect(() => dumpCodec.decode(JSON.stringify({ event: 'appended', data: { ...metadata(), status: '200' } }))) + .toThrow(/Invalid dump broker frame.*status/su); +}); + +test('broker decoding preserves the JSON parse failure in its error chain', () => { + let caught: unknown; + try { dumpCodec.decode('{invalid'); } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).cause).toBeInstanceOf(SyntaxError); +}); diff --git a/packages/dump/__tests__/fixtures.ts b/packages/dump/__tests__/fixtures.ts new file mode 100644 index 0000000000..11e6ebb579 --- /dev/null +++ b/packages/dump/__tests__/fixtures.ts @@ -0,0 +1,25 @@ +import type { DumpMetadata, StoredDumpRecord } from '../src/index.ts'; +import type { DumpEvent } from '@floway-dev/pipeline'; + +export const metadata = (overrides: Partial = {}): DumpMetadata => ({ + id: 'recorded-run', + startedAt: 100, + completedAt: 200, + method: 'POST', + path: '/v1/chat/completions', + status: 200, + upstream: null, + model: 'recorded-model', + inputTokens: null, + outputTokens: null, + requestBytes: 20, + responseBytes: 40, + durationMs: 100, + ttftMs: null, + targetApi: null, + error: null, + ...overrides, +}); + +export const eventsOf = (record: StoredDumpRecord): DumpEvent[] => + new TextDecoder('utf-8', { fatal: true }).decode(record.events).trimEnd().split('\n').map(line => JSON.parse(line) as DumpEvent); diff --git a/packages/dump/__tests__/recorder_test.ts b/packages/dump/__tests__/recorder_test.ts new file mode 100644 index 0000000000..a0fd3c681a --- /dev/null +++ b/packages/dump/__tests__/recorder_test.ts @@ -0,0 +1,126 @@ +import { expect, test } from 'vitest'; + +import { eventsOf, metadata } from './fixtures.ts'; +import { createRunRecorder, type StoredDumpRecord } from '../src/index.ts'; +import { compose, createRunReader, defer, defineStage, move, run } from '@floway-dev/pipeline'; + +test('a native pipeline round-trips facts, shared frame values and deferred outcomes through one object space', async () => { + const written: StoredDumpRecord[] = []; + const recorder = createRunRecorder({ write: async record => { written.push(record); }, publish: async () => {} }); + const shared = move({ text: 'Recorded 中', nested: { count: 7 } }); + const pending = Promise.withResolvers(); + const deferred = defer(pending.promise); + const stream = recorder.openStream(); + const streamFact = move(stream.fact); + const answer = defineStage<{ payload: typeof shared }, { + left: typeof shared; + right: typeof shared; + pending: typeof deferred; + stream: typeof streamFact; + }>({ + name: 'answer', + return: { provides: ['left', 'right', 'pending', 'stream'] }, + execute: async (facts, use) => { + await use.log.info('prepared', { payload: facts.payload }); + return move({ ...facts, left: facts.payload, right: facts.payload, pending: deferred, stream: streamFact }); + }, + }); + + const result = await run(compose('recorded', [answer]), move({ payload: shared }), { dump: recorder.sink }); + await stream.frame(shared); + await stream.end(); + pending.resolve(shared); + await result.drain(); + await recorder.finish(metadata()); + + expect(written).toHaveLength(1); + const events = eventsOf(written[0]!); + expect(events[0]?.type).toBe('object'); + expect(events.filter(event => event.type === 'stage.entered').map(event => event.name)).toEqual(['answer']); + expect(events.filter(event => event.type === 'stage.log').map(event => event.message)).toEqual(['prepared']); + const read = createRunReader(); + const decoded = events.map(event => read(event)); + const returned = decoded.find(value => value?.facts && 'left' in value.facts)!.facts!; + const frame = decoded.find(value => value?.frames)!.frames![0]; + const settled = decoded.find(value => value?.outcome)!.outcome!; + expect(returned.left).toEqual(shared); + expect(returned.left).toBe(returned.right); + expect(frame).toBe(returned.left); + expect(settled.status).toBe('fulfilled'); + if (settled.status !== 'fulfilled') throw new Error('Expected the deferred result to fulfill'); + expect(settled.value).toBe(returned.left); + expect(returned.stream).toEqual({ stream: 1 }); +}); + +test('publication waits for an observed storage write to complete', async () => { + const entered = Promise.withResolvers(); + const stored = Promise.withResolvers(); + const order: string[] = []; + const meta = metadata(); + const recorder = createRunRecorder({ + write: async record => { order.push('writing'); entered.resolve(record); await stored.promise; order.push('stored'); }, + publish: async value => { expect(value).toBe(meta); order.push('published'); }, + }); + recorder.sink({ type: 'stage.entered', stageId: 1, name: 'answer', parentStageId: null, facts: move({ input: '中' }) }); + const finishing = recorder.finish(meta); + const record = await entered.promise; + expect(record.meta).toBe(meta); + expect(eventsOf(record).some(event => event.type === 'stage.entered')).toBe(true); + expect(order).toEqual(['writing']); + stored.resolve(); + await finishing; + expect(order).toEqual(['writing', 'stored', 'published']); +}); + +test('an encoder failure retains its original exception and never stores or publishes a partial record', async () => { + let writes = 0; + let publications = 0; + const recorder = createRunRecorder({ write: async () => { writes++; }, publish: async () => { publications++; } }); + let original: unknown; + try { + recorder.sink({ type: 'stage.entered', stageId: 1, name: 'invalid', parentStageId: null, facts: move({ upload: new Blob(['source bytes']) }) }); + } catch (error) { original = error; } + expect(original).toBeInstanceOf(TypeError); + expect((original as Error).message).toContain('Blob'); + let subsequent: unknown; + try { + recorder.sink({ type: 'stage.failed', stageId: 1, error: original }); + } catch (error) { subsequent = error; } + expect(subsequent).toBe(original); + await expect(recorder.finish(metadata({ status: 500 }))).rejects.toBe(original); + expect(writes).toBe(0); + expect(publications).toBe(0); +}); + +test('storage rejection propagates the original error and prevents publication', async () => { + const original = new Error('durable storage failed'); + let publications = 0; + const recorder = createRunRecorder({ write: async () => { throw original; }, publish: async () => { publications++; } }); + await recorder.frame({ type: 'event', event: { text: 'recorded' } }); + await expect(recorder.finish(metadata())).rejects.toBe(original); + expect(publications).toBe(0); +}); + +test('publication rejection preserves its original error after the complete record exists', async () => { + const original = new Error('broker failed'); + const written: StoredDumpRecord[] = []; + const recorder = createRunRecorder({ write: async record => { written.push(record); }, publish: async () => { throw original; } }); + await recorder.frame({ type: 'event', event: { text: 'recorded' } }); + await expect(recorder.finish(metadata())).rejects.toBe(original); + expect(written).toHaveLength(1); + expect(eventsOf(written[0]!).filter(event => event.type === 'stream.frame')).toHaveLength(1); +}); + +test('direct frames share a stream and separately opened streams retain distinct references', async () => { + const written: StoredDumpRecord[] = []; + const recorder = createRunRecorder({ write: async record => { written.push(record); }, publish: async () => {} }); + await recorder.frame({ type: 'event', event: 'first' }); + await recorder.frame({ type: 'event', event: 'second' }); + const independent = recorder.openStream(); + await independent.frame({ type: 'event', event: 'other' }); + await independent.end(); + await recorder.finish(metadata()); + const events = eventsOf(written[0]!); + expect(events.filter(event => event.type === 'stream.frame').map(event => event.streamId)).toEqual([1, 1, 2]); + expect(events.filter(event => event.type === 'stream.end').map(event => event.streamId)).toEqual([2]); +}); diff --git a/packages/dump/__tests__/stream_test.ts b/packages/dump/__tests__/stream_test.ts new file mode 100644 index 0000000000..e9a6f77a1a --- /dev/null +++ b/packages/dump/__tests__/stream_test.ts @@ -0,0 +1,146 @@ +import { expect, test } from 'vitest'; + +import { eventsOf, metadata } from './fixtures.ts'; +import { createRunRecorder, recordStream, streamReferenceOf, type StoredDumpRecord, type StreamRecorder } from '../src/index.ts'; +import { isStreamFact, streamFact } from '@floway-dev/pipeline'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; + +const frames = [ + { type: 'event', event: { text: 'first 中' } }, + { type: 'event', event: { text: 'second' } }, +] as const; + +const fixture = () => { + const records: StoredDumpRecord[] = []; + const recorder = createRunRecorder({ write: async record => { records.push(record); }, publish: async () => {} }); + return { recorder, records }; +}; + +test('natural source EOF records the same values and its completion marker', async () => { + const { recorder, records } = fixture(); + const source = (async function* () { yield* frames; })(); + const recorded = recordStream(source, recorder); + const values: unknown[] = []; + for await (const value of recorded) values.push(value); + expect(values[0]).toBe(frames[0]); + expect(values[1]).toBe(frames[1]); + expect(isStreamFact(recorded)).toBe(true); + expect(isStreamFact(streamReferenceOf(recorded))).toBe(true); + await recorder.finish(metadata()); + const events = eventsOf(records[0]!); + expect(events.filter(event => event.type === 'stream.frame')).toHaveLength(2); + expect(events.filter(event => event.type === 'stream.end').map(event => event.streamId)).toEqual([1]); +}); + +test('an early return closes the actual source and leaves its recording incomplete', async () => { + const { recorder, records } = fixture(); + let closed = false; + const source = (async function* () { try { yield* frames; } finally { closed = true; } })(); + const iterator = recordStream(source, recorder)[Symbol.asyncIterator](); + expect((await iterator.next()).value).toBe(frames[0]); + await iterator.return?.(); + expect(closed).toBe(true); + await recorder.finish(metadata()); + const events = eventsOf(records[0]!); + expect(events.filter(event => event.type === 'stream.frame')).toHaveLength(1); + expect(events.some(event => event.type === 'stream.end')).toBe(false); +}); + +test('a source failure preserves its original error and omits clean completion', async () => { + const { recorder, records } = fixture(); + const original = new Error('source failed'); + let closed = false; + const source = (async function* () { + try { yield frames[0]; throw original; } finally { closed = true; } + })(); + const iterator = recordStream(source, recorder)[Symbol.asyncIterator](); + expect((await iterator.next()).value).toBe(frames[0]); + await expect(iterator.next()).rejects.toBe(original); + expect(closed).toBe(true); + await recorder.finish(metadata()); + const events = eventsOf(records[0]!); + expect(events.filter(event => event.type === 'stream.frame')).toHaveLength(1); + expect(events.some(event => event.type === 'stream.end')).toBe(false); +}); + +test('projection can skip recording a value while forwarding every source value untouched', async () => { + const { recorder, records } = fixture(); + const skipped = { kind: 'heartbeat' } as const; + const content = { kind: 'text', text: 'recorded' } as const; + const source = (async function* () { yield skipped; yield content; })(); + const values: unknown[] = []; + const projected: ProtocolFrame = { type: 'event', event: content }; + for await (const value of recordStream(source, recorder, value => value.kind === 'heartbeat' ? null : projected)) values.push(value); + expect(values[0]).toBe(skipped); + expect(values[1]).toBe(content); + await recorder.finish(metadata()); + const events = eventsOf(records[0]!); + expect(events.filter(event => event.type === 'stream.frame')).toHaveLength(1); + expect(events.filter(event => event.type === 'stream.end')).toHaveLength(1); +}); + +test('no recorder returns the source identity and does not invoke projection', () => { + const source = (async function* () { yield frames[0]; })(); + const recorded = recordStream(source, null, () => { throw new Error('Projection must not run'); }); + expect(recorded).toBe(source); + expect(streamReferenceOf(source)).toEqual({}); +}); + +test('a source value is not yielded until its asynchronous frame write completes', async () => { + const entered = Promise.withResolvers(); + const stored = Promise.withResolvers(); + let closed = false; + let yielded = false; + const recorder: StreamRecorder = { + openStream: () => ({ + fact: streamFact(1), + frame: async frame => { expect(frame).toBe(frames[0]); entered.resolve(); await stored.promise; }, + end: () => { throw new Error('Early return must not end the recording'); }, + }), + }; + const source = (async function* () { try { yield frames[0]; } finally { closed = true; } })(); + const iterator = recordStream(source, recorder)[Symbol.asyncIterator](); + const first = iterator.next().then(value => { yielded = true; return value; }); + await entered.promise; + await new Promise(resolve => setImmediate(resolve)); + expect(yielded).toBe(false); + stored.resolve(); + expect((await first).value).toBe(frames[0]); + await iterator.return?.(); + expect(closed).toBe(true); +}); + +test('clean iterator completion waits for the asynchronous recording end', async () => { + const entered = Promise.withResolvers(); + const stored = Promise.withResolvers(); + let completed = false; + const recorder: StreamRecorder = { + openStream: () => ({ + fact: streamFact(1), + frame: () => { throw new Error('The empty source has no frames'); }, + end: async () => { entered.resolve(); await stored.promise; }, + }), + }; + const source = (async function* (): AsyncGenerator> {})(); + const iterator = recordStream(source, recorder)[Symbol.asyncIterator](); + const completion = iterator.next().then(value => { completed = true; return value; }); + await entered.promise; + await new Promise(resolve => setImmediate(resolve)); + expect(completed).toBe(false); + stored.resolve(); + expect((await completion).done).toBe(true); +}); + +test('recording failure closes the actual source and preserves the original exception', async () => { + const original = new Error('frame write failed'); + let closed = false; + let ended = false; + const recorder: StreamRecorder = { + openStream: () => ({ fact: streamFact(1), frame: async () => { throw original; }, end: () => { ended = true; } }), + }; + const source = (async function* () { try { yield* frames; } finally { closed = true; } })(); + const iterator = recordStream(source, recorder)[Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toBe(original); + expect(closed).toBe(true); + expect(ended).toBe(false); +}); diff --git a/packages/dump/__tests__/wire_test.ts b/packages/dump/__tests__/wire_test.ts new file mode 100644 index 0000000000..1f76ca697c --- /dev/null +++ b/packages/dump/__tests__/wire_test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'vitest'; + +import { metadata } from './fixtures.ts'; +import { dumpRecordToWire } from '../src/index.ts'; + +test('wire conversion preserves NDJSON content verbatim and shares its metadata', () => { + const meta = metadata(); + const events = '{"text":"中"}\n{"text":"β"}\n'; + const wire = dumpRecordToWire({ meta, events: new TextEncoder().encode(events) }); + expect(wire.meta).toBe(meta); + expect(wire.events).toBe(events); +}); + +test('wire conversion exposes corrupted UTF-8 instead of changing the recorded bytes', () => { + expect(() => dumpRecordToWire({ meta: metadata(), events: new Uint8Array([0xc3, 0x28]) })).toThrow(TypeError); +}); diff --git a/packages/dump/package.json b/packages/dump/package.json new file mode 100644 index 0000000000..13bcc5cf1b --- /dev/null +++ b/packages/dump/package.json @@ -0,0 +1,33 @@ +{ + "name": "@floway-dev/dump", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts" + }, + "./schemas": { + "import": "./src/schemas.ts", + "types": "./src/schemas.ts" + }, + "./codec": { + "import": "./src/codec.ts", + "types": "./src/codec.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@floway-dev/pipeline": "workspace:*", + "@floway-dev/platform": "workspace:*", + "@floway-dev/protocols": "workspace:*", + "@floway-dev/provider": "workspace:*", + "zod": "^4.4.3" + } +} diff --git a/packages/gateway/src/dump/codec.ts b/packages/dump/src/codec.ts similarity index 100% rename from packages/gateway/src/dump/codec.ts rename to packages/dump/src/codec.ts diff --git a/packages/dump/src/index.ts b/packages/dump/src/index.ts new file mode 100644 index 0000000000..6893b349b1 --- /dev/null +++ b/packages/dump/src/index.ts @@ -0,0 +1,6 @@ +export * from './types.ts'; +export * from './schemas.ts'; +export * from './codec.ts'; +export * from './recorder.ts'; +export * from './stream.ts'; +export * from './wire.ts'; diff --git a/packages/dump/src/recorder.ts b/packages/dump/src/recorder.ts new file mode 100644 index 0000000000..3f77546e71 --- /dev/null +++ b/packages/dump/src/recorder.ts @@ -0,0 +1,54 @@ +import type { DumpMetadata, StoredDumpRecord } from './types.ts'; +import { createRunEncoder, streamFact, toNdjson, type DumpEvent, type Event, type StreamFact } from '@floway-dev/pipeline'; + +export interface StreamRecording { + readonly fact: StreamFact; + frame(frame: unknown): void | Promise; + end(): void | Promise; +} + +export interface StreamRecorder { + openStream(): StreamRecording; +} + +export interface RecordingPorts { + write(record: StoredDumpRecord): Promise; + publish(meta: DumpMetadata): Promise; +} + +export const createRunRecorder = ({ write, publish }: RecordingPorts) => { + const encode = createRunEncoder(); + const events: DumpEvent[] = []; + let failure: { readonly error: unknown } | null = null; + let streams = 0; + let answerStream: StreamRecording | undefined; + const sink = (event: Event): void => { + if (failure !== null) throw failure.error; + try { + for (const encoded of encode(event)) events.push(encoded); + } catch (error) { + failure = { error }; + throw error; + } + }; + const openStream = (): StreamRecording => { + const streamId = ++streams; + return { + fact: streamFact(streamId), + frame: frame => { sink({ type: 'stream.frame', streamId, frames: [frame] }); }, + end: () => { sink({ type: 'stream.end', streamId }); }, + }; + }; + const frame = (value: unknown): void | Promise => { + answerStream ??= openStream(); + return answerStream.frame(value); + }; + const finish = async (meta: DumpMetadata): Promise => { + if (failure !== null) throw failure.error; + await write({ meta, events: new TextEncoder().encode(toNdjson(events)) }); + await publish(meta); + }; + return { sink, frame, openStream, finish }; +}; + +export type RunRecorder = ReturnType; diff --git a/packages/dump/src/schemas.ts b/packages/dump/src/schemas.ts new file mode 100644 index 0000000000..5e6ecd613d --- /dev/null +++ b/packages/dump/src/schemas.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +import { ALL_PROVIDER_KINDS } from '@floway-dev/provider/model'; + +export const dumpErrorSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.enum(['upstream', 'gateway']) }).strict(), + z.object({ kind: z.literal('failed'), reason: z.string() }).strict(), +]); + +export const dumpUpstreamRefSchema = z.object({ + id: z.string(), + name: z.string(), + kind: z.enum(ALL_PROVIDER_KINDS), + hue: z.number(), +}).strict(); + +export const dumpMetadataSchema = z.object({ + id: z.string(), + startedAt: z.number(), + completedAt: z.number(), + method: z.string(), + path: z.string(), + status: z.number().nullable(), + upstream: dumpUpstreamRefSchema.nullable(), + model: z.string().nullable(), + inputTokens: z.number().nullable(), + outputTokens: z.number().nullable(), + requestBytes: z.number(), + responseBytes: z.number(), + durationMs: z.number(), + // Time to first token in milliseconds. Null on non-streaming responses, + // turns failed before any output token arrived, prewarm answers, and + // records written before this field was introduced. + ttftMs: z.number().nullish(), + error: dumpErrorSchema.nullable(), + // The target protocol a translated turn spoke to its upstream. Null on + // native turns (no translation) and on records written before this field. + // `.nullish()` so old `meta_json` rows missing the key still parse. + targetApi: z.enum(['anthropicMessages', 'openaiResponses', 'openaiChatCompletions']).nullish(), +}).strict(); + +export const dumpBrokerFrameSchema = z.object({ + event: z.literal('appended'), + data: dumpMetadataSchema, +}).strict(); diff --git a/packages/dump/src/stream.ts b/packages/dump/src/stream.ts new file mode 100644 index 0000000000..e22454aa0b --- /dev/null +++ b/packages/dump/src/stream.ts @@ -0,0 +1,33 @@ +import type { StreamRecorder } from './recorder.ts'; +import { isStreamFact, type StreamFact } from '@floway-dev/pipeline'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; + +/** Records final protocol content before transport framing. Collection and streaming + * consume the same recorded values; the wrapper preserves source object identity. */ +export function recordStream>(stream: AsyncIterable, dump: StreamRecorder | null): AsyncIterable; +export function recordStream(stream: AsyncIterable, dump: StreamRecorder | null, asFrame: (value: T) => ProtocolFrame | null): AsyncIterable; +export function recordStream( + stream: AsyncIterable, + dump: StreamRecorder | null, + asFrame: (value: T) => ProtocolFrame | null = value => value as ProtocolFrame, +): AsyncIterable { + if (dump === null) return stream; + + const recording = dump.openStream(); + return { + ...recording.fact, + [Symbol.asyncIterator]: () => (async function* () { + for await (const value of stream) { + const frame = asFrame(value); + if (frame !== null) await recording.frame(frame); + yield value; + } + // Early return or failure leaves the recording incomplete. + await recording.end(); + })(), + }; +} + +/** Transport wrappers retain the same symbol-backed reference as their content stream. */ +export const streamReferenceOf = (value: unknown): StreamFact | Record => + isStreamFact(value) ? { ...value } : {}; diff --git a/packages/dump/src/types.ts b/packages/dump/src/types.ts new file mode 100644 index 0000000000..5ff2339514 --- /dev/null +++ b/packages/dump/src/types.ts @@ -0,0 +1,37 @@ +import type { z } from 'zod'; + +import type { dumpErrorSchema, dumpMetadataSchema, dumpUpstreamRefSchema } from './schemas.ts'; +import type { ChannelBroker } from '@floway-dev/platform'; + +export type DumpRecordId = string; +export type DumpErrorMeta = z.infer; +export type DumpMetadata = z.infer; +export type DumpUpstreamRef = z.infer; +export type DumpBroker = ChannelBroker; + +export interface StoredDumpRecord { + readonly meta: DumpMetadata; + readonly events: Uint8Array; +} + +export interface DumpRecord { + readonly meta: DumpMetadata; + readonly events: string; +} + +export interface DumpListOptions { + before?: DumpRecordId; + q?: string; + failures?: boolean; + limit: number; +} + +export interface DumpStore { + put(keyId: string, record: StoredDumpRecord): Promise; + list(keyId: string, options: DumpListOptions): Promise; + get(keyId: string, recordId: DumpRecordId): Promise; + deleteExpiredBatch(keyId: string, now: number, limit: number): Promise; + findOldestCreatedAt(keyId: string): Promise; +} + +export const DUMP_DISABLED_REASON = 'dump retention disabled'; diff --git a/packages/dump/src/wire.ts b/packages/dump/src/wire.ts new file mode 100644 index 0000000000..023ac20ce1 --- /dev/null +++ b/packages/dump/src/wire.ts @@ -0,0 +1,6 @@ +import type { DumpRecord, StoredDumpRecord } from './types.ts'; + +export const dumpRecordToWire = (record: StoredDumpRecord): DumpRecord => ({ + meta: record.meta, + events: new TextDecoder('utf-8', { fatal: true }).decode(record.events), +}); diff --git a/packages/dump/tsconfig.json b/packages/dump/tsconfig.json new file mode 100644 index 0000000000..4cffc03465 --- /dev/null +++ b/packages/dump/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["vitest.config.ts", "src/**/*.ts", "__tests__/**/*.ts"] +} diff --git a/packages/dump/vitest.config.ts b/packages/dump/vitest.config.ts new file mode 100644 index 0000000000..bf72d73952 --- /dev/null +++ b/packages/dump/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +import { requireNamedTestFiles } from '../../tools/src/verification-scope.ts'; + +requireNamedTestFiles(); + +export default defineConfig({ + test: { + include: ['__tests__/**/*_test.ts'], + environment: 'node', + }, +}); diff --git a/packages/gateway/__tests__/dump/storage-codec_test.ts b/packages/gateway/__tests__/dump/storage-codec_test.ts index 6297db9e93..05277337a0 100644 --- a/packages/gateway/__tests__/dump/storage-codec_test.ts +++ b/packages/gateway/__tests__/dump/storage-codec_test.ts @@ -1,13 +1,13 @@ import { expect, test } from 'vitest'; import { fakeMeta } from './test-fixtures.ts'; -import { dumpCodec } from '../../src/dump/codec.ts'; import { decodeDumpHeaders, decodePersistedDumpMetadata, encodeDumpHeaders, encodePersistedDumpMetadata, } from '../../src/dump/storage-codec.ts'; +import { dumpCodec } from '@floway-dev/dump/codec'; test('dump storage headers preserve duplicate pairs and their order', () => { const headers: Array<[string, string]> = [ diff --git a/packages/gateway/__tests__/dump/test-fixtures.ts b/packages/gateway/__tests__/dump/test-fixtures.ts index cf9d384140..6cbb022c2b 100644 --- a/packages/gateway/__tests__/dump/test-fixtures.ts +++ b/packages/gateway/__tests__/dump/test-fixtures.ts @@ -1,6 +1,6 @@ -import type { DumpBroker } from '../../src/dump/broker.ts'; import type { DumpStore } from '../../src/dump/store-contract.ts'; import type { DumpMetadata, StoredDumpRecord } from '../../src/dump/types.ts'; +import type { DumpBroker } from '@floway-dev/dump'; export const fakeMeta = (overrides: Partial = {}): DumpMetadata => ({ id: 'test-id', diff --git a/packages/gateway/__tests__/vitest.setup.ts b/packages/gateway/__tests__/vitest.setup.ts index f4af14b3bb..5ca7278aff 100644 --- a/packages/gateway/__tests__/vitest.setup.ts +++ b/packages/gateway/__tests__/vitest.setup.ts @@ -1,11 +1,11 @@ import { trackBackground } from './test-utils/background-tracker.ts'; -import type { DumpBroker } from '../src/dump/broker.ts'; import { initDumpBroker, initDumpStore } from '../src/dump/registry.ts'; import type { DumpStore } from '../src/dump/store-contract.ts'; import type { DumpMetadata, StoredDumpRecord, DumpRecordId } from '../src/dump/types.ts'; import { handleExecutionRequest } from '../src/execution/handler.ts'; import { initBackgroundSchedulerResolver } from '../src/runtime/background.ts'; import { initExecutionCellNamespace } from '../src/runtime/execution.ts'; +import type { DumpBroker } from '@floway-dev/dump'; import { isReplayableBody } from '@floway-dev/http'; import { initEnv, initFetch, initRuntimeKind, initTimingSafeEqual, InProcessExecutionCellNamespace } from '@floway-dev/platform'; diff --git a/packages/gateway/package.json b/packages/gateway/package.json index c91275a347..97dd4f957f 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -20,10 +20,6 @@ "./control-plane/upstreams/types": { "types": "./src/control-plane/upstreams/types.ts" }, - "./dump-codec": { - "import": "./src/dump/codec.ts", - "types": "./src/dump/codec.ts" - }, "./dump-types": { "types": "./src/dump/types.ts" }, @@ -40,6 +36,7 @@ }, "dependencies": { "@floway-dev/agent-setup": "workspace:*", + "@floway-dev/dump": "workspace:*", "@floway-dev/http": "workspace:*", "@floway-dev/interceptor": "workspace:*", "@floway-dev/platform": "workspace:*", diff --git a/packages/gateway/src/dump/broker.ts b/packages/gateway/src/dump/broker.ts deleted file mode 100644 index 067f8c46a8..0000000000 --- a/packages/gateway/src/dump/broker.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { DumpMetadata } from './types.ts'; -import type { ChannelBroker } from '@floway-dev/platform'; - -export type DumpBroker = ChannelBroker; - -export const DUMP_DISABLED_REASON = 'dump retention disabled'; diff --git a/packages/gateway/src/dump/registry.ts b/packages/gateway/src/dump/registry.ts index 84f3a32d59..8032fc6d0a 100644 --- a/packages/gateway/src/dump/registry.ts +++ b/packages/gateway/src/dump/registry.ts @@ -1,5 +1,5 @@ -import { DUMP_DISABLED_REASON, type DumpBroker } from './broker.ts'; import type { DumpStore } from './store-contract.ts'; +import { DUMP_DISABLED_REASON, type DumpBroker } from '@floway-dev/dump'; let _store: DumpStore | null = null; let _broker: DumpBroker | null = null; diff --git a/packages/gateway/src/dump/schemas.ts b/packages/gateway/src/dump/schemas.ts index 2566c8a211..2a4deaccc1 100644 --- a/packages/gateway/src/dump/schemas.ts +++ b/packages/gateway/src/dump/schemas.ts @@ -1,43 +1,6 @@ import { z } from 'zod'; -import { ALL_PROVIDER_KINDS } from '@floway-dev/provider'; - -export const dumpErrorSchema = z.discriminatedUnion('kind', [ - z.object({ kind: z.enum(['upstream', 'gateway']) }).strict(), - z.object({ kind: z.literal('failed'), reason: z.string() }).strict(), -]); - -export const dumpUpstreamRefSchema = z.object({ - id: z.string(), - name: z.string(), - kind: z.enum(ALL_PROVIDER_KINDS), - hue: z.number(), -}).strict(); - -export const dumpMetadataSchema = z.object({ - id: z.string(), - startedAt: z.number(), - completedAt: z.number(), - method: z.string(), - path: z.string(), - status: z.number().nullable(), - upstream: dumpUpstreamRefSchema.nullable(), - model: z.string().nullable(), - inputTokens: z.number().nullable(), - outputTokens: z.number().nullable(), - requestBytes: z.number(), - responseBytes: z.number(), - durationMs: z.number(), - // Time to first token in milliseconds. Null on non-streaming responses, - // turns failed before any output token arrived, prewarm answers, and - // records written before this field was introduced. - ttftMs: z.number().nullish(), - error: dumpErrorSchema.nullable(), - // The target protocol a translated turn spoke to its upstream. Null on - // native turns (no translation) and on records written before this field. - // `.nullish()` so old `meta_json` rows missing the key still parse. - targetApi: z.enum(['anthropicMessages', 'openaiResponses', 'openaiChatCompletions']).nullish(), -}).strict(); +import { dumpMetadataSchema } from '@floway-dev/dump/schemas'; export const persistedDumpMetadataSchema = dumpMetadataSchema.omit({ upstream: true }); @@ -99,8 +62,3 @@ export const dumpCaptureEnvelopeSchema = z.object({ ]), }).optional(), }).strict(); - -export const dumpBrokerFrameSchema = z.object({ - event: z.literal('appended'), - data: dumpMetadataSchema, -}).strict(); diff --git a/packages/gateway/src/dump/types.ts b/packages/gateway/src/dump/types.ts index 39621828c6..847c2abe3b 100644 --- a/packages/gateway/src/dump/types.ts +++ b/packages/gateway/src/dump/types.ts @@ -13,28 +13,13 @@ import type { z } from 'zod'; import type { dumpCaptureSchema, - dumpErrorSchema, - dumpMetadataSchema, dumpStreamEventSchema, - dumpUpstreamRefSchema, } from './schemas.ts'; +import type { DumpMetadata } from '@floway-dev/dump/types'; +export type { DumpRecordId, DumpErrorMeta, DumpMetadata, DumpUpstreamRef } from '@floway-dev/dump/types'; -export type DumpRecordId = string; export type DumpCapture = z.infer; -export type DumpUpstreamRef = z.infer; - -// What went wrong on a failed turn. Either a categorized api-error envelope -// (real upstream non-2xx or a gateway-synthesized envelope — `kind` matches -// `ApiErrorResult.source`) or an uncategorized failure (anything the -// respond layer / passthrough-serve caught or observed mid-flight: thrown -// exceptions, source-emitted error events, downstream cancels, write -// errors) carrying its one-line reason text. The categorized form stores -// no status — `DumpMetadata.status` already does. -export type DumpErrorMeta = z.infer; - -export type DumpMetadata = z.infer; - // Canonical protocol frame the gateway's respond layer fans out to every // dump-enabled key. Stored as ProtocolFrame (not the SSE-serialized form) // so the gateway's live fold and the dashboard's cold fold can share the diff --git a/packages/pipeline/__tests__/backpressure_test.ts b/packages/pipeline/__tests__/backpressure_test.ts new file mode 100644 index 0000000000..33f3dcca0b --- /dev/null +++ b/packages/pipeline/__tests__/backpressure_test.ts @@ -0,0 +1,98 @@ +import { expect, test } from 'vitest'; + +import { compose, defer, defineStage, getFailureFacts, move, run } from '../src/index.ts'; + +const gate = () => { + let release!: () => void; + const promise = new Promise(resolve => { release = resolve; }); + return { promise, release }; +}; + +test('stage execution waits for its entry to reach the asynchronous recording sink', async () => { + const received = gate(); + const stored = gate(); + let executed = false; + const answer = defineStage, Record>({ + name: 'answer', return: { provides: [] }, execute: async facts => { executed = true; return facts; }, + }); + const running = run(compose('backpressure', [answer]), move({}), { + dump: async event => { + if (event.type === 'stage.entered') { received.release(); await stored.promise; } + }, + }); + await received.promise; + expect(executed).toBe(false); + stored.release(); + await running; + expect(executed).toBe(true); +}); + +test('an awaited scoped log blocks on recording while accepting a synchronous global logger', async () => { + const received = gate(); + const stored = gate(); + let finished = false; + let globalWritten = false; + const answer = defineStage, Record>({ + name: 'logging', return: { provides: [] }, execute: async (facts, use) => { + await use.log.info('line'); + finished = true; + return facts; + }, + }); + const running = run(compose('logging', [answer]), move({}), { + log: { debug: () => {}, info: () => { globalWritten = true; }, warn: () => {}, error: () => {} }, + dump: async event => { if (event.type === 'stage.log') { received.release(); await stored.promise; } }, + }); + await received.promise; + expect(globalWritten).toBe(true); + expect(finished).toBe(false); + stored.release(); + await running; + expect(finished).toBe(true); +}); + +test('drain waits for deferred settlement to be durably recorded', async () => { + const received = gate(); + const stored = gate(); + const answer = defineStage, { pending: Promise }>({ + name: 'deferred', return: { provides: ['pending'] }, execute: async () => move({ pending: defer(Promise.resolve(42)) }), + }); + const result = await run(compose('deferred', [answer]), move({}), { + dump: async event => { if (event.type === 'deferred.settled') { received.release(); await stored.promise; } }, + }); + let drained = false; + const draining = result.drain().then(() => { drained = true; }); + await received.promise; + expect(drained).toBe(false); + stored.release(); + await draining; + expect(drained).toBe(true); +}); + +test('recording rejection exposes its original error and accepted failure facts', async () => { + const error = new Error('storage failed'); + const answer = defineStage<{ context: string }, { context: string }>({ + name: 'logging', return: { provides: ['context'] }, execute: async (facts, use) => { await use.log.info('line'); return facts; }, + }); + const facts = move({ context: 'request' }); + await expect(run(compose('logging', [answer]), facts, { + dump: async event => { if (event.type === 'stage.log') throw error; }, + })).rejects.toBe(error); + expect(getFailureFacts(error)).toBe(facts); +}); + +test('deferred and recording failures retain both exceptions and their fact snapshot', async () => { + const sourceError = new Error('deferred failed'); + const storageError = new Error('storage failed'); + const answer = defineStage, { pending: Promise }>({ + name: 'deferred', return: { provides: ['pending'] }, execute: async () => move({ pending: defer(Promise.reject(sourceError)) }), + }); + const result = await run(compose('deferred', [answer]), move({}), { + dump: async event => { if (event.type === 'deferred.settled') throw storageError; }, + }); + const caught: unknown = await result.drain().catch((error: unknown) => error); + expect(caught).toBeInstanceOf(AggregateError); + expect((caught as AggregateError).errors).toEqual([sourceError, storageError]); + expect((caught as AggregateError).cause).toBe(sourceError); + expect(getFailureFacts(caught)).toBe(result.facts); +}); diff --git a/packages/pipeline/__tests__/boundary_test.ts b/packages/pipeline/__tests__/boundary_test.ts new file mode 100644 index 0000000000..a0a684dc4e --- /dev/null +++ b/packages/pipeline/__tests__/boundary_test.ts @@ -0,0 +1,89 @@ +// What a handoff must satisfy when the target pipeline lives in another package. It turns +// out to need nothing, and this is the proof — the provider is a separate module with its +// own fact space, its own key that travels through the record, and an export whose type +// mentions only the core space. + +import { describe, expect, it } from 'vitest'; + +import { callUpstream, failover, makeProvider, splitWords } from './fixtures.ts'; +import type { Core } from './fixtures.ts'; +import { providerChain } from './provider-package.ts'; +import { compose, defineStage, move, run } from '../src/index.ts'; +import type { Event } from '../src/index.ts'; + +const PLAIN = {}; + +/** The gateway's side of the boundary. It names the target and knows nothing else about + * it — not its stages, not its space, not that it has one. */ +const enterProvider = defineStage< + Core<'in.words' | 'route.candidate'>, + Core<'in.words' | 'route.candidate'>, + Core<'out.result' | 'out.body'>, + Core<'out.result' | 'out.body'> +>({ + name: 'enterProvider', + into: { + request: { needs: ['in.words', 'route.candidate'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + execute: async (facts, next) => await next(facts, providerChain), +}); + +describe('a handoff across a package boundary', () => { + it('enters a provider whose fact space the caller cannot see', async () => { + const serve = compose, Core<'out.result'>>('serve', [enterProvider]); + const { facts } = await run(serve, move({ 'in.words': ['a', 'b'], 'route.candidate': 'steady' }), PLAIN); + expect(facts['out.result']).toEqual({ ok: 'tok-steady:a-b' }); + }); + + // A pipeline's interior is not part of its type — that is what `compose` erasing its + // stages buys — so the module boundary changes nothing that assembly had not already + // settled. The credential is a fact the whole way, and the dump shows it. + it('carries the provider-s own key through the record and drops it before the wire', async () => { + const seen: Event[] = []; + const serve = compose, Core<'out.result'>>('serve', [enterProvider]); + await run(serve, move({ 'in.words': ['a'], 'route.candidate': 'steady' }), { dump: (e: Event) => { seen.push(e); } }); + + const carrying = seen.filter(e => e.type === 'stage.entered' && 'provider.token' in e.facts); + expect(carrying.map(e => e.type === 'stage.entered' ? e.name : '')).toEqual(['callWithToken']); + + // And it is gone from what the run answers with. + const last = seen.filter(e => e.type === 'stage.leaved').at(-1)!; + expect('provider.token' in last.facts).toBe(false); + }); + + it('fails over across the boundary, because the fork is above it', async () => { + const serve = compose, Core<'out.result'>>('serve', [ + splitWords, failover(['flaky', 'steady']), enterProvider, + ]); + const { facts } = await run(serve, move({ 'in.text': 'a b' }), PLAIN); + expect(facts['out.result']).toEqual({ ok: 'tok-steady:a-b' }); + }); + + // Two providers with different spaces, in one composition, reached through one type. + it('holds two providers whose spaces differ behind one seal type', async () => { + const other = compose, Core<'out.result' | 'out.body'>>( + 'other-provider', [callUpstream(makeProvider('other', []))], + ); + const choose = defineStage< + Core<'in.words' | 'route.candidate'>, + Core<'in.words' | 'route.candidate'>, + Core<'out.result' | 'out.body'>, + Core<'out.result' | 'out.body'> + >({ + name: 'choose', + into: { + request: { needs: ['in.words', 'route.candidate'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + execute: async (facts, next) => + await next(facts, facts['route.candidate'] === 'steady' ? providerChain : other), + }); + const serve = compose, Core<'out.result'>>('serve', [choose]); + + expect((await run(serve, move({ 'in.words': ['a'], 'route.candidate': 'steady' }), PLAIN)).facts['out.result']) + .toEqual({ ok: 'tok-steady:a' }); + expect((await run(serve, move({ 'in.words': ['a'], 'route.candidate': 'quiet' }), PLAIN)).facts['out.result']) + .toEqual({ ok: 'other:a' }); + }); +}); diff --git a/packages/pipeline/__tests__/compose_test.ts b/packages/pipeline/__tests__/compose_test.ts new file mode 100644 index 0000000000..754b81bb1c --- /dev/null +++ b/packages/pipeline/__tests__/compose_test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { attemptPipeline, cache, callUpstream, handoff, makeProvider, splitWords } from './fixtures.ts'; +import type { Core } from './fixtures.ts'; +import { compose, defineStage } from '../src/index.ts'; + +const provider = () => makeProvider('tok', []); + +describe('compose', () => { + it('derives the entry contract from what nobody below provides', () => { + const serve = compose, Core<'out.result'>>('serve', [ + cache({}), splitWords, handoff(attemptPipeline(provider(), ['steady'])), + ]); + expect(serve.entryNeeds).toEqual(['in.text']); + }); + + it('leaves a key out of the entry contract once a stage above provides it', () => { + const attempt = attemptPipeline(provider(), ['steady']); + expect(attempt.entryNeeds).toEqual(['in.words']); // `route.candidate` is failover's own + }); + + // The load-bearing one: a key an earlier stage consumed cannot be needed below it, so a + // translated request cannot re-enter its own chain. + it('refuses a stage that needs a key an earlier stage consumed', () => { + expect(() => compose('reentry', [splitWords, cache({}), callUpstream(provider())])) + .toThrow('compose(reentry): cache needs in.text, which splitWords consumed above it'); + }); + + it('refuses a stage that declares `into` and is not last', () => { + expect(() => compose('bad', [handoff(attemptPipeline(provider(), ['steady'])), splitWords])) + .toThrow("compose(bad): handoff declares 'into' but is not last"); + }); + + it('refuses a short-circuit that does not cover what the stages above it need', () => { + const answersNothing = defineStage, object>({ + name: 'answersNothing', + return: { provides: [] }, + execute: async facts => facts, + }); + expect(() => compose('shortfall', [splitWords, answersNothing])) + .toThrow('compose(shortfall): answersNothing may answer here, but its short-circuit does not provide out.result, which stages above it need'); + }); + + it('refuses a stage that can neither answer nor descend', () => { + const inert = { name: 'inert', execute: async () => ({}) }; + expect(() => compose('inert', [inert])) + .toThrow('compose(inert): inert declares neither a way down nor a way to answer'); + }); + + // The mirror of the re-entry error, on the way back: a stage that takes a response key + // and does not hand it on leaves a stage above it needing something that cannot arrive. + it('refuses a stage that consumes a response key a stage above it needs', () => { + const swallows = defineStage, Core<'in.words'>, Core<'out.result'>, object>({ + name: 'swallows', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: ['out.result'], provides: [] }, + }, + execute: async (facts, next) => await next(facts), + }); + expect(() => compose('swallowed', [splitWords, swallows, callUpstream(provider())])) + .toThrow('compose(swallowed): swallows consumes out.result on the way up, which a stage above it needs'); + }); + + // A last stage that only declares `through` must go down, and there is nothing below it. + it('refuses a pipeline whose last stage can only descend', () => { + expect(() => compose('endless', [splitWords])) + .toThrow('compose(endless): splitWords is last but can neither answer nor hand off'); + }); + + // A stage that can only answer is where the array ends: anything after it never runs, + // and deriving an entry contract from stages that never run would be worse than useless. + it('refuses a stage that can only answer with stages written after it', () => { + expect(() => compose('unreachable', [callUpstream(provider()), splitWords])) + .toThrow('compose(unreachable): callUpstream can only answer, so the stages after it never run'); + }); + + // Assembly reasons over declarations, which are strings, so a stage written against a + // provider's larger space composes beside stages written against the core space with no + // variance question to lose. This is the composition the architecture exists to make work. + it('composes stages from two different fact spaces', () => { + expect(() => compose('mixed', [splitWords, handoff(attemptPipeline(provider(), ['steady']))])).not.toThrow(); + }); +}); diff --git a/packages/pipeline/__tests__/composition_test.ts b/packages/pipeline/__tests__/composition_test.ts new file mode 100644 index 0000000000..e68732d4b9 --- /dev/null +++ b/packages/pipeline/__tests__/composition_test.ts @@ -0,0 +1,86 @@ +import { expect, test } from 'vitest'; + +import { compose, defineStage, move, run } from '../src/index.ts'; + +type Request = { input: { value: string } }; +type Response = { answer: string }; + +test('descends outermost-first and unwinds responses inside-out', async () => { + const calls: string[] = []; + const wrap = (name: string) => defineStage({ + name, + through: { + request: { needs: ['input'], consumes: [], provides: [] }, + response: { needs: ['answer'], consumes: [], provides: [] }, + }, + execute: async (facts, next) => { + calls.push(`${name}-before`); + const result = await next(facts); + calls.push(`${name}-after`); + return result; + }, + }); + const terminal = defineStage({ + name: 'terminal', return: { provides: ['answer'] }, + execute: async facts => { + calls.push('terminal'); + return move({ ...facts, answer: facts.input.value }); + }, + }); + const result = await run(compose('order', [wrap('outer'), wrap('inner'), terminal]), move({ input: { value: 'ok' } }), {}); + expect(result.facts.answer).toBe('ok'); + expect(calls).toEqual(['outer-before', 'inner-before', 'terminal', 'inner-after', 'outer-after']); +}); + +test('reruns the suffix with a new request snapshot and transforms the response', async () => { + const initial = move({ input: { value: 'broken' } }); + const requests: Request[] = []; + const retry = defineStage({ + name: 'retry', + through: { + request: { needs: ['input'], consumes: [], provides: ['input'] }, + response: { needs: ['answer'], consumes: [], provides: ['answer'] }, + }, + execute: async (facts, next) => { + const first = await next(facts); + if (first.answer !== 'fail') return first; + const result = await next({ ...facts, input: move({ value: 'fixed' }) }); + return { ...result, answer: `${result.answer}:patched` }; + }, + }); + const terminal = defineStage({ + name: 'terminal', return: { provides: ['answer'] }, + execute: async facts => { + requests.push(facts); + return move({ ...facts, answer: facts.input.value === 'broken' ? 'fail' : facts.input.value }); + }, + }); + const result = await run(compose('retry', [retry, terminal]), initial, {}); + expect(result.facts.answer).toBe('fixed:patched'); + expect(requests.map(facts => facts.input.value)).toEqual(['broken', 'fixed']); + expect(requests[0]).toBe(initial); + expect(requests[1]).not.toBe(initial); + expect(initial.input.value).toBe('broken'); + expect(requests.every(facts => Object.isFrozen(facts) && Object.isFrozen(facts.input))).toBe(true); +}); + +test('unwinds each enclosing finally and propagates the original error', async () => { + const calls: string[] = []; + const error = new Error('upstream failed'); + const wrap = (name: string) => defineStage({ + name, + through: { + request: { needs: ['input'], consumes: [], provides: [] }, + response: { needs: ['answer'], consumes: [], provides: [] }, + }, + execute: async (facts, next) => { + calls.push(`${name}-before`); + try { return await next(facts); } finally { calls.push(`${name}-after`); } + }, + }); + const terminal = defineStage({ + name: 'terminal', return: { provides: ['answer'] }, execute: async () => { throw error; }, + }); + await expect(run(compose('error', [wrap('outer'), wrap('inner'), terminal]), move({ input: { value: 'x' } }), {})).rejects.toBe(error); + expect(calls).toEqual(['outer-before', 'inner-before', 'inner-after', 'outer-after']); +}); diff --git a/packages/pipeline/__tests__/deferral_test.ts b/packages/pipeline/__tests__/deferral_test.ts new file mode 100644 index 0000000000..6d6cbe9678 --- /dev/null +++ b/packages/pipeline/__tests__/deferral_test.ts @@ -0,0 +1,103 @@ +// Deferral: what a run started and has not finished. +// +// The rule these establish is that a stage has no fire-and-forget. Work a stage begins +// becomes a fact with a name, and teardown is where the run waits for it — so nothing +// escapes the run's own accounting, and nothing hangs it either. + +import { describe, expect, it, vi } from 'vitest'; + +import { compose, defer, defineStage, isDeferred, move, run } from '../src/index.ts'; + +const ending = (produce: () => unknown) => defineStage, { readonly 'x.settled': unknown }>({ + name: 'ending', + return: { provides: ['x.settled'] }, + execute: async facts => await Promise.resolve(move({ ...facts, 'x.settled': produce() })), +}); + +const pipelineOf = (produce: () => unknown) => compose('deferring', [ending(produce)]); + +describe('a deferred fact', () => { + it('is a claim on the value, not a promise the runner went looking for', () => { + expect(isDeferred(Promise.resolve(1))).toBe(false); + expect(isDeferred(defer(Promise.resolve(1)))).toBe(true); + expect(isDeferred({ then: () => {} })).toBe(false); + }); + + // Teardown is where the run waits. A caller that drains has waited for everything the run + // began, which is what lets a stage start work without reaching for a scheduler. + it('is awaited at teardown, so what a stage started finishes inside the run', async () => { + let finished = false; + let release!: () => void; + const started = new Promise(resolve => { release = resolve; }); + const { drain } = await run( + pipelineOf(() => defer(started.then(() => { finished = true; }))), + move({}), + {}, + ); + + // The run has answered and the work has not finished: that is the whole point of it + // being deferred rather than awaited where it was started. + expect(finished).toBe(false); + const torn = drain(); + release(); + await torn; + expect(finished).toBe(true); + }); + + // A failure that nobody hears about is a row nobody writes and nobody misses. + it('reports a failure rather than swallowing it', async () => { + const error = new Error('the write failed'); + const { drain } = await run( + pipelineOf(() => defer(Promise.reject(error))), + move({}), + {}, + ); + await expect(drain()).rejects.toBe(error); + }); + + it('does not fail declared work after an arbitrary wall-clock deadline', async () => { + vi.useFakeTimers(); + try { + let finish!: () => void; + const work = new Promise(resolve => { finish = resolve; }); + const { drain } = await run(pipelineOf(() => defer(work)), move({}), {}); + let completed = false; + const draining = drain().then(() => { completed = true; }); + await vi.advanceTimersByTimeAsync(120_000); + expect(completed).toBe(false); + finish(); + await draining; + expect(completed).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + // Per run, not per root: a discarded branch's facts are private to it and unreachable from + // the root's record, so a root-level sweep could never see them. Each run tears down its + // own, and a branch nobody adopted still finishes what it began. + it('belongs to the run that started it', async () => { + const finished: string[] = []; + const gate = (label: string) => { + let open!: () => void; + const waited = new Promise(resolve => { open = resolve; }); + return { open, work: () => defer(waited.then(() => { finished.push(label); })) }; + }; + const innerGate = gate('inner'); + const outerGate = gate('outer'); + const inner = await run(pipelineOf(innerGate.work), move({}), {}); + const outer = await run(pipelineOf(outerGate.work), move({}), {}); + + // Tearing one run down waits for that run's own work and nothing else's, which is what + // lets a discarded branch finish what it began without the root reaching into it. + const innerTorn = inner.drain(); + innerGate.open(); + await innerTorn; + expect(finished).toEqual(['inner']); + + const outerTorn = outer.drain(); + outerGate.open(); + await outerTorn; + expect(finished).toEqual(['inner', 'outer']); + }); +}); diff --git a/packages/pipeline/__tests__/dump_test.ts b/packages/pipeline/__tests__/dump_test.ts new file mode 100644 index 0000000000..cc8e1dff88 --- /dev/null +++ b/packages/pipeline/__tests__/dump_test.ts @@ -0,0 +1,295 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { attemptPipeline, makeProvider, servePipeline } from './fixtures.ts'; +import { compose, createRunEncoder, defer, defineStage, encodeRun, isSecret, move, run, secret, storedSecret, streamFact, toNdjson } from '../src/index.ts'; +import type { DumpEvent, Event, Stored } from '../src/index.ts'; + +const recording = () => { + const events: Event[] = []; + return { events, services: { dump: (event: Event) => { events.push(event); } } }; +}; + +const refsIn = (value: Stored, out: number[] = []): number[] => { + if (typeof value !== 'object' || value === null) return out; + if (Array.isArray(value)) { for (const item of value) refsIn(item, out); return out; } + const record = value as Record; + if ('$' in record && typeof record['$'] === 'number') { out.push(record['$']); return out; } + for (const child of Object.values(record)) refsIn(child, out); + return out; +}; + +const encodeFacts = (facts: object): DumpEvent[] => + encodeRun([{ type: 'stage.entered', stageId: 1, name: 'only', parentStageId: null, facts: facts as never }]); + +describe('the dump encoding', () => { + it('gives every object an id at first sight and refers to it thereafter', () => { + const shared = move({ n: 1 }); + const events = encodeFacts(move({ a: shared, b: shared })); + const entered = events.find(e => e.type === 'stage.entered')!; + const facts = entered.facts as Record; + expect(facts['a']).toEqual(facts['b']); + // One node, reached twice. The facts map itself is not a node — its keys are the + // event's own shape, and two events that share a whole record share it by folding. + const nodes = events.filter(e => e.type === 'object').flatMap(e => e.nodes); + expect(nodes).toEqual([{ n: 1 }]); + }); + + // An id is taken before recursing, which is what makes a cycle terminate on its second + // visit rather than recursing forever. + it('terminates on a cycle', () => { + const a: Record = { name: 'a' }; + const b: Record = { name: 'b', a }; + a['b'] = b; + const events = encodeFacts(move({ root: a })); + const objects = events.filter(e => e.type === 'object'); + expect(objects).toHaveLength(1); + expect(JSON.stringify(objects[0]!.nodes)).toContain('"$"'); + }); + + // The invariant every `object` event owes: no reference points forward into an event + // that has not arrived. That is what makes the stream emittable as it happens. + it('never refers forward past the event it is in', async () => { + const serve = servePipeline(attemptPipeline(makeProvider('tok', []), ['flaky', 'steady'])); + const { events, services } = recording(); + await run(serve, move({ 'in.text': 'a b c' }), services); + let highest = 0; + for (const event of encodeRun(events)) { + if (event.type === 'object') { + highest = event.fromObjectId + event.nodes.length - 1; + for (const node of event.nodes) { + for (const ref of refsIn(node)) expect(ref).toBeLessThanOrEqual(highest); + } + continue; + } + const carried = event.type === 'stage.entered' ? event.facts : event.type === 'stage.leaved' ? event.facts : undefined; + if (carried === undefined) continue; + for (const value of Object.values(carried)) { + for (const ref of refsIn(value)) expect(ref).toBeLessThanOrEqual(highest); + } + } + }); + + // A tool's parameters are JSON Schema, so `$schema`, `$defs` and `$ref` really do arrive. + it('escapes a key that already begins with a dollar', () => { + const events = encodeFacts(move({ tool: { $ref: '#/$defs/q', $defs: { q: 1 } } })); + const written = JSON.stringify(events); + expect(written).toContain('"$$ref"'); + expect(written).toContain('"$$defs"'); + // Only keys are escaped: a `$` inside a value is data. + expect(written).toContain('#/$defs/q'); + }); + + it('tags a stream by id rather than holding it', () => { + // A stream tag is written where it sits: it is an identifier, not an object that + // needs one, and its content arrives later in `stream.frame` events naming that id. + const events = encodeFacts(move({ body: streamFact(3) })); + const entered = events.find(e => e.type === 'stage.entered')!; + expect((entered.facts as Record)['body']).toEqual({ $stream: 3 }); + expect(events.some(e => e.type === 'object')).toBe(false); + }); + + it('stores a secret as length, redaction and hash, and never as itself', () => { + const token = secret('eyJhbGciOiJIUzI1NiJ9.super-secret-payload.qJp-QV30'); + expect(isSecret(token)).toBe(true); + const stored = storedSecret(token); + expect(stored.redacted).toBe('*'.repeat(50)); + expect(stored.length).toBe(50); + expect(stored.hash).toMatch(/^0x[0-9a-f]{16}$/); + const written = JSON.stringify(encodeFacts(move({ auth: token }))); + expect(written).not.toContain('super-secret-payload'); + expect(written).toContain('"$secret"'); + }); + + // Two separately constructed wrappers around the same secret, which is what a diff + // across two events actually compares. + it('gives the same secret the same hash at two events, so a diff reports no change', () => { + const first = secret('a-token-that-is-quite-long-indeed'); + const second = secret('a-token-that-is-quite-long-indeed'); + expect(first).not.toBe(second); + expect(storedSecret(first).hash).toBe(storedSecret(second).hash); + expect(storedSecret(secret('another-token-entirely-different')).hash).not.toBe(storedSecret(first).hash); + }); + + it('masks a short secret entirely rather than showing most of it', () => { + expect(storedSecret(secret('short')).redacted).toBe('*****'); + }); + + it('records portable file metadata and bytes exactly', () => { + const events = encodeFacts(move({ upload: { name: 'voice.wav', type: 'audio/wav', lastModified: 42, bytes: new Uint8Array([65, 66, 67]) } })); + const nodes = events.filter(event => event.type === 'object').flatMap(event => event.nodes); + expect(nodes).toContainEqual({ name: 'voice.wav', type: 'audio/wav', lastModified: 42, bytes: { $: 2 } }); + expect(nodes).toContainEqual({ $bytes: 'QUJD' }); + expect(() => encodeFacts(move({ upload: new File(['ABC'], 'voice.wav') }))).toThrow('bytes and metadata'); + }); + + it('shares complete byte values across buffer and view identities', () => { + const original = new Uint8Array([1, 2, 3]); + const buffer = original.slice().buffer; + const view = new DataView(buffer); + const events = encodeFacts(move({ original, buffer, view })); + expect(events.filter(event => event.type === 'object').flatMap(event => event.nodes)).toEqual([{ $bytes: 'AQID' }]); + const facts = events.find(event => event.type === 'stage.entered')!.facts!; + expect(facts['original']).toEqual(facts['buffer']); + expect(facts['buffer']).toEqual(facts['view']); + }); + + it('preserves collection and date content with explicit tags', () => { + const key = { id: 3 }; + const events = encodeFacts(move({ map: new Map([[key, 'value']]), set: new Set([key]), date: new Date(1234) })); + const nodes = events.filter(event => event.type === 'object').flatMap(event => event.nodes); + expect(nodes).toContainEqual({ $date: 1234 }); + expect(nodes.some(node => typeof node === 'object' && node !== null && '$map' in node)).toBe(true); + expect(nodes.some(node => typeof node === 'object' && node !== null && '$set' in node)).toBe(true); + expect(nodes.filter(node => JSON.stringify(node) === '{"id":3}')).toHaveLength(1); + expect(JSON.stringify(events)).toContain('value'); + expect(Object.isFrozen(key)).toBe(true); + }); + + it('distinguishes a native body from a recorded protocol stream', () => { + const body = new ReadableStream({ start: controller => controller.close() }); + const events = encodeFacts(move({ body, frames: streamFact(7) })); + expect(events.filter(event => event.type === 'object').flatMap(event => event.nodes)).toContainEqual({ $readableStream: true }); + expect(events.find(event => event.type === 'stage.entered')?.facts?.['frames']).toEqual({ $stream: 7 }); + }); + + it('retains non-enumerable error details and shared causes', () => { + const cause = new TypeError('provider fault'); + const error = new AggregateError([cause], 'failed', { cause }); + const events = encodeFacts(move({ error })); + const nodes = events.filter(event => event.type === 'object').flatMap(event => event.nodes); + const stored = nodes[0] as { $error: Record }; + expect(stored.$error['name']).toBe('AggregateError'); + expect(stored.$error['message']).toBe('failed'); + const stack = stored.$error['stack']; + const stackValue = typeof stack === 'object' && stack !== null && '$' in stack && typeof stack.$ === 'number' ? nodes[stack.$ - 1] : stack; + expect(stackValue).toBe(error.stack); + const errors = nodes[Number((stored.$error['errors'] as { $: number }).$) - 1] as Stored[]; + expect(errors[0]).toEqual(stored.$error['cause']); + }); + + it('records deferred content when the original fact settles', async () => { + const pending = defer(Promise.resolve({ count: 3 })); + const seen: Event[] = []; + const ending = compose('deferredDump', [defineStage, { pending: typeof pending }>({ + name: 'ending', return: { provides: ['pending'] }, execute: async facts => move({ ...facts, pending }), + })]); + const result = await run(ending, move({}), { dump: event => { seen.push(event); } }); + await result.drain(); + expect(seen.filter(event => event.type === 'deferred.settled')).toHaveLength(1); + const encoded = encodeRun(seen); + expect(encoded.filter(event => event.type === 'object').flatMap(event => event.nodes)).toContainEqual({ $deferred: true }); + expect(encoded.find(event => event.type === 'deferred.settled')).toHaveProperty('deferred'); + expect(JSON.stringify(encoded)).toContain('"count":3'); + }); + + it('shares a large string by value, which reference sharing cannot reach', () => { + const image = 'A'.repeat(4096); + const events = encodeFacts(move({ first: { data: image }, second: { data: image } })); + const nodes = events.filter(e => e.type === 'object').flatMap(e => e.nodes); + expect(nodes.filter(node => node === image)).toHaveLength(1); + }); + + // A `stage.entered` always appears — dropping it would strand its children's + // `parentStageId` — but it carries `facts` only when they differ from its parent's. + it('folds an entry that carries nothing of its own, and drops an exit that does', async () => { + const serve = servePipeline(attemptPipeline(makeProvider('tok', []), ['steady'])); + const { events, services } = recording(); + await run(serve, move({ 'in.text': 'a b' }), services); + const encoded = encodeRun(events); + const entries = encoded.filter(e => e.type === 'stage.entered'); + const rawEntries = events.filter(e => e.type === 'stage.entered'); + expect(entries).toHaveLength(rawEntries.length); + expect(entries.some(e => e.facts === undefined)).toBe(true); + expect(encoded.filter(e => e.type === 'stage.leaved').length) + .toBeLessThan(events.filter(e => e.type === 'stage.leaved').length); + }); + + it('carries a log line with its structured fields', () => { + const events: Event[] = [{ type: 'stage.log', stageId: 4, level: 'warn', context: 'callUpstream', message: 'slow', fields: { ms: 1200 } }]; + const [encoded] = encodeRun(events).filter(e => e.type === 'stage.log'); + expect(encoded).toMatchObject({ stageId: 4, level: 'warn', message: 'slow', fields: { ms: 1200 } }); + }); + + // A buffer is atomic and records its full content, the way a string does. Walking it by + // index would turn one image into a JSON object with a key per byte — an expansion of + // exactly the value most likely to be large, and enough to break the single-`put` sizing. + it('records a buffer as bytes rather than as an object keyed by index', () => { + const events = encodeFacts(move({ 'request.http.body': new Uint8Array([1, 2, 3, 4, 5]) })); + const nodes = events.filter(e => e.type === 'object').flatMap(e => e.nodes); + expect(nodes).toEqual([{ $bytes: 'AQIDBAU=' }]); + }); + + it('keeps a large buffer close to its own size instead of multiplying it', () => { + const image = move({ 'request.http.body': new Uint8Array(400 * 1024) }); + const written = toNdjson(encodeFacts(image)).length; + expect(written).toBeLessThan(600 * 1024); + }); + + // The design's own reason for refusing `undefined` as a removal is that `JSON.stringify` + // drops it, so the record and the dump would disagree in the direction that looks + // correct. Every value JSON cannot carry therefore gets a tag. + it('carries the values JSON cannot', () => { + const events = encodeFacts(move({ payload: { missing: undefined, nan: NaN, up: Infinity, down: -Infinity, big: 10n } })); + const [node] = events.filter(e => e.type === 'object').flatMap(e => e.nodes); + expect(node).toEqual({ + missing: { $undefined: true }, + nan: { $number: 'NaN' }, + up: { $number: 'Infinity' }, + down: { $number: '-Infinity' }, + big: { $bigint: '10' }, + }); + expect(() => JSON.stringify(events)).not.toThrow(); + }); + + it('keeps a key whose value is undefined, because the record kept it too', () => { + const payload = move({ payload: { a: 1, b: undefined } }); + expect('b' in (payload.payload as object)).toBe(true); + const written = toNdjson(encodeFacts(payload)); + expect(written).toContain('$undefined'); + }); + + // One event in, its encoding out — so a run can be written down as it happens rather + // than only once it is over, which is what a live observer and an appended file need. + it('encodes one event at a time, with ids carried across calls', () => { + const shared = move({ n: 1 }); + const encode = createRunEncoder(); + const first = encode({ type: 'stage.entered', stageId: 1, name: 'a', parentStageId: null, facts: move({ x: shared }) as never }); + const second = encode({ type: 'stage.entered', stageId: 2, name: 'b', parentStageId: 1, facts: move({ y: shared }) as never }); + expect(first.filter(e => e.type === 'object')).toHaveLength(1); + expect(second.filter(e => e.type === 'object')).toHaveLength(0); // already has an id + const entered = second.find(e => e.type === 'stage.entered')!; + expect((entered.facts as Record)['y']).toEqual({ $: 1 }); + }); + + it('writes one event per line', () => { + const ndjson = toNdjson(encodeFacts(move({ a: { n: 1 } }))); + expect(ndjson.endsWith('\n')).toBe(true); + const lines = ndjson.trimEnd().split('\n'); + expect(lines).toHaveLength(2); // the object it first saw, then the entry itself + for (const line of lines) expect(() => JSON.parse(line)).not.toThrow(); + }); + + // What makes exact retention affordable: deduplicated storage grows with the + // conversation plus what each stage changed, not with events times conversation. + it('costs a fraction of writing every state independently', async () => { + const serve = servePipeline(attemptPipeline(makeProvider('tok', []), ['flaky', 'steady'])); + const words = Array.from({ length: 400 }, (_, i) => `word${i}`).join(' '); + const { events, services } = recording(); + await run(serve, move({ 'in.text': words }), services); + const independent = events.reduce((n, e) => n + ('facts' in e ? JSON.stringify(e.facts).length : 0), 0); + const deduplicated = toNdjson(encodeRun(events)).length; + expect(deduplicated).toBeLessThan(independent / 2); + }); +}); + +it('a live encoder releases original fact graphs when stages return or fail', async () => { + const child = spawn(process.execPath, ['--expose-gc', '--import', import.meta.resolve('jiti/register'), fileURLToPath(new URL('./fixtures/encoder-retention.ts', import.meta.url))], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + child.stderr.on('data', chunk => { stderr += String(chunk); }); + const [code] = await once(child, 'exit'); + expect({ code, stderr }).toEqual({ code: 0, stderr: '' }); +}); diff --git a/packages/pipeline/__tests__/facts_test.ts b/packages/pipeline/__tests__/facts_test.ts new file mode 100644 index 0000000000..d4a051d0de --- /dev/null +++ b/packages/pipeline/__tests__/facts_test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { assertHandedOver, move } from '../src/facts.ts'; + +describe('handover', () => { + it('deep freezes what enters the record', () => { + const value = move({ outer: { inner: [1, 2, 3] } }); + expect(Object.isFrozen(value)).toBe(true); + expect(Object.isFrozen(value.outer)).toBe(true); + expect(Object.isFrozen(value.outer.inner)).toBe(true); + expect(() => { (value.outer as { inner: unknown }).inner = []; }).toThrow(TypeError); + }); + + it('accepts a value that was handed over and rejects one that was not', () => { + const handed = move({ a: 1 }); + expect(() => assertHandedOver('somewhere', handed)).not.toThrow(); + expect(() => assertHandedOver('somewhere', { a: 1 })) + .toThrow('somewhere: value was not handed over — call move() at the assignment site'); + }); + + it('lets primitives through, because only objects can be mutated in place', () => { + expect(() => assertHandedOver('somewhere', 'a string')).not.toThrow(); + expect(() => assertHandedOver('somewhere', null)).not.toThrow(); + expect(() => assertHandedOver('somewhere', 7)).not.toThrow(); + }); + + // `Object.freeze` throws on a buffer view that has elements, so the walk skips typed + // arrays — which is exactly why `Object.isFrozen` cannot be the gate and a `WeakSet` is. + it('registers a typed array without freezing it', () => { + const bytes = move(new Uint8Array([1, 2, 3])); + expect(Object.isFrozen(bytes)).toBe(false); + expect(() => assertHandedOver('body', bytes)).not.toThrow(); + }); + + it('registers a body nested inside a record', () => { + const record = move({ 'request.http.body': new Uint8Array([1, 2, 3]) }); + expect(() => assertHandedOver('body', record['request.http.body'])).not.toThrow(); + }); + + // The other half of why the gate is a `WeakSet`: a shallow builtin freeze satisfies + // `Object.isFrozen` while its children stay mutable, so `isFrozen` would pass a value + // whose interior is still live. + it('does not mistake a shallow freeze for a handover', () => { + const shallow = Object.freeze({ inner: { n: 1 } }); + expect(Object.isFrozen(shallow)).toBe(true); + shallow.inner.n = 2; + expect(shallow.inner.n).toBe(2); + expect(() => assertHandedOver('somewhere', shallow)).toThrow('was not handed over'); + }); + + it('walks a cycle once', () => { + const a: Record = { name: 'a' }; + const b: Record = { name: 'b', a }; + a['b'] = b; + expect(() => move(a)).not.toThrow(); + expect(Object.isFrozen(b)).toBe(true); + }); +}); diff --git a/packages/pipeline/__tests__/failure-context_test.ts b/packages/pipeline/__tests__/failure-context_test.ts new file mode 100644 index 0000000000..dc06e03515 --- /dev/null +++ b/packages/pipeline/__tests__/failure-context_test.ts @@ -0,0 +1,126 @@ +import { expect, test } from 'vitest'; + +import { compose, createRunReader, defer, defineStage, encodeRun, getFailureFacts, move, own, run } from '../src/index.ts'; +import type { Event } from '../src/index.ts'; + +const gate = () => { + let release!: () => void; + const promise = new Promise(resolve => { release = resolve; }); + return { release, promise }; +}; + +test('failed parents remain distinguishable from folded successful returns after a child answers', async () => { + const error = new Error('parent failed'); + const events: Event[] = []; + const child = defineStage, { answer: string }>({ + name: 'child', return: { provides: ['answer'] }, execute: async () => move({ answer: 'upstream' }), + }); + const parent = defineStage, Record, { answer: string }, { answer: string }>({ + name: 'parent', + through: { request: { needs: [], consumes: [], provides: [] }, response: { needs: ['answer'], consumes: [], provides: [] } }, + execute: async (facts, next) => { await next(facts); throw error; }, + }); + await expect(run(compose('failure', [parent, child]), move({}), { dump: event => { events.push(event); } })).rejects.toBe(error); + expect(events.filter(event => event.type === 'stage.leaved').map(event => event.stageId)).toEqual([2]); + expect(events.find(event => event.type === 'stage.failed')).toMatchObject({ stageId: 1, error }); + const encoded = encodeRun(events); + const marker = encoded.find(event => event.type === 'stage.failed')!; + const read = createRunReader(); + for (const event of encoded) read(event); + expect(read(marker)?.error).toMatchObject({ error: { name: 'Error', message: 'parent failed' } }); + expect(getFailureFacts(error)).toEqual({ answer: 'upstream' }); +}); + +test('concurrent runs retain distinct contexts when a shared rejection reaches the second run during first-run cleanup', async () => { + const shared = new Error('shared token refresh failed'); + shared.name = 'AbortError'; + const cleanupStarted = gate(); + const cleanupAllowed = gate(); + const resource = move(own({}, async () => { cleanupStarted.release(); await cleanupAllowed.promise; })); + const events: Event[] = []; + const fail = defineStage<{ candidate: string }, Record>({ + name: 'fail', return: { provides: [] }, execute: async () => { throw shared; }, + }); + const pipeline = compose('sharedFailure', [fail]); + const firstFacts = move({ candidate: 'first', resource }); + const first = run(pipeline, firstFacts, {}).catch((error: unknown) => error); + await cleanupStarted.promise; + const secondFacts = move({ candidate: 'second' }); + const second: unknown = await run(pipeline, secondFacts, { dump: event => { events.push(event); } }).catch((error: unknown) => error); + expect(second).toBeInstanceOf(Error); + expect(second).not.toBe(shared); + expect((second as Error).cause).toBe(shared); + expect((second as Error).name).toBe('AbortError'); + expect((second as Error).message).toBe(shared.message); + expect(getFailureFacts(second)).toBe(secondFacts); + expect(events.find(event => event.type === 'stage.failed')).toMatchObject({ error: shared }); + cleanupAllowed.release(); + const caughtFirst = await first; + expect(caughtFirst).toBe(shared); + expect(getFailureFacts(caughtFirst)).toBe(firstFacts); + expect(getFailureFacts(second)).toBe(secondFacts); +}); + +test('a failed-stage recording rejection preserves the primary exception and its facts', async () => { + const primary = new Error('stage failed'); + const recording = new Error('storage failed'); + const fail = defineStage<{ candidate: string }, Record>({ + name: 'fail', return: { provides: [] }, execute: async () => { throw primary; }, + }); + const facts = move({ candidate: 'actual' }); + const caught: unknown = await run(compose('failure', [fail]), facts, { + dump: async event => { if (event.type === 'stage.failed') throw recording; }, + }).catch((error: unknown) => error); + expect(caught).toBeInstanceOf(AggregateError); + expect((caught as AggregateError).errors).toEqual([primary, recording]); + expect((caught as AggregateError).cause).toBe(primary); + expect(getFailureFacts(caught)).toBe(facts); +}); + +test('late resource failures stay associated with the run whose drain observed them', async () => { + const shared = new Error('shared cancellation'); + const answer = defineStage<{ candidate: string }, { candidate: string }>({ name: 'answer', return: { provides: ['candidate'] }, execute: async facts => facts }); + const pipeline = compose('drainContext', [answer]); + const first = await run(pipeline, move({ candidate: 'A', body: own({}, async () => { throw shared; }) }), {}); + const second = await run(pipeline, move({ candidate: 'B', body: own({}, async () => { throw shared; }) }), {}); + const errorA: unknown = await first.drain().catch((error: unknown) => error); + const errorB: unknown = await second.drain().catch((error: unknown) => error); + expect(errorA).toBe(shared); + expect((errorB as Error).cause).toBe(shared); + expect(getFailureFacts(errorA)).toBe(first.facts); + expect(getFailureFacts(errorB)).toBe(second.facts); +}); + +test.each([{ reason: 'wire broke' }, { reason: 0 }, { reason: false }, { reason: null }, { reason: undefined }])('primitive rejection $reason retains the accepted response facts and original source marker', async ({ reason }) => { + const events: Event[] = []; + const child = defineStage<{ model: string }, { model: string; called: boolean }>({ + name: 'called', return: { provides: ['model', 'called'] }, execute: async facts => move({ ...facts, called: true }), + }); + const parent = defineStage<{ model: string }, { model: string }, { model: string; called: boolean }, { model: string; called: boolean }>({ + name: 'readFiniteBody', + through: { request: { needs: ['model'], consumes: [], provides: [] }, response: { needs: ['called'], consumes: [], provides: [] } }, + execute: async (facts, next) => { await next(facts); throw reason; }, + }); + const caught: unknown = await run(compose('finiteBody', [parent, child]), move({ model: 'wire-model' }), { dump: event => { events.push(event); } }).catch((error: unknown) => error); + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).cause).toBe(reason); + expect(Object.hasOwn(caught as Error, 'cause')).toBe(true); + expect(getFailureFacts(caught)).toMatchObject({ model: 'wire-model', called: true }); + expect(events.find(event => event.type === 'stage.failed')).toMatchObject({ error: reason }); + const encoded = encodeRun(events); + const read = createRunReader(); + const source = encoded.map(read).find(value => value !== null && Object.hasOwn(value, 'error'))!; + expect(source.error).toBe(reason); +}); + +test('primitive deferred rejection retains its exact outcome while drain exposes its fact context', async () => { + const reason = 'background wire broke'; + const events: Event[] = []; + const facts = move({ model: 'actual', background: defer(Promise.reject(reason)) }); + const terminal = defineStage({ name: 'answer', return: { provides: ['model', 'background'] }, execute: async values => values }); + const executed = await run(compose('deferredContext', [terminal]), facts, { dump: event => { events.push(event); } }); + const caught: unknown = await executed.drain().catch((error: unknown) => error); + expect((caught as Error).cause).toBe(reason); + expect(getFailureFacts(caught)).toBe(facts); + expect(events.find(event => event.type === 'deferred.settled')).toMatchObject({ outcome: { status: 'rejected', reason } }); +}); diff --git a/packages/pipeline/__tests__/fixtures.ts b/packages/pipeline/__tests__/fixtures.ts new file mode 100644 index 0000000000..8c2d69d5e8 --- /dev/null +++ b/packages/pipeline/__tests__/fixtures.ts @@ -0,0 +1,172 @@ +// A fact space with no business in it, and the stages that exercise every shape. Kept +// beside the tests because it is what the tests are about: the core has to work for a +// space it has never heard of. + +import { compose, defineStage, move, own, transform } from '../src/index.ts'; +import type { Owned, Pipeline, Slice } from '../src/index.ts'; + +export interface CoreFacts { + 'in.text': string; + 'in.words': readonly string[]; + 'route.candidate': string; + /** Failure is a value. The declaration names this key; which arm is in it is a property + * of the value, not something a stage declares. */ + 'out.result': { readonly ok: string } | { readonly failed: string }; + 'out.body': Owned & { readonly label: string }; +} + +/** A provider extends the space with its own keys; it never merges into the core. */ +export interface ProviderFacts extends CoreFacts { + 'provider.token': string; +} + +export type Core = Slice; + +/** A provider package. `ProviderFacts` is defined here and never leaves: the handle's + * type mentions only the core space. */ +export interface SealedHandle { + readonly name: string; + readonly enter: (facts: Core<'in.words' | 'route.candidate'>) => Promise>; +} + +export const makeProvider = (token: string, released: string[]): SealedHandle => { + const inside: Slice = move({ 'provider.token': token }); + return { + name: 'toy-provider', + enter: async facts => { + const candidate = facts['route.candidate']; + // Every attempt opens a body, the failing one included — which is why a losing + // branch has something to release. + return move({ + 'out.result': candidate === 'flaky' + ? { failed: `${candidate} refused` } + : { ok: `${inside['provider.token']}:${facts['in.words'].join('-')}` }, + 'out.body': own({ label: candidate }, async () => { released.push(`body@${candidate}`); }), + }); + }, + }; +}; + +/** (1) return — it answers, and may not call next. Its `execute` takes one argument. */ +export const callUpstream = (handle: SealedHandle) => + defineStage, Core<'out.result' | 'out.body'>>({ + name: 'callUpstream', + return: { provides: ['out.result', 'out.body'] }, + // Answering hands up the whole record too, so the keys that reached the ending are + // still accounted for on the way back. + execute: async facts => move({ ...facts, ...(await handle.enter(facts)) }), + }); + +/** (2) through + return — a cache: it answers on a hit and goes down on a miss. This is + * the shape a single `kind` discriminator cannot express. */ +export const cache = (canned: Record) => + defineStage, Core<'in.text'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'cache', + through: { + request: { needs: ['in.text'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + return: { provides: ['out.result'] }, + execute: async (facts, next) => { + const hit = canned[facts['in.text']]; + if (hit !== undefined) return move({ ...facts, 'out.result': { ok: hit } }); + return await next(facts); + }, + }); + +/** (3) through — it must go down. Creating and modifying are one return, and removing is + * the same action written the other way. */ +export const splitWords = defineStage, Core<'in.words'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'splitWords', + through: { + request: { needs: ['in.text'], consumes: ['in.text'], provides: ['in.words'] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + execute: transform(() => ({ + request: facts => { + const { 'in.text': text, ...rest } = facts; + return { ...rest, 'in.words': move(text.split(' ')) }; + }, + })), +}); + +/** (3 again) An ordinary `through` stage that happens to call next more than once. + * Nothing in the framework knows what a retry is. */ +export const failover = (candidates: readonly string[]) => + defineStage< + Core<'in.words'>, + Core<'in.words' | 'route.candidate'>, + Core<'out.result' | 'out.body'>, + Core<'out.result' | 'out.body'> + >({ + name: 'failover', + through: { + request: { needs: ['in.words'], consumes: [], provides: ['route.candidate'] }, + // It owns every attempt's body — that is `consumes` — and hands the winner's + // onward, which is `provides`. Ownership of that one goes with it. + response: { needs: ['out.result'], consumes: ['out.body'], provides: ['out.body'] }, + }, + execute: async (facts, next) => { + let last: Core<'out.result' | 'out.body'> | undefined; + for (const candidate of candidates) { + last = await next({ ...facts, 'route.candidate': candidate }); + if ('ok' in last['out.result']) return last; // failure is a value, not a throw + } + if (last === undefined) throw new Error('failover: no candidates'); + return last; + }, + }); + +/** The closure earning its keep: state computed on the way down and used on the way up. + * It cannot live in a fact (it is not one) or on the stage (shared across runs). */ +export const rememberShape = defineStage, Core<'in.words'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'rememberShape', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + execute: transform(() => { + let wordCount = 0; // per invocation, both directions + return { + request: facts => { wordCount = facts['in.words'].length; return facts; }, + response: facts => { + const result = facts['out.result']; + if (!('ok' in result)) return facts; // hand up what came back + return { ...facts, 'out.result': move({ ok: `${result.ok} (${wordCount}w)` }) }; + }, + }; + }), +}); + +type AttemptPipeline = Pipeline, Core<'out.result'>>; + +/** (4) into + return — hands off to a named pipeline, unless there is nothing to do. */ +export const handoffUnlessEmpty = (target: AttemptPipeline) => + defineStage, Core<'in.words'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'handoffUnlessEmpty', + into: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + return: { provides: ['out.result'] }, + execute: async (facts, next) => (facts['in.words'].length === 0 + ? move({ ...facts, 'out.result': { ok: '' } }) + : await next(facts, target)), + }); + +/** (5) into — it must hand off. `next` takes the target; a `through` stage's does not. */ +export const handoff = (target: AttemptPipeline) => + defineStage, Core<'in.words'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'handoff', + into: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + execute: async (facts, next) => await next(facts, target), + }); + +export const attemptPipeline = (handle: SealedHandle, candidates: readonly string[]): AttemptPipeline => + compose, Core<'out.result'>>('attempt', [failover(candidates), callUpstream(handle)]); + +export const servePipeline = (attempt: AttemptPipeline, canned: Record = {}) => + compose, Core<'out.result'>>('serve', [cache(canned), splitWords, rememberShape, handoff(attempt)]); diff --git a/packages/pipeline/__tests__/fixtures/encoder-retention.ts b/packages/pipeline/__tests__/fixtures/encoder-retention.ts new file mode 100644 index 0000000000..f7a68fd3e5 --- /dev/null +++ b/packages/pipeline/__tests__/fixtures/encoder-retention.ts @@ -0,0 +1,23 @@ +import { strictEqual } from 'node:assert'; + +import { createRunEncoder } from '../../src/dump.ts'; + +const encode = createRunEncoder(); +const closedStage = (type: 'stage.leaved' | 'stage.failed', stageId: number): WeakRef => { + const payload = { text: 'finished stage payload' }; + const ref = new WeakRef(payload); + encode({ type: 'stage.entered', stageId, name: 'closed', parentStageId: null, facts: { payload } }); + if (type === 'stage.leaved') encode({ type, stageId, facts: { answer: 42 } }); + else encode({ type, stageId, error: new Error('closed with failure') }); + return ref; +}; +const returned = closedStage('stage.leaved', 1); +const failed = closedStage('stage.failed', 2); +const collect = (globalThis as typeof globalThis & { gc(): void }).gc; +for (let pass = 0; pass < 20; pass++) { + await new Promise(resolve => setImmediate(resolve)); + collect(); +} +strictEqual(returned.deref(), undefined); +strictEqual(failed.deref(), undefined); +encode({ type: 'stage.entered', stageId: 3, name: 'still live', parentStageId: null, facts: {} }); diff --git a/packages/pipeline/__tests__/lifecycle_test.ts b/packages/pipeline/__tests__/lifecycle_test.ts new file mode 100644 index 0000000000..4cf902de4d --- /dev/null +++ b/packages/pipeline/__tests__/lifecycle_test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest'; + +import { compose, defer, defineStage, getFailureFacts, move, own, run, setRelease } from '../src/index.ts'; + +const answer = defineStage, Record>({ + name: 'answer', return: { provides: [] }, execute: async facts => facts, +}); + +describe('run lifetime', () => { + it('retains initial failure facts and releases resources when entry validation fails', async () => { + let released = 0; + const body = move(own({}, async () => { released++; })); + const initial = { invalid: { mutable: true }, body }; + const caught: unknown = await run(compose('invalidInitial', [answer]), initial as never, {}).catch((error: unknown) => error); + expect(caught).toBeInstanceOf(Error); + expect(getFailureFacts(caught)).toBe(initial); + expect(released).toBe(1); + }); + + it('changes the release action without adopting a resource twice', async () => { + let cancelled = 0; + let drained = 0; + const resource = move(own({}, async () => { cancelled++; })); + setRelease(resource, async () => { drained++; }); + await resource[Symbol.asyncDispose](); + await resource[Symbol.asyncDispose](); + expect(cancelled).toBe(0); + expect(drained).toBe(1); + expect(() => setRelease(resource, async () => {})).toThrow('after disposal has started'); + }); + + it('composes the previous release action without recursively awaiting its own disposal', async () => { + const steps: string[] = []; + const resource = move(own({}, async () => { steps.push('drain'); })); + const previous = setRelease(resource, async () => { + try { await previous(); } finally { steps.push('settle'); } + }); + await resource[Symbol.asyncDispose](); + await resource[Symbol.asyncDispose](); + expect(steps).toEqual(['drain', 'settle']); + }); + + it('cleans an initial resource consumed before the first handover', async () => { + let released = 0; + const body = move(own({}, async () => { released++; })); + const consume = defineStage<{ body: typeof body }, Record, Record, Record>({ + name: 'consume', + through: { + request: { needs: ['body'], consumes: ['body'], provides: [] }, + response: { needs: [], consumes: [], provides: [] }, + }, + execute: async (_facts, next) => await next(move({})), + }); + const result = await run(compose('initialResource', [consume, answer]), move({ body }), {}); + await result.drain(); + expect(released).toBe(1); + }); + + it('waits for an initial deferred fact even after it is consumed', async () => { + let finish!: () => void; + const pending = defer(new Promise(resolve => { finish = resolve; })); + const consume = defineStage<{ pending: typeof pending }, Record, Record, Record>({ + name: 'consume', + through: { + request: { needs: ['pending'], consumes: ['pending'], provides: [] }, + response: { needs: [], consumes: [], provides: [] }, + }, + execute: async (_facts, next) => await next(move({})), + }); + const result = await run(compose('initialDeferred', [consume, answer]), move({ pending }), {}); + let drained = false; + const draining = result.drain().then(() => { drained = true; }); + await Promise.resolve(); + expect(drained).toBe(false); + finish(); + await draining; + expect(drained).toBe(true); + }); + + it('shares one disposal between manual release and the runner', async () => { + let releases = 0; + const ending = defineStage, { body: ReturnType }>({ + name: 'ending', return: { provides: ['body'] }, + execute: async () => move({ body: own({}, async () => { releases++; }) }), + }); + const manual = defineStage, Record, { body: ReturnType }, Record>({ + name: 'manual', + through: { + request: { needs: [], consumes: [], provides: [] }, + response: { needs: ['body'], consumes: ['body'], provides: [] }, + }, + execute: async (facts, next) => { + const back = await next(facts); + await back.body[Symbol.asyncDispose](); + return move({}); + }, + }); + const result = await run(compose('manualRelease', [manual, ending]), move({}), {}); + await result.drain(); + expect(releases).toBe(1); + }); + + it('shares the unfinished drain with concurrent callers', async () => { + let finish!: () => void; + const body = move(own({}, () => new Promise(resolve => { finish = resolve; }))); + const result = await run(compose('concurrentDrain', [answer]), move({ body }), {}); + const first = result.drain(); + expect(result.drain()).toBe(first); + finish(); + await first; + }); + + it('preserves a stage failure and cleanup failures while releasing every resource', async () => { + const stageError = new Error('stage failed'); + const cleanupError = new Error('cleanup failed'); + let lastReleased = false; + const ending = defineStage, { bad: ReturnType; good: ReturnType }>({ + name: 'ending', return: { provides: ['bad', 'good'] }, + execute: async () => move({ + bad: own({}, async () => { throw cleanupError; }), + good: own({}, async () => { lastReleased = true; }), + }), + }); + const fail = defineStage, Record, { bad: ReturnType }, Record>({ + name: 'fail', + through: { + request: { needs: [], consumes: [], provides: [] }, + response: { needs: ['bad'], consumes: [], provides: [] }, + }, + execute: async (facts, next) => { await next(facts); throw stageError; }, + }); + const caught = await run(compose('cleanupFailure', [fail, ending]), move({}), {}).catch(error => error as AggregateError); + expect(caught).toBeInstanceOf(AggregateError); + expect((caught as AggregateError).errors).toEqual([stageError, cleanupError]); + expect((caught as AggregateError).cause).toBe(stageError); + expect(lastReleased).toBe(true); + expect(getFailureFacts(caught)).toHaveProperty('bad'); + }); + + it('returns the deepest failure facts with the original error object', async () => { + const error = new Error('failed'); + const ending = defineStage<{ position: number }, Record>({ + name: 'ending', return: { provides: [] }, execute: async () => { throw error; }, + }); + const descend = defineStage, { position: number }, Record, Record>({ + name: 'descend', + through: { + request: { needs: [], consumes: [], provides: ['position'] }, + response: { needs: [], consumes: [], provides: [] }, + }, + execute: async (facts, next) => await next(move({ ...facts, position: 42 })), + }); + await expect(run(compose('faultFacts', [descend, ending]), move({}), {})).rejects.toBe(error); + expect(getFailureFacts(error)).toEqual({ position: 42 }); + }); + + it('captures returned response facts for an error above the upstream call', async () => { + const error = new Error('response rewrite failed'); + const ending = defineStage, { upstream: string; response: string }>({ + name: 'upstream', return: { provides: ['upstream', 'response'] }, + execute: async facts => move({ ...facts, upstream: 'provider-a', response: 'answer' }), + }); + const rewrite = defineStage, Record, { response: string }, Record>({ + name: 'rewrite', + through: { + request: { needs: [], consumes: [], provides: [] }, + response: { needs: ['response'], consumes: [], provides: [] }, + }, + execute: async (facts, next) => { await next(facts); throw error; }, + }); + await expect(run(compose('responseFault', [rewrite, ending]), move({}), {})).rejects.toBe(error); + expect(getFailureFacts(error)).toEqual({ upstream: 'provider-a', response: 'answer' }); + }); + + it('checks declared needs after a stage drops an undeclared fact', async () => { + const drop = defineStage<{ input: string }, Record, Record, Record>({ + name: 'drop', + through: { + request: { needs: ['input'], consumes: [], provides: [] }, + response: { needs: [], consumes: [], provides: [] }, + }, + execute: async (_facts, next) => await next(move({})), + }); + const needs = defineStage<{ input: string }, { input: string }, Record, Record>({ + name: 'needsInput', + through: { + request: { needs: ['input'], consumes: [], provides: [] }, + response: { needs: [], consumes: [], provides: [] }, + }, + execute: async (facts, next) => await next(facts), + }); + await expect(run(compose('missingNeed', [drop, needs, answer]), move({ input: 'hello' }), {})) + .rejects.toThrow('needsInput entering: needs input'); + }); +}); diff --git a/packages/pipeline/__tests__/provider-package.ts b/packages/pipeline/__tests__/provider-package.ts new file mode 100644 index 0000000000..8f50881e8c --- /dev/null +++ b/packages/pipeline/__tests__/provider-package.ts @@ -0,0 +1,69 @@ +// A provider package, standing on its own. It extends the fact space with a key of its +// own, that key *travels* — created by one of its stages and consumed by another before +// the request leaves — and nothing outside this module can name it. +// +// What it exports is a sealed handle: a `Pipeline` whose type mentions only the core +// space. That is the whole cross-package handoff question, and what answers it is +// `compose` taking erased stages, so a pipeline's interior never enters its type. The +// module boundary changes nothing, because the erasure happens at assembly and not at the +// import. + +import type { CoreFacts } from './fixtures.ts'; +import { compose, defineStage, move, own, transform } from '../src/index.ts'; +import type { Pipeline, Slice } from '../src/index.ts'; + +/** Defined here and never exported. A stage written against the core space cannot name it, + * and the compiler says so at the definition site. */ +interface ProviderFacts extends CoreFacts { + 'provider.token': string; +} + +type P = Slice; +type C = Slice; + +/** Mints the credential. It is a fact, so it is in the record and in the dump — and it is + * gone again before the request leaves this subtree. */ +const attachToken = defineStage< + C<'route.candidate'>, + P<'provider.token'>, + C<'out.result' | 'out.body'>, + C<'out.result' | 'out.body'> +>({ + name: 'attachToken', + through: { + request: { needs: ['route.candidate'], consumes: [], provides: ['provider.token'] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + execute: transform(() => ({ + request: facts => ({ ...facts, 'provider.token': `tok-${facts['route.candidate']}` }), + })), +}); + +/** Spends it and drops it, so nothing below this point can reach the credential. */ +const callWithToken = defineStage< + P<'provider.token'> & C<'in.words' | 'route.candidate'>, + C<'out.result' | 'out.body'> +>({ + name: 'callWithToken', + return: { provides: ['out.result', 'out.body'] }, + execute: async facts => { + const { 'provider.token': token, ...rest } = facts; + const candidate = facts['route.candidate']; + return move({ + ...rest, + 'out.result': candidate === 'flaky' + ? { failed: `${candidate} refused` } + : { ok: `${token}:${facts['in.words'].join('-')}` }, + 'out.body': own({ label: candidate }, async () => {}), + }); + }, +}); + +/** + * The seal. Its type mentions only core keys; `provider.token` appears nowhere in it, and + * a caller cannot learn that the key exists — let alone name it. + */ +export const providerChain: Pipeline< + C<'in.words' | 'route.candidate'>, + C<'out.result' | 'out.body'> +> = compose('toy-provider', [attachToken, callWithToken]); diff --git a/packages/pipeline/__tests__/read_test.ts b/packages/pipeline/__tests__/read_test.ts new file mode 100644 index 0000000000..c080e1d0be --- /dev/null +++ b/packages/pipeline/__tests__/read_test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { createRunReader, defer, encodeRun, secret, streamFact } from '../src/index.ts'; +import type { DumpEvent } from '../src/index.ts'; + +const entered = (facts: Record): DumpEvent[] => encodeRun([ + { type: 'stage.entered', stageId: 1, name: 'read', parentStageId: null, facts }, +]); + +describe('run reader', () => { + it('preserves cycles and shared identities across facts and frames', () => { + const shared: Record = { value: 1 }; + const array: unknown[] = [shared]; + shared['array'] = array; + array.push(array); + const read = createRunReader(); + const events = encodeRun([ + { type: 'stage.entered', stageId: 1, name: 'read', parentStageId: null, facts: { a: shared, b: shared, array } }, + { type: 'stream.frame', streamId: 1, frames: [shared] }, + ]); + const first = events.map(read).find(value => value?.facts !== undefined)!.facts!; + const frames = read(events.at(-1)!)!.frames!; + expect(first['a']).toBe(first['b']); + expect((first['a'] as Record)['array']).toBe(first['array']); + expect((first['array'] as unknown[])[1]).toBe(first['array']); + expect(frames[0]).toBe(first['a']); + const event = events.find(value => value.type === 'stage.entered')!; + const ref = event.facts!['a'] as { $: number }; + expect(read.decode(ref)).toBe(first['a']); + expect(read.node(ref.$)).toEqual({ value: 1, array: expect.any(Object) }); + }); + + it('describes collection and Error cycles without recreating live values', () => { + const map = new Map(); + const set = new Set(); + const error = new Error('failed'); + Object.defineProperty(error, 'cause', { value: error }); + map.set(map, set); + set.add(map); + const read = createRunReader(); + const facts = entered({ map, set, error, date: new Date(42), invalidDate: new Date(NaN) }) + .map(read).find(value => value?.facts !== undefined)!.facts!; + const decodedMap = facts['map'] as { map: unknown[][] }; + const decodedSet = facts['set'] as { set: unknown[] }; + const decodedError = facts['error'] as { error: Record }; + expect(decodedMap.map[0]![0]).toBe(decodedMap); + expect(decodedMap.map[0]![1]).toBe(decodedSet); + expect(decodedSet.set[0]).toBe(decodedMap); + expect(decodedError.error['cause']).toBe(decodedError); + expect(decodedError.error).toMatchObject({ name: 'Error', message: 'failed', stack: error.stack }); + expect(facts['date']).toEqual({ date: 42 }); + expect(facts['invalidDate']).toEqual({ date: NaN }); + }); + + it('shares complete byte values and preserves scalar tags and escaped keys', () => { + const bytes = new Uint8Array([1, 2]); + const object = Object.fromEntries([['__proto__', 'ordinary'], ['$schema', 'schema'], ['$$', 'dollars']]); + const read = createRunReader(); + const facts = entered({ bytes, buffer: bytes.slice().buffer, object, absent: undefined, nan: NaN, inf: Infinity, negative: -Infinity, big: 42n, secret: secret('credential'), stream: streamFact(7), raw: new ReadableStream() }) + .map(read).find(value => value?.facts !== undefined)!.facts!; + expect(facts['buffer']).toBe(facts['bytes']); + expect(facts['bytes']).toEqual({ bytes: 'AQI=' }); + expect(facts['object']).toEqual(object); + expect(Object.getPrototypeOf(facts['object'])).toBe(Object.prototype); + expect(facts).toMatchObject({ absent: undefined, nan: NaN, inf: Infinity, negative: -Infinity, big: 42n, stream: { stream: 7 }, raw: { readableStream: true } }); + expect(facts['secret']).toMatchObject({ redacted: '**********', length: 10 }); + }); + + it('associates settlement with the original deferred handle', async () => { + const deferred = defer(Promise.resolve({ answer: 42 })); + const outcome = { status: 'fulfilled', value: await deferred } as const; + const events = encodeRun([ + { type: 'stage.entered', stageId: 1, name: 'read', parentStageId: null, facts: { result: deferred } }, + { type: 'deferred.settled', deferred, outcome }, + ]); + const read = createRunReader(); + let handle: unknown; + for (const event of events) { + const value = read(event); + if (value?.facts) handle = value.facts['result']; + if (value?.outcome) expect(value.deferred).toBe(handle); + } + const stage = events.find(value => value.type === 'stage.entered')!; + const id = (stage.facts!['result'] as { $: number }).$; + expect(read.settlement(id)).toEqual(outcome); + expect(read.settlement(id + 100)).toBeUndefined(); + }); + + it('rejects absent references while folding omitted state without a replacement', () => { + const read = createRunReader(); + expect(() => read.decode({ $: 9 })).toThrow('no event carried'); + expect(read({ type: 'stage.entered', stageId: 1, name: 'folded', parentStageId: null })).toEqual({}); + expect(read({ type: 'stream.end', streamId: 1 })).toEqual({}); + }); +}); diff --git a/packages/pipeline/__tests__/run_test.ts b/packages/pipeline/__tests__/run_test.ts new file mode 100644 index 0000000000..c00e396066 --- /dev/null +++ b/packages/pipeline/__tests__/run_test.ts @@ -0,0 +1,666 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + attemptPipeline, cache, callUpstream, failover, handoff, handoffUnlessEmpty, + makeProvider, rememberShape, servePipeline, splitWords, +} from './fixtures.ts'; +import type { Core } from './fixtures.ts'; +import { compose, defineStage, isOwned, move, own, run, transform } from '../src/index.ts'; +import type { Descend, Event, Facts } from '../src/index.ts'; + +/** No dump sink, so nothing records — which is the ordinary case. */ +const PLAIN = {}; + +/** With a sink, so the run's events are delivered as they happen. */ +const recorded = () => { + const seen: Event[] = []; + return { services: { dump: (event: Event) => { seen.push(event); } }, seen }; +}; + +const edges = (events: readonly Event[]): string[] => { + const names = new Map(); + const out: string[] = []; + for (const event of events) { + if (event.type !== 'stage.entered') continue; + names.set(event.stageId, event.name); + const parent = event.parentStageId === null ? undefined : names.get(event.parentStageId); + if (parent !== undefined) out.push(`${parent} → ${event.name}`); + } + return out; +}; + +const body = (label: string, released: string[]) => + move(own({ label }, async () => { released.push(label); })); + +describe('run', () => { + it('carries a request through every stage shape and answers', async () => { + const serve = servePipeline(attemptPipeline(makeProvider('tok', []), ['flaky', 'steady'])); + const { facts } = await run(serve, move({ 'in.text': 'hello brave world' }), PLAIN); + expect(facts['out.result']).toEqual({ ok: 'tok:hello-brave-world (3w)' }); + }); + + it('lets a stage answer without descending', async () => { + const { services, seen } = recorded(); + const serve = servePipeline(attemptPipeline(makeProvider('tok', []), ['steady']), { 'known phrase': 'from cache' }); + const { facts } = await run(serve, move({ 'in.text': 'known phrase' }), services); + expect(facts['out.result']).toEqual({ ok: 'from cache' }); + expect(edges(seen)).toEqual([]); // nothing below the cache was entered + }); + + // A fork is not one event repeating; it is several children naming the same parent. + it('records a fork as repeated children of one parent', async () => { + const { services, seen } = recorded(); + const attempt = attemptPipeline(makeProvider('tok', []), ['flaky', 'steady']); + await run(attempt, move({ 'in.words': ['a'] }), services); + expect(edges(seen)).toEqual(['failover → callUpstream', 'failover → callUpstream']); + const entries = seen.filter(e => e.type === 'stage.entered'); + expect(entries.filter(e => e.name === 'failover')).toHaveLength(1); + expect(new Set(entries.map(e => e.stageId)).size).toBe(entries.length); + }); + + it('delivers each event to the sink as it happens, not in one batch at the end', async () => { + const order: string[] = []; + const answer = defineStage, Record>({ + name: 'answer', return: { provides: [] }, + execute: async facts => { + expect(order).toEqual(['stage.entered']); + return facts; + }, + }); + const result = await run(compose('liveEvents', [answer]), move({}), { + dump: (event: Event) => { order.push(event.type); }, + }); + expect(order).toEqual(['stage.entered', 'stage.leaved']); + expect(result).not.toHaveProperty('events'); + }); + + it('records nothing at all when the prologue resolved no dump sink', async () => { + const attempt = attemptPipeline(makeProvider('tok', []), ['steady']); + const result = await run(attempt, move({ 'in.words': ['a'] }), PLAIN); + expect(result).not.toHaveProperty('events'); + }); +}); + +describe('what the run owns', () => { + it('releases the branch a fork did not adopt, and leaves the winner for the drain', async () => { + const released: string[] = []; + const attempt = attemptPipeline(makeProvider('tok', released), ['flaky', 'steady']); + const { drain } = await run(attempt, move({ 'in.words': ['a'] }), PLAIN); + expect(released).toEqual(['body@flaky']); // the loser, at the fork + await drain(); + expect(released).toEqual(['body@flaky', 'body@steady']); + }); + + // Awaiting the drain before answering would eat a streaming family's frames and block + // the handler for as long as the upstream keeps writing. + it('answers before draining, so a live stream can be handed back', async () => { + const released: string[] = []; + let drained = false; + const slow = move(own({}, async () => { + await new Promise(resolve => setTimeout(resolve, 30)); + drained = true; + released.push('slow'); + })); + const opens = defineStage({ + name: 'opens', + return: { provides: ['out.body'] }, + execute: async facts => move({ ...facts, 'out.body': slow }), + }); + const pipeline = compose('opens', [opens]); + const { drain } = await run(pipeline, move({}), PLAIN); + expect(drained).toBe(false); // the answer came back first + await drain(); + expect(released).toEqual(['slow']); + }); + + // The case the guarantee exists for: a body opened below a throw must not be abandoned, + // because an aborted connection cannot be reused and leaves its billing unsettled. + it('releases a body opened below a stage that threw', async () => { + const released: string[] = []; + const thrower = defineStage, Core<'in.words'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'thrower', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + execute: async (facts, next) => { + await next(facts); + throw new Error('a bug, rendered as a 500'); + }, + }); + const pipeline = compose, Core<'out.result'>>('boom', [ + thrower, callUpstream(makeProvider('tok', released)), + ]); + await expect(run(pipeline, move({ 'in.words': ['a'], 'route.candidate': 'steady' }), PLAIN)) + .rejects.toThrow('a bug, rendered as a 500'); + expect(released).toEqual(['body@steady']); + }); + + it('keeps the dump of a run that threw', async () => { + const { services, seen } = recorded(); + const thrower = defineStage, Core<'out.result'>>({ + name: 'thrower', + return: { provides: ['out.result'] }, + execute: async () => { throw new Error('a bug'); }, + }); + const pipeline = compose, Core<'out.result'>>('lost', [thrower]); + await expect(run(pipeline, move({ 'in.words': ['a'] }), services)).rejects.toThrow('a bug'); + expect(seen.map(e => e.type)).toEqual(['stage.entered', 'stage.failed']); + }); + + // A key the stage declared it consumes is one it took ownership of. Ownership is a + // declaration, not an arity, so descending once claims it as surely as forking does. + it('releases a consumed body even when the stage descended exactly once', async () => { + const released: string[] = []; + const swallows = defineStage, Core<'in.words'>, Core<'out.result' | 'out.body'>, Core<'out.result'>>({ + name: 'swallows', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: ['out.body'], provides: [] }, + }, + execute: async (facts, next) => { + const { 'out.body': gone, ...rest } = await next(facts); + void gone; + return rest; + }, + }); + const pipeline = compose, Core<'out.result'>>('owned', [ + swallows, callUpstream(makeProvider('tok', released)), + ]); + const { drain } = await run(pipeline, move({ 'in.words': ['a'], 'route.candidate': 'steady' }), PLAIN); + expect(released).toEqual(['body@steady']); + await drain(); + expect(released).toEqual(['body@steady']); // released once, not twice + }); + + // Identity, not the key: a stage that hands a body up under a different name must not + // have it drained out from under whoever is reading it. + it('does not release a body the stage handed up under another key', async () => { + const released: string[] = []; + const kept = body('kept', released); + const opens = defineStage, { 'out.body': unknown; 'out.result': unknown }>({ + name: 'opens', + return: { provides: ['out.body', 'out.result'] }, + execute: async facts => move({ ...facts, 'out.body': kept, 'out.result': { ok: 'x' } }), + }); + const renames = defineStage< + Core<'in.words'>, Core<'in.words'>, + { 'out.body': unknown; 'out.result': unknown }, { 'out.kept': unknown; 'out.result': unknown } + >({ + name: 'renames', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: ['out.body'], provides: ['out.kept'] }, + }, + execute: async (facts, next) => { + const { 'out.body': moved, ...rest } = await next(facts); + return { ...rest, 'out.kept': moved }; + }, + }); + const pipeline = compose, { 'out.result': unknown }>('rename', [renames, opens]); + const { drain } = await run(pipeline, move({ 'in.words': ['a'] }), PLAIN); + expect(released).toEqual([]); // still live: somebody above holds it + await drain(); + expect(released).toEqual(['kept']); + }); + + // Ownership is claimed, never sniffed. `Symbol.asyncDispose` is the language's own release + // mechanism handed out on the language's own terms, and those terms are not stable across + // the hosts this runs on: an async generator carries it on Node 24 and not on Node 22, and + // a `ReadableStream` — which is what a body actually is — carries it on neither. So what is + // asserted here is the run's answer, which is the same on every host. Sniffing would adopt + // a transducer's own iterator as a run resource and release it the moment its stage handed + // up, while missing the upstream body the rule exists for. + it('does not adopt an async generator, whatever the host says about disposing one', async () => { + const frames = (async function* () { yield 1; yield 2; })(); + expect(isOwned(frames)).toBe(false); + + const opens = defineStage({ + name: 'opens', + return: { provides: ['out.frames'] }, + execute: async facts => move({ ...facts, 'out.frames': frames }), + }); + const pipeline = compose('frames', [opens]); + const { facts, drain } = await run(pipeline, move({}), PLAIN); + await drain(); + // Still readable: nothing released it out from under whoever holds it. + expect(await (facts['out.frames'] as AsyncGenerator).next()).toEqual({ value: 1, done: false }); + }); + + it('does adopt a ReadableStream, which no host marks at all', async () => { + const released: string[] = []; + const stream = new ReadableStream(); + const body = move(own(stream, async () => { released.push('drained'); })); + const opens = defineStage({ + name: 'opens', + return: { provides: ['out.body'] }, + execute: async facts => move({ ...facts, 'out.body': body }), + }); + const pipeline = compose('body', [opens]); + const { drain } = await run(pipeline, move({}), PLAIN); + await drain(); + expect(released).toEqual(['drained']); + }); + + it('passes an owned value nobody declared straight through to the run', async () => { + const released: string[] = []; + const undeclared = body('undeclared', released); + const opens = defineStage({ + name: 'opens', + return: { provides: ['out.body'] }, + execute: async facts => move({ ...facts, 'out.body': undeclared }), + }); + const pipeline = compose('rides', [opens]); + const { drain } = await run(pipeline, move({}), PLAIN); + expect(released).toEqual([]); + await drain(); + expect(released).toEqual(['undeclared']); + }); +}); + +describe('what the runner checks', () => { + it('holds a stage to its `consumes` on the way down', async () => { + const forgetful = { + ...splitWords, name: 'forgetful', + execute: async (facts: Facts, next: Descend) => await next({ ...facts, 'in.words': move(['x']) }), + }; + const pipeline = compose, Core<'out.result'>>('kept', [forgetful, callUpstream(makeProvider('tok', []))]); + await expect(run(pipeline, move({ 'in.text': 'a b', 'route.candidate': 'steady' }), PLAIN)) + .rejects.toThrow('forgetful: declared consuming in.text but handed it down'); + }); + + it('holds a stage to its `consumes` on the way back', async () => { + const absentminded = { + ...rememberShape, name: 'absentminded', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: ['out.result'], provides: [] }, + }, + }; + const pipeline = compose, Core<'out.result'>>('kept-up', [ + absentminded, callUpstream(makeProvider('tok', [])), + ]); + await expect(run(pipeline, move({ 'in.words': ['a'], 'route.candidate': 'steady' }), PLAIN)) + .rejects.toThrow('absentminded: declared consuming out.result but handed it up'); + }); + + it('holds a stage to its `provides` on the way down', async () => { + const promises = { + ...splitWords, name: 'promises', + execute: async (facts: Facts, next: Descend) => { + const { 'in.text': gone, ...rest } = facts; + void gone; + return await next(rest); + }, + }; + const pipeline = compose, Core<'out.result'>>('promised', [promises, callUpstream(makeProvider('tok', []))]); + await expect(run(pipeline, move({ 'in.text': 'a b', 'route.candidate': 'steady' }), PLAIN)) + .rejects.toThrow('promises: declared providing in.words but did not'); + }); + + it('holds a stage to its `provides` on the way back', async () => { + const forgets = defineStage, Core<'in.words'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'forgets', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: ['out.result'], provides: ['out.summary'] as never }, + }, + execute: async (facts, next) => { + const { 'out.result': gone, ...rest } = await next(facts); + void gone; + return rest as never; + }, + }); + const pipeline = compose, Core<'out.result'>>('forgot', [ + forgets, callUpstream(makeProvider('tok', [])), + ]); + await expect(run(pipeline, move({ 'in.words': ['a'], 'route.candidate': 'steady' }), PLAIN)) + .rejects.toThrow('forgets: declared providing out.summary but did not'); + }); + + it('holds an answering stage to its `provides`', async () => { + const silent = defineStage, Core<'out.result'>>({ + name: 'silent', + return: { provides: ['out.result'] }, + execute: async facts => facts as never, + }); + const pipeline = compose, Core<'out.result'>>('silent', [silent]); + await expect(run(pipeline, move({ 'in.words': ['a'] }), PLAIN)) + .rejects.toThrow('silent: declared providing out.result but did not'); + }); + + it('holds a fork to declaring the releasables it received', async () => { + const sloppy = { + ...failover(['flaky', 'steady']), name: 'sloppyFailover', + through: { + request: { needs: ['in.words'], consumes: [], provides: ['route.candidate'] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + }; + const pipeline = compose, Core<'out.result'>>('sloppy', [ + sloppy, callUpstream(makeProvider('tok', [])), + ]); + await expect(run(pipeline, move({ 'in.words': ['x'] }), PLAIN)) + .rejects.toThrow('sloppyFailover: called next 2 times and received releasables it did not declare consuming: out.body'); + }); + + it('rejects a value the prologue never handed over', async () => { + const serve = servePipeline(attemptPipeline(makeProvider('tok', []), ['steady'])); + await expect(run(serve, { 'in.text': 'x', leaked: { mutable: true } } as never, PLAIN)) + .rejects.toThrow('prologue leaked: value was not handed over — call move() at the assignment site'); + }); + + it('rejects a value a stage never handed over, naming the stage and the key', async () => { + const leaks = { + ...splitWords, name: 'leaks', + execute: async (facts: Facts, next: Descend) => { + const { 'in.text': gone, ...rest } = facts; + void gone; + return await next({ ...rest, 'in.words': ['x'] }); // no move() + }, + }; + const pipeline = compose, Core<'out.result'>>('leaky', [leaks, callUpstream(makeProvider('tok', []))]); + await expect(run(pipeline, move({ 'in.text': 'a b', 'route.candidate': 'steady' }), PLAIN)) + .rejects.toThrow('leaks handing down in.words: value was not handed over'); + }); + + it('rejects a value a stage never handed over on the way back', async () => { + const leaksUp = defineStage, Core<'in.words'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'leaksUp', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: ['out.result'] }, + }, + execute: async (facts, next) => ({ ...await next(facts), 'out.result': { ok: 'unmoved' } }), + }); + const pipeline = compose, Core<'out.result'>>('leaky-up', [ + leaksUp, callUpstream(makeProvider('tok', [])), + ]); + await expect(run(pipeline, move({ 'in.words': ['a'], 'route.candidate': 'steady' }), PLAIN)) + .rejects.toThrow('leaksUp handing up out.result: value was not handed over'); + }); + + // Ruling 23's accepted cost, made into a check rather than a hope. + it('names the entry key the caller did not bring', async () => { + const serve = servePipeline(attemptPipeline(makeProvider('tok', []), ['steady'])); + await expect(run(serve, move({}) as never, PLAIN)) + .rejects.toThrow('run(serve): serve needs in.text, which it was not given'); + }); + + it('names both the caller and the target when a handoff falls short', async () => { + const target = compose, Core<'out.result'>>('needsText', [ + cache({ x: 'y' }), splitWords, callUpstream(makeProvider('tok', [])), + ]); + const badHandoff = defineStage, Core<'in.text'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'badHandoff', + into: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + execute: async (facts, next) => await next(facts as never, target), + }); + const pipeline = compose, Core<'out.result'>>('handoff-gap', [badHandoff]); + await expect(run(pipeline, move({ 'in.words': ['a'] }), PLAIN)) + .rejects.toThrow('badHandoff handing off: needsText needs in.text, which it was not given'); + }); + + // The record a stage builds with a spread is a fresh object that `move` never touched — + // so this is the freeze the runner itself applies, and it is what lets the dump treat + // "the same object" as "this side changed nothing". + it('freezes a record a stage built, so a recorded state cannot be rewritten', async () => { + const { services, seen } = recorded(); + const serve = servePipeline(attemptPipeline(makeProvider('tok', []), ['steady'])); + await run(serve, move({ 'in.text': 'a b' }), services); + const built = seen.filter(e => e.type === 'stage.entered') + .map(e => e.facts) + .find(facts => 'in.words' in facts)!; + expect(Object.isFrozen(built)).toBe(true); + expect(() => { (built as Record)['in.words'] = ['rewritten']; }).toThrow(TypeError); + }); + + it('freezes the record the prologue built', async () => { + const initial = move({ 'in.words': ['a'] }); + const attempt = attemptPipeline(makeProvider('tok', []), ['steady']); + await run(attempt, initial, PLAIN); + expect(Object.isFrozen(initial)).toBe(true); + }); + + it('hands the same record on by identity when a stage changed nothing', async () => { + const { services, seen } = recorded(); + const unchanged = defineStage, Core<'in.words'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'unchanged', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + execute: transform(() => ({})), + }); + const pipeline = compose, Core<'out.result'>>('identity', [ + unchanged, callUpstream(makeProvider('tok', [])), + ]); + await run(pipeline, move({ 'in.words': ['a'], 'route.candidate': 'steady' }), services); + const entries = seen.filter(e => e.type === 'stage.entered'); + expect(entries[1]!.facts).toBe(entries[0]!.facts); + }); +}); + +describe('what a stage is given', () => { + it('gives an answering stage its services where a continuation would be', async () => { + let second: unknown; + const answering = defineStage, Core<'out.result'>>({ + name: 'answering', + return: { provides: ['out.result'] }, + execute: async (facts, use) => { + second = use; + return move({ ...facts, 'out.result': { ok: 'answered' } }); + }, + }); + const pipeline = compose, Core<'out.result'>>('answering', [answering]); + await run(pipeline, move({ 'in.words': ['a'] }), PLAIN); + expect(second).toHaveProperty('log'); + }); + + it('gives each stage its own logger and records what it wrote', async () => { + const sink = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const seen: Event[] = []; + const talkative = defineStage, Core<'out.result'>>({ + name: 'talkative', + return: { provides: ['out.result'] }, + execute: async (facts, use) => { + await use.log.info('answering', { words: 1 }); + return move({ ...facts, 'out.result': { ok: 'said' } }); + }, + }); + const pipeline = compose, Core<'out.result'>>('talk', [talkative]); + await run(pipeline, move({ 'in.words': ['a'] }), { log: sink, dump: (e: Event) => { seen.push(e); } }); + expect(sink.info).toHaveBeenCalledWith('answering', { stage: 'talkative', words: 1 }); + const logged = seen.filter(e => e.type === 'stage.log'); + expect(logged).toHaveLength(1); + expect(logged[0]).toMatchObject({ level: 'info', message: 'answering', fields: { words: 1 } }); + }); + + // The line names the stage that wrote it, so a reader holding only the log lines can tell + // them apart. `stageId` answers that too, but only for someone holding the rest of the run. + it('carries the stage name as the entry context', async () => { + const seen: Event[] = []; + const talkative = defineStage, Core<'out.result'>>({ + name: 'talkative', + return: { provides: ['out.result'] }, + execute: async (facts, use) => { + await use.log.info('answering'); + return move({ ...facts, 'out.result': { ok: 'said' } }); + }, + }); + const pipeline = compose, Core<'out.result'>>('talk', [talkative]); + await run(pipeline, move({ 'in.words': ['a'] }), { dump: (e: Event) => { seen.push(e); } }); + expect(seen.find(e => e.type === 'stage.log')).toMatchObject({ context: 'talkative' }); + }); + + // A line refers to a stage by id, so it can only be read once that id has been introduced. + // Entering a stage is what mints it, and the entry event goes out before the stage body + // runs — so a log line can never arrive before the event that explains which stage it is. + it('emits a log line only after the event that introduced its stage', async () => { + const seen: Event[] = []; + const talkative = defineStage, Core<'in.words'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'talkative', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + execute: async (facts, next, use) => { + await use.log.info('on the way down'); + const back = await next(facts); + await use.log.info('on the way back'); + return back; + }, + }); + const answers = defineStage, Core<'out.result'>>({ + name: 'answers', + return: { provides: ['out.result'] }, + execute: async (facts, use) => { + await use.log.info('answering'); + return move({ ...facts, 'out.result': { ok: 'said' } }); + }, + }); + const pipeline = compose, Core<'out.result'>>('talk', [talkative, answers]); + await run(pipeline, move({ 'in.words': ['a'] }), { dump: (e: Event) => { seen.push(e); } }); + + const introduced = new Set(); + for (const event of seen) { + if (event.type === 'stage.entered') introduced.add(event.stageId); + if (event.type === 'stage.log') expect(introduced.has(event.stageId)).toBe(true); + } + // Both stages logged, and one of them on both sides of its descent. + expect(seen.filter(e => e.type === 'stage.log')).toHaveLength(3); + }); + + // A stored line has to be a state that existed, for the same reason the record is frozen. + it('snapshots a log line, so a later write by the caller cannot drift into it', async () => { + const seen: Event[] = []; + const fields: Record = { attempt: 1 }; + const talkative = defineStage, Core<'out.result'>>({ + name: 'talkative', + return: { provides: ['out.result'] }, + execute: async (facts, use) => { + await use.log.warn('slow', fields); + fields['attempt'] = 2; + return move({ ...facts, 'out.result': { ok: 'said' } }); + }, + }); + const pipeline = compose, Core<'out.result'>>('talk', [talkative]); + await run(pipeline, move({ 'in.words': ['a'] }), { dump: (e: Event) => { seen.push(e); } }); + expect(seen.find(e => e.type === 'stage.log')).toMatchObject({ fields: { attempt: 1 } }); + }); + + it('passes the composition-owned services through to every stage', async () => { + const seen: string[] = []; + const reads = defineStage, Core<'out.result'>, { clock: () => number }>({ + name: 'reads', + return: { provides: ['out.result'] }, + execute: async (facts, use) => { + seen.push(String(use.clock())); + return move({ ...facts, 'out.result': { ok: 'read' } }); + }, + }); + const pipeline = compose, Core<'out.result'>>('svc', [reads]); + await run(pipeline, move({ 'in.words': ['a'] }), { clock: () => 42 }); + expect(seen).toEqual(['42']); + }); +}); + +describe('runs beside each other', () => { + // A module-level run context saved and restored around an `await` interleaves two + // concurrent runs. A gateway serves concurrent requests by definition. + it('keeps two concurrent runs apart', async () => { + const serve = () => servePipeline(attemptPipeline(makeProvider('tok', []), ['steady'])); + const a = recorded(); + const b = recorded(); + await Promise.all([ + run(serve(), move({ 'in.text': 'a a a' }), a.services), + run(serve(), move({ 'in.text': 'b b' }), b.services), + ]); + for (const { seen } of [a, b]) { + const entries = seen.filter(e => e.type === 'stage.entered'); + expect(entries.length).toBeGreaterThan(0); + expect(new Set(entries.map(e => e.stageId)).size).toBe(entries.length); + expect(entries[0]!.parentStageId).toBeNull(); + } + expect(a.seen).not.toBe(b.seen); + }); + + // Two moments, two owners: a sub-request is `run`, not `next`, and it needs no capability. + it('lets a stage start a sub-request, whose events belong to its own run', async () => { + const { services, seen } = recorded(); + const inner = compose, Core<'out.result'>>('inner', [callUpstream(makeProvider('sub', []))]); + const launcher = defineStage, Core<'in.words'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'launcher', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: ['out.result'] }, + }, + execute: async (facts, next) => { + const side = await run(inner, move({ 'in.words': ['side'], 'route.candidate': 'steady' }), {}); + const back = await next(facts); + const ours = back['out.result']; + const theirs = side.facts['out.result']; + return { ...back, 'out.result': move({ ok: `${'ok' in ours ? ours.ok : ''}+${'ok' in theirs ? theirs.ok : ''}` }) }; + }, + }); + const outer = compose, Core<'out.result'>>('outer', [ + launcher, failover(['steady']), callUpstream(makeProvider('main', [])), + ]); + const { facts } = await run(outer, move({ 'in.words': ['a'] }), services); + expect(facts['out.result']).toEqual({ ok: 'main:a+sub:side' }); + expect(seen.filter(e => e.type === 'stage.entered').map(e => e.name)) + .toEqual(['launcher', 'failover', 'callUpstream']); + }); + + // What a stage provides when it short-circuits and what it provides when it descends are + // two statements, not one. A resolver that refuses a request it found no upstream for + // answers with a key its descend path never carries, and that has to be expressible. + it('lets a stage answer with a key its descend path never carries', async () => { + const refuses = defineStage< + Core<'in.words'>, Core<'in.words'>, Core<'out.result'>, Core<'out.result'>, + Core<'out.result'> & { 'out.refusal': string } + >({ + name: 'refuses', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + return: { provides: ['out.result', 'out.refusal'] }, + execute: async (facts, next) => (facts['in.words'].length === 0 + ? move({ ...facts, 'out.result': { failed: 'nothing to do' }, 'out.refusal': 'empty input' }) + : await next(facts)), + }); + const pipeline = compose, Core<'out.result'>>('refusing', [ + refuses, callUpstream(makeProvider('tok', [])), + ]); + const refused = await run(pipeline, move({ 'in.words': [] }), PLAIN); + expect(refused.facts).toMatchObject({ 'out.refusal': 'empty input' }); + const served = await run(pipeline, move({ 'in.words': ['a'], 'route.candidate': 'steady' }), PLAIN); + expect(served.facts['out.result']).toEqual({ ok: 'tok:a' }); + expect('out.refusal' in served.facts).toBe(false); + }); + + it('runs a stage carrying both traits, one way each time', async () => { + const attempt = attemptPipeline(makeProvider('tok', []), ['steady']); + const short = compose, Core<'out.result'>>('short', [handoffUnlessEmpty(attempt)]); + const long = compose, Core<'out.result'>>('long', [handoffUnlessEmpty(attempt)]); + expect((await run(short, move({ 'in.words': [] }), PLAIN)).facts['out.result']).toEqual({ ok: '' }); + expect((await run(long, move({ 'in.words': ['a', 'b'] }), PLAIN)).facts['out.result']).toEqual({ ok: 'tok:a-b' }); + }); + + it('reaches a provider through a sealed handle whose type mentions only the core space', async () => { + const inside = compose, Core<'out.result'>>('inside-package', [ + splitWords, handoff(attemptPipeline(makeProvider('tok', []), ['steady'])), + ]); + expect((await run(inside, move({ 'in.text': 'a b' }), PLAIN)).facts['out.result']).toEqual({ ok: 'tok:a-b' }); + }); + + it('treats a failure as a value the stage above reads, not as a throw', async () => { + const attempt = attemptPipeline(makeProvider('tok', []), ['flaky']); + const { facts } = await run(attempt, move({ 'in.words': ['a'] }), PLAIN); + expect(facts['out.result']).toEqual({ failed: 'flaky refused' }); + }); +}); diff --git a/packages/pipeline/__tests__/sharing_test.ts b/packages/pipeline/__tests__/sharing_test.ts new file mode 100644 index 0000000000..8d9c4b06c5 --- /dev/null +++ b/packages/pipeline/__tests__/sharing_test.ts @@ -0,0 +1,104 @@ +// The only thermometer for "exact recording is affordable". Nothing else in the system +// guards it: a stage rewritten to rebuild unconditionally drops this number to near zero +// while every other test still passes. + +import { describe, expect, it } from 'vitest'; + +import { defineStage, move, transform } from '../src/index.ts'; +import type { Handed } from '../src/index.ts'; + +interface Message { readonly role: string; readonly content: string } +type Payload = { readonly messages: readonly Message[] }; +type Facts = { readonly 'request.chat.openaiChatCompletions': Payload }; + +const conversation = (turns: number): Payload => move({ + messages: [ + { role: 'system', content: 'You are a pirate.' }, + ...Array.from({ length: turns }, (_, i) => ({ role: i % 2 === 0 ? 'user' : 'assistant', content: `turn ${i}` })), + ], +}); + +const nodes = (value: unknown): number => + typeof value !== 'object' || value === null ? 0 : 1 + Object.values(value).reduce((n, child) => n + nodes(child), 0); + +const sharedWith = (before: unknown, after: unknown): number => { + if (typeof after !== 'object' || after === null) return 0; + if (before === after) return nodes(after); + if (typeof before !== 'object' || before === null) return 0; + return Object.entries(after).reduce( + (n, [key, value]) => n + sharedWith((before as Record)[key], value), 0, + ); +}; + +/** The convention: write conditionally, and what the stage did not change comes back by + * identity. Both forms behave identically and neither errors — the number is the only + * thing that watches the difference. */ +const careful = defineStage({ + name: 'careful', + through: { + request: { needs: ['request.chat.openaiChatCompletions'], consumes: [], provides: ['request.chat.openaiChatCompletions'] }, + response: { needs: [], consumes: [], provides: [] }, + }, + execute: transform(() => ({ + request: (facts): Handed => { + const payload = facts['request.chat.openaiChatCompletions']; + return { + ...facts, + 'request.chat.openaiChatCompletions': move({ + ...payload, + messages: payload.messages.map(m => (m.role === 'system' ? { ...m, role: 'developer' } : m)), + }), + }; + }, + })), +}); + +const careless = (payload: Payload): Payload => move({ + ...payload, + messages: payload.messages.map(m => ({ ...m, role: m.role === 'system' ? 'developer' : m.role })), +}); + +describe('structural sharing', () => { + it('costs three objects on a 49-message conversation with one message rewritten', () => { + const before = conversation(48); + const rewritten = move({ + ...before, + messages: before.messages.map(m => (m.role === 'system' ? { ...m, role: 'developer' } : m)), + }); + expect(nodes(rewritten)).toBe(51); + expect(sharedWith(before, rewritten)).toBe(48); + }); + + it('falls to nothing when a stage rebuilds unconditionally', () => { + const before = conversation(48); + expect(sharedWith(before, careless(before))).toBe(0); + }); + + it('holds its ratio as the conversation grows, because the cost is what changed', () => { + for (const turns of [8, 48, 200]) { + const before = conversation(turns); + const rewritten = move({ + ...before, + messages: before.messages.map(m => (m.role === 'system' ? { ...m, role: 'developer' } : m)), + }); + // Whatever the length: the payload, the array, and the one message that changed. + expect(nodes(rewritten) - sharedWith(before, rewritten)).toBe(3); + } + }); + + it('is what a real stage produces, not just what a hand-written map produces', () => { + const before = conversation(48); + let handed: Facts | undefined; + const erased = careful as unknown as { + execute: (facts: Facts, next: (h: Facts) => Promise, use: object) => Promise; + }; + return erased.execute( + { 'request.chat.openaiChatCompletions': before }, + async h => { handed = h; return {}; }, + { log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} } }, + ).then(() => { + const after = handed!['request.chat.openaiChatCompletions']; + expect(nodes(after) - sharedWith(before, after)).toBe(3); + }); + }); +}); diff --git a/packages/pipeline/__tests__/types_test.ts b/packages/pipeline/__tests__/types_test.ts new file mode 100644 index 0000000000..be29845406 --- /dev/null +++ b/packages/pipeline/__tests__/types_test.ts @@ -0,0 +1,65 @@ +// Two assertions the compiler holds, checked by compiling. `@ts-expect-error` fails the +// build when the error it names does *not* occur, so these are real tests that happen to +// run at typecheck time rather than at vitest time. + +import { describe, expect, it } from 'vitest'; + +import type { CoreFacts } from './fixtures.ts'; +import { decodeKey, defineStage, encodeKey } from '../src/index.ts'; +import type { IntoNext, Pipeline, Slice } from '../src/index.ts'; + +type Core = Slice; + +// A `through` stage's `next` takes one argument, so naming a target is an error at the +// definition site — which is the one half of "into must be last" that a type can hold. +void defineStage, Core<'in.words'>, Core<'out.result'>, Core<'out.result'>>({ + name: 'throughCannotNameATarget', + through: { + request: { needs: ['in.words'], consumes: [], provides: [] }, + response: { needs: ['out.result'], consumes: [], provides: [] }, + }, + execute: async (facts, next) => await next( + facts, + // @ts-expect-error a `through` stage's next takes one argument + null as unknown as Pipeline, Core<'out.result'>>, + ), +}); + +// A provider's own key is unreachable from a pipeline over the gateway's space: the key is +// not in that space at all, so a stage written against it cannot name one. +declare const gatewayOnly: (stage: { + readonly through?: { readonly request: { readonly needs: readonly (keyof CoreFacts)[] } }; +}) => void; + +const leakCheck = (): void => gatewayOnly({ + through: { + // @ts-expect-error 'provider.token' is not a key of CoreFacts + request: { needs: ['provider.token'] }, + }, +}); +void leakCheck; + +type ImageEntry = { payload: { operation: 'generate'; prompt: string } | { operation: 'edit'; image: string } }; +type GeneratedImageEntry = { payload: Extract }; +declare const dispatchImage: IntoNext; +declare const generateImage: Pipeline; + +const narrowTargetCheck = (): void => { + void dispatchImage({ payload: { operation: 'generate', prompt: 'hello' } }, generateImage); + // @ts-expect-error an edit payload cannot enter a generation-only target + void dispatchImage({ payload: { operation: 'edit', image: 'bytes' } }, generateImage); +}; +void narrowTargetCheck; + +// The two `@ts-expect-error` markers above are the assertions: each fails the build if the +// error it names stops occurring. What is left for runtime is the reader's half of the key +// escaping, which has no compile-time form. +describe('what the type layer holds', () => { + it('round-trips a key that begins with a dollar', () => { + for (const key of ['$ref', '$defs', '$schema', 'model', '$$literal']) { + expect(decodeKey(encodeKey(key))).toBe(key); + } + expect(encodeKey('$ref')).toBe('$$ref'); + expect(decodeKey('$$ref')).toBe('$ref'); + }); +}); diff --git a/packages/pipeline/package.json b/packages/pipeline/package.json new file mode 100644 index 0000000000..814f78c88d --- /dev/null +++ b/packages/pipeline/package.json @@ -0,0 +1,19 @@ +{ + "name": "@floway-dev/pipeline", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@noble/hashes": "^2.2.0", + "@scure/base": "^2.2.0" + } +} diff --git a/packages/pipeline/src/compose.ts b/packages/pipeline/src/compose.ts new file mode 100644 index 0000000000..ed9a251472 --- /dev/null +++ b/packages/pipeline/src/compose.ts @@ -0,0 +1,85 @@ +// Assembly checks what types cannot, per trait, along the array — O(n) per trait, not +// O(2ⁿ) over which stages took which path. Envoy validates terminal-filter position at +// config load for the same reason and it works for the same reason. +// +// The entry contract is derived rather than declared: a need nobody provided joins it, and +// a need an earlier stage **consumed** is an assembly error. The second is the load-bearing +// one — a key an earlier stage consumed cannot be needed below it, so a translated request +// cannot re-enter its own chain, and assembly says so rather than a runtime guard. + +import type { Facts } from './facts.ts'; +import { walk } from './run.ts'; +import type { Pipeline, Stage } from './stage.ts'; + +export const compose = ( + name: string, + stages: readonly Stage[], +): Pipeline => { + const have = new Set(); // a stage below can read it + const consumedBy = new Map(); // gone, and who took it + const entry = new Set(); // therefore the caller must bring it + const neededAbove = new Set(); // response keys the stages above declared + + stages.forEach((stage, index) => { + const pass = stage.through ?? stage.into; + const terminal = pass === undefined; + + if (stage.into !== undefined && index !== stages.length - 1) { + throw new Error(`compose(${name}): ${stage.name} declares 'into' but is not last`); + } + if (terminal && stage.return === undefined) { + throw new Error(`compose(${name}): ${stage.name} declares neither a way down nor a way to answer`); + } + // A stage that can only answer is where the array ends, whatever is written after it. + if (terminal && index !== stages.length - 1) { + throw new Error(`compose(${name}): ${stage.name} can only answer, so the stages after it never run`); + } + // A stage that may answer must cover what the stages above it declared needing, or + // short-circuiting leaves them without a fact they said they need. + if (stage.return !== undefined) { + const uncovered = [...neededAbove].filter(key => !stage.return!.provides.includes(key)); + if (uncovered.length > 0) { + throw new Error( + `compose(${name}): ${stage.name} may answer here, but its short-circuit does not provide ` + + `${uncovered.join(', ')}, which stages above it need`, + ); + } + } + if (pass === undefined) return; + + // The response direction flows the other way: what this stage provides on the way up + // satisfies the stages above it, so it clears their outstanding needs before this + // stage adds its own. + for (const key of pass.response.provides) neededAbove.delete(key); + for (const key of pass.request.needs) { + if (have.has(key)) continue; + const taker = consumedBy.get(key); + if (taker !== undefined) { + throw new Error(`compose(${name}): ${stage.name} needs ${key}, which ${taker} consumed above it`); + } + entry.add(key); + } + for (const key of pass.request.consumes) { have.delete(key); consumedBy.set(key, stage.name); } + for (const key of pass.request.provides) { have.add(key); consumedBy.delete(key); } + // The mirror of the request-direction error. A stage that takes a response key and + // does not hand it on leaves a stage above it needing something that cannot arrive. + for (const key of pass.response.consumes) { + if (pass.response.provides.includes(key) || !neededAbove.has(key)) continue; + throw new Error(`compose(${name}): ${stage.name} consumes ${key} on the way up, which a stage above it needs`); + } + for (const key of pass.response.needs) neededAbove.add(key); + }); + + // A pipeline has to be able to end. A last stage that only declares `through` must go + // down, and there is nothing below it, so the array is unsatisfiable however it runs. + const last = stages[stages.length - 1]; + if (last !== undefined && last.return === undefined && last.into === undefined) { + throw new Error(`compose(${name}): ${last.name} is last but can neither answer nor hand off`); + } + + return { + name, + entryNeeds: [...entry] as unknown as readonly (keyof Entry)[], + enter: async (facts, services, scope) => (await walk(name, stages, 0, facts as Facts, services, scope)) as unknown as Exit, + }; +}; diff --git a/packages/pipeline/src/dump.ts b/packages/pipeline/src/dump.ts new file mode 100644 index 0000000000..e83c006479 --- /dev/null +++ b/packages/pipeline/src/dump.ts @@ -0,0 +1,343 @@ +// What a run emits, and how it is written down. +// +// A run emits a sequence of events. What a live observer is given and what is stored are +// the same sequence, so there is one encoding and one thing to implement. +// +// { type: 'stage.entered', stageId, name, parentStageId, facts } +// { type: 'stage.leaved', stageId, facts } +// { type: 'stage.log', stageId, level, context, message, fields } +// { type: 'object', fromObjectId, nodes: [ … ] } +// { type: 'stream.frame', streamId, frames: [ … ] } +// { type: 'stream.end', streamId } +// +// Events carry states and never differences. What a stage did is the difference between +// the state it entered on and the state its child entered on, and between what its child +// handed up and what it handed up — all adjacent in the stream, so a reader derives every +// change and the run stores none. Object identity makes that cheap: equal ids mean equal +// subtrees, so a reader recurses only where they differ. + +import { sha256 } from '@noble/hashes/sha2.js'; +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'; +import { base64 } from '@scure/base'; + +import type { Facts } from './facts.ts'; +import { isDeferred } from './run.ts'; +import type { LogEntry, LogLevel } from './stage.ts'; + +/** What the runner produces, with facts still as live objects. */ +export type Event = + | { readonly type: 'stage.entered'; readonly stageId: number; readonly name: string; readonly parentStageId: number | null; readonly facts: Facts } + | { readonly type: 'stage.leaved'; readonly stageId: number; readonly facts: Facts } + | { readonly type: 'stage.failed'; readonly stageId: number; readonly error: unknown } + /** What that stage's own logger wrote. A log line is content about a stage, like the + * rest, and it has to be somewhere: every stage gets a logger and with a dump open its + * lines are stored into that stage's record as well as going to the global sink. */ + | ({ readonly type: 'stage.log'; readonly stageId: number } & LogEntry) + | { readonly type: 'stream.frame'; readonly streamId: number; readonly frames: readonly unknown[] } + | { readonly type: 'stream.end'; readonly streamId: number } + | { readonly type: 'deferred.settled'; readonly deferred: import('./run.ts').Deferred; readonly outcome: PromiseSettledResult }; + +// --- Value kinds the space cannot hold as plain data ------------------------------- + +const SECRET = Symbol('floway.secret'); +const RENDERED = Symbol('floway.secret.rendered'); +const STREAM = Symbol('floway.stream'); + +/** + * A value-level marker that redaction reads. *Secret* and *credential* are two different + * concepts and only the first exists this far down: a stage knows some values are secret, + * sends them exactly as given, and does nothing extra either way. What a thing is, who + * issued it and when it expires is a domain concept the ending stage never hears of. + */ +export interface Secret { + readonly [SECRET]: true; + readonly [RENDERED]: string; + /** Reading it is deliberate and greppable. The dump never calls this. */ + readonly reveal: () => T; +} + +/** A renderer is required exactly when the value is not already a string, because + * `{length, redacted, hash}` is a statement about a rendering and `String(bytes)` is not + * one anybody would recognise. */ +export function secret(value: string): Secret; +export function secret(value: T, render: (value: T) => string): Secret; +export function secret(value: T, render?: (value: T) => string): Secret { + return { + [SECRET]: true, + [RENDERED]: render === undefined ? (value as unknown as string) : render(value), + reveal: () => value, + }; +} + +export const isSecret = (value: unknown): value is Secret => + typeof value === 'object' && value !== null && SECRET in value; + +/** A stream is identified in the space and its frames arrive as their own events, because + * frames are produced over time and an object is not. */ +export interface StreamFact { + readonly [STREAM]: number; +} + +export const streamFact = (streamId: number): StreamFact => ({ [STREAM]: streamId }); + +export const isStreamFact = (value: unknown): value is StreamFact => + typeof value === 'object' && value !== null && STREAM in value; + +/** + * `{length, redacted, hash}`. `hash` is what lets a reader see that the same secret sat at + * the same key across two events, so a diff reports no change where none happened; + * `length` and `redacted` are what it renders. All three are produced here, because a + * reader has nothing to compute them from — that is the point. + */ +export const storedSecret = (value: Secret): StoredSecret => { + const rendered = value[RENDERED]; + return { + length: rendered.length, + redacted: redact(rendered), + hash: `0x${bytesToHex(sha256(utf8ToBytes(rendered))).slice(0, 16)}`, + }; +}; + +const redact = (rendered: string): string => + '*'.repeat(rendered.length); + +export interface StoredSecret { + readonly length: number; + readonly redacted: string; + readonly hash: string; +} + +// --- The encoding ------------------------------------------------------------------ + +export type Ref = { readonly $: number }; +export type Stored = + | null | boolean | number | string + | Ref + | { readonly $stream: number } + | { readonly $readableStream: true } + | { readonly $deferred: true } + | { readonly $error: { readonly [key: string]: Stored } } + | { readonly $map: Stored } + | { readonly $set: Stored } + | { readonly $date: Stored } + | { readonly $secret: StoredSecret } + | { readonly $bytes: string } + | { readonly $undefined: true } + | { readonly $number: 'NaN' | 'Infinity' | '-Infinity' } + | { readonly $bigint: string } + | Stored[] + | { readonly [key: string]: Stored }; + +export type DumpEvent = + | { readonly type: 'stage.entered'; readonly stageId: number; readonly name: string; readonly parentStageId: number | null; readonly facts?: Record } + | { readonly type: 'stage.leaved'; readonly stageId: number; readonly facts: Record } + | { readonly type: 'stage.failed'; readonly stageId: number; readonly error: Stored } + | { readonly type: 'stage.log'; readonly stageId: number; readonly level: LogLevel; readonly context: string; readonly message: string; readonly fields?: Record } + | { readonly type: 'object'; readonly fromObjectId: number; readonly nodes: readonly Stored[] } + | { readonly type: 'stream.frame'; readonly streamId: number; readonly frames: readonly Stored[] } + | { readonly type: 'stream.end'; readonly streamId: number } + | { readonly type: 'deferred.settled'; readonly deferred: Stored; readonly outcome: Stored }; + +/** + * A key that begins with `$` is written with one more, and a reader strips one back off. + * Not hypothetical: a tool's parameters are JSON Schema, so `$schema`, `$defs` and `$ref` + * arrive in real payloads. Only keys are escaped — a `$` inside a value is data. + */ +export const encodeKey = (key: string): string => (key.startsWith('$') ? `$${key}` : key); +export const decodeKey = (key: string): string => (key.startsWith('$$') ? key.slice(1) : key); + +const isNode = (value: unknown): value is object => typeof value === 'object' && value !== null; + +/** + * Every value JSON cannot carry gets its own tag, because a record and a dump that + * disagree in the direction that looks correct is the worst way for them to disagree — + * which is the same reason `undefined` is not a removal. `JSON.stringify` drops + * `undefined`, turns `NaN` and the infinities into `null`, and throws on a `bigint`. + */ +const tagged = (value: unknown): Stored | undefined => { + if (value === undefined) return { $undefined: true }; + if (typeof value === 'bigint') return { $bigint: value.toString() }; + if (typeof value !== 'number' || Number.isFinite(value)) return undefined; + return { $number: Number.isNaN(value) ? 'NaN' : value > 0 ? 'Infinity' : '-Infinity' }; +}; + +/** + * Ids come from one counter and are taken at first sight, so the ids minted while encoding + * one event are a contiguous range and only the first needs stating. An id is taken + * **before** recursing into the object, which is what makes a cycle terminate on its + * second visit. + * + * The invariant every `object` event owes: each reference in it points at an id from an + * earlier event or at one inside this event, never forward into an event that has not + * arrived. That is what makes the stream emittable as it happens. + * + * Bytes and large strings are shared by complete value. Separately allocated payload + * graphs can still refer to the same encoded byte or string node. + */ +const createEncoder = (options: { readonly shareStringsFrom?: number } = {}) => { + const shareStringsFrom = options.shareStringsFrom ?? 1024; + const objectIds = new WeakMap(); + const stringIds = new Map(); + const byteIds = new Map(); + let nextObjectId = 1; + + const encodeFacts = (facts: Readonly>, emit: (event: DumpEvent) => void): Record => { + const fromObjectId = nextObjectId; + const nodes: Stored[] = []; + + const write = (value: unknown): Stored => { + const tag = tagged(value); + if (tag !== undefined) return tag; + if (typeof value === 'string' && value.length >= shareStringsFrom) { + const known = stringIds.get(value); + if (known !== undefined) return { $: known }; + const id = nextObjectId++; + stringIds.set(value, id); + nodes[id - fromObjectId] = value; + return { $: id }; + } + if (!isNode(value)) return value as Stored; + if (isStreamFact(value)) return { $stream: value[STREAM] }; + const known = objectIds.get(value); + if (known !== undefined) return { $: known }; + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { + const bytes = base64.encode(bytesOf(value)); + const shared = byteIds.get(bytes); + if (shared !== undefined) { + objectIds.set(value, shared); + return { $: shared }; + } + const id = nextObjectId++; + objectIds.set(value, id); + byteIds.set(bytes, id); + nodes[id - fromObjectId] = { $bytes: bytes }; + return { $: id }; + } + const id = nextObjectId++; + objectIds.set(value, id); + nodes[id - fromObjectId] = body(value); + return { $: id }; + }; + + // A buffer is atomic and records its full content, the way a string does — walking it + // by index would turn one image into a JSON object with a key per byte. + const body = (value: object): Stored => { + if (isSecret(value)) return { $secret: storedSecret(value) }; + if (value instanceof Blob) throw new TypeError('Pipeline facts must represent Blob and File content as bytes and metadata before recording'); + if (value instanceof ReadableStream) return { $readableStream: true }; + if (isDeferred(value)) return { $deferred: true }; + if (value instanceof Error) { + const keys = new Set(['name', 'message', ...Object.getOwnPropertyNames(value)]); + return { $error: Object.fromEntries([...keys].map(key => [encodeKey(key), write((value as unknown as Record)[key])])) }; + } + if (value instanceof Map) return { $map: write([...value]) }; + if (value instanceof Set) return { $set: write([...value]) }; + if (value instanceof Date) return { $date: write(value.getTime()) }; + if (Array.isArray(value)) return value.map(write); + return Object.fromEntries(Object.entries(value).map(([key, child]) => [encodeKey(key), write(child)])); + }; + + const encoded = Object.fromEntries(Object.entries(facts).map(([key, value]) => [encodeKey(key), write(value)])); + if (nodes.length > 0) emit({ type: 'object', fromObjectId, nodes }); + return encoded; + }; + + return { encodeFacts }; +}; + +const bytesOf = (value: ArrayBuffer | ArrayBufferView): Uint8Array => + value instanceof ArrayBuffer ? new Uint8Array(value) : new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + +/** + * One event in, its encoding out — so a run can be written down as it happens rather than + * only once it is over, which is what a live observer and an appended file both need. + * + * Folding is by identity, the convention structural sharing already rests on: a + * `stage.entered` always appears — it is what puts the stage in the tree, so dropping it + * would strand its children's `parentStageId` — but it carries `facts` only when they + * differ from its parent's, and a `stage.leaved` that hands up exactly what its last child + * handed up carries nothing at all and goes. + */ +export const createRunEncoder = (options?: { readonly shareStringsFrom?: number }) => { + const encoder = createEncoder(options); + const entered = new Map(); + const lastChildLeft = new Map(); + + return (event: Event): DumpEvent[] => { + const out: DumpEvent[] = []; + const emit = (encoded: DumpEvent): void => { out.push(encoded); }; + + if (event.type === 'stage.entered') { + const parent = event.parentStageId === null ? undefined : entered.get(event.parentStageId); + const unchanged = parent !== undefined && parent.facts === event.facts; + const facts = unchanged ? undefined : encoder.encodeFacts(event.facts, emit); + emit({ + type: 'stage.entered', + stageId: event.stageId, + name: event.name, + parentStageId: event.parentStageId, + ...(facts === undefined ? {} : { facts }), + }); + entered.set(event.stageId, { parent: event.parentStageId, facts: event.facts }); + return out; + } + + if (event.type === 'stage.leaved') { + const seen = entered.get(event.stageId); + if (lastChildLeft.get(event.stageId) !== event.facts) { + emit({ type: 'stage.leaved', stageId: event.stageId, facts: encoder.encodeFacts(event.facts, emit) }); + } + if (seen?.parent != null) lastChildLeft.set(seen.parent, event.facts); + entered.delete(event.stageId); + lastChildLeft.delete(event.stageId); + return out; + } + + if (event.type === 'stage.log') { + const fields = event.fields === undefined ? undefined : encoder.encodeFacts(event.fields, emit); + emit({ + type: 'stage.log', + stageId: event.stageId, + level: event.level, + context: event.context, + message: event.message, + ...(fields === undefined ? {} : { fields }), + }); + return out; + } + + if (event.type === 'stream.frame') { + const frames = event.frames.map(frame => encoder.encodeFacts({ frame }, emit)['frame']!); + emit({ type: 'stream.frame', streamId: event.streamId, frames }); + return out; + } + + if (event.type === 'stage.failed') { + const fields = encoder.encodeFacts({ error: event.error }, emit); + emit({ type: 'stage.failed', stageId: event.stageId, error: fields['error']! }); + entered.delete(event.stageId); + lastChildLeft.delete(event.stageId); + return out; + } + + if (event.type === 'deferred.settled') { + const fields = encoder.encodeFacts({ deferred: event.deferred, outcome: event.outcome }, emit); + emit({ type: 'deferred.settled', deferred: fields['deferred']!, outcome: fields['outcome']! }); + return out; + } + + emit(event); + return out; + }; +}; + +/** A whole run at once, for a caller that already has every event. */ +export const encodeRun = (events: readonly Event[], options?: { readonly shareStringsFrom?: number }): DumpEvent[] => { + const encode = createRunEncoder(options); + return events.flatMap(encode); +}; + +/** NDJSON, one event per line, appended in order. A line maps to one SSE `data:` payload, + * so the stored file and the live protocol are the same bytes. */ +export const toNdjson = (events: readonly DumpEvent[]): string => + events.map(event => `${JSON.stringify(event)}\n`).join(''); diff --git a/packages/pipeline/src/facts.ts b/packages/pipeline/src/facts.ts new file mode 100644 index 0000000000..729566061f --- /dev/null +++ b/packages/pipeline/src/facts.ts @@ -0,0 +1,74 @@ +// One immutable record travels a pipeline. Entering it is a **move**: afterwards the +// value belongs to the record, the caller must not keep writing to it, and it is deep +// frozen so that a later write throws rather than silently rewriting a state the dump +// has already recorded. +// +// Two mechanisms, because one cannot do both jobs. Measured on Node 24: +// +// freeze(Uint8Array with elements) THROW Cannot freeze array buffer views with elements +// freeze(empty Uint8Array) OK +// freeze(Map) then map.set OK mutated, size=1 +// freeze(Date) then setTime OK mutated +// shallow freeze passes isFrozen OK child still mutable +// freeze(ReadableStream) OK +// +// An HTTP body is a `Uint8Array`, so the freeze walk has to skip typed arrays — and a +// value it skipped can never satisfy `Object.isFrozen`. Native streams and blobs are also +// registered without freezing their platform-owned implementation. The last row is the other half: +// a shallow builtin freeze satisfies `Object.isFrozen` while its children stay mutable. +// So membership in a `WeakSet` of handed-over roots is the gate, and `Object.freeze` is +// what makes a later in-place write throw. +// +// Two limits of `Object.freeze` are accepted rather than worked around: `Map`, `Set` and +// `Date` stay mutable through their own methods, and a typed array gets the gate but no +// post-hoc protection. + +const handedOver = new WeakSet(); + +/** + * Hand a value to the record. Registers every object and freezes ordinary data, including + * non-enumerable error causes. Platform resources keep their native implementation mutable. + * A value already handed over is not walked again. + */ +export const move = (value: T): T => { + const walk = (v: unknown): void => { + if (v === null || typeof v !== 'object' || handedOver.has(v)) return; + handedOver.add(v); + if (v instanceof ArrayBuffer || ArrayBuffer.isView(v) || v instanceof ReadableStream || v instanceof Blob) return; + Object.freeze(v); + if (v instanceof Map) for (const [key, child] of v) { walk(key); walk(child); } + if (v instanceof Set) for (const child of v) walk(child); + for (const name of Object.getOwnPropertyNames(v)) walk((v as Record)[name]); + }; + walk(value); + return value; +}; + +/** + * The gate, at the two places a value can enter the record: the runner's acceptance of + * what a stage hands on, and the prologue's creation of the initial facts. Together they + * are the whole surface. + */ +export const assertHandedOver = (where: string, value: unknown): void => { + if (value === null || typeof value !== 'object') return; + if (!handedOver.has(value)) { + throw new Error(`${where}: value was not handed over — call move() at the assignment site`); + } +}; + +/** The keys a stage's author names, so `needs`, `consumes` and `provides` are checked + * against a closed set and a key from another package's space is unreachable. It is what + * the author sees, not what arrives: the record a stage is handed carries every key in + * flight, and the ones this stage never heard of are the assembly's business. Facts are + * not marked `readonly` here — freezing and the gate are runtime properties, and + * restating them in the types costs friction and buys nothing. */ +export type Slice = { [P in K]: F[P] }; + +/** What a stage hands on: the whole record the next segment runs on, never a delta. The + * type names the keys this stage promised and admits the rest, because a stage hands on + * keys it has never heard of and they must survive the trip. Which keys it may name is + * the assembly's question, not the type layer's. */ +export type Handed = Down & Record; + +/** The erased record, as assembly and the runner hold it. */ +export type Facts = { readonly [key: string]: unknown }; diff --git a/packages/pipeline/src/index.ts b/packages/pipeline/src/index.ts new file mode 100644 index 0000000000..a4e5cdfe6d --- /dev/null +++ b/packages/pipeline/src/index.ts @@ -0,0 +1,49 @@ +// The pipeline core: one immutable record of facts, and a list of stages that each +// declare which facts they touch. +// +// Nothing here knows what a model is. The gateway and the provider packages compose these +// pieces; this package is what they compose. + +export { assertHandedOver, move } from './facts.ts'; +export type { Facts, Handed, Slice } from './facts.ts'; + +export { defineStage, transform } from './stage.ts'; +export type { + Descend, + ErasedPass, + ErasedSide, + IntoNext, + Logger, + LogLevel, + Open, + PassDecl, + Pipeline, + ReturnDecl, + RunScope, + RunServices, + Stage, + ThroughNext, + Use, +} from './stage.ts'; + +export { compose } from './compose.ts'; + +export { defer, getFailureFacts, isDeferred, isOwned, own, run, setRelease } from './run.ts'; +export type { Deferred, Owned, RunResult } from './run.ts'; + +export { + createRunEncoder, + decodeKey, + encodeKey, + encodeRun, + isSecret, + isStreamFact, + secret, + storedSecret, + streamFact, + toNdjson, +} from './dump.ts'; +export type { DumpEvent, Event, Ref, Secret, Stored, StoredSecret, StreamFact } from './dump.ts'; + +export { createRunReader } from './read.ts'; +export type { ReadEvent, RunReader } from './read.ts'; diff --git a/packages/pipeline/src/read.ts b/packages/pipeline/src/read.ts new file mode 100644 index 0000000000..93c70b7473 --- /dev/null +++ b/packages/pipeline/src/read.ts @@ -0,0 +1,94 @@ +import { decodeKey } from './dump.ts'; +import type { DumpEvent, Stored } from './dump.ts'; + +export interface ReadEvent { + readonly facts?: Record; + readonly frames?: readonly unknown[]; + readonly deferred?: unknown; + readonly outcome?: PromiseSettledResult; + readonly error?: unknown; +} + +export interface RunReader { + (event: DumpEvent): ReadEvent | null; + node(id: number): Stored; + decode(value: Stored): unknown; + settlement(id: number): PromiseSettledResult | undefined; +} + +/** One object space for both decoded values and identity-based inspection of stored nodes. + * Bytes and platform handles remain descriptions: decoding never acquires a resource. */ +export const createRunReader = (): RunReader => { + const nodes = new Map(); + const decoded = new Map(); + const settlements = new Map>(); + + const node = (id: number): Stored => { + const stored = nodes.get(id); + if (stored === undefined) throw new Error(`Dump reference $${id} points at a node no event carried`); + return stored; + }; + + const fields = (values: Readonly>, target: Record): Record => { + for (const [key, value] of Object.entries(values)) { + Object.defineProperty(target, decodeKey(key), { value: decode(value), enumerable: true, writable: true, configurable: true }); + } + return target; + }; + + const decode = (stored: Stored, id?: number): unknown => { + const remember = (value: unknown): unknown => { + if (id !== undefined) decoded.set(id, value); + return value; + }; + if (stored === null || typeof stored !== 'object') return remember(stored); + if (Array.isArray(stored)) { + const value: unknown[] = []; + remember(value); + for (const child of stored) value.push(decode(child)); + return value; + } + if ('$' in stored) { + const ref = stored.$ as number; + return decoded.has(ref) ? decoded.get(ref) : decode(node(ref), ref); + } + if ('$stream' in stored) return remember({ stream: stored.$stream }); + if ('$readableStream' in stored) return remember({ readableStream: true }); + if ('$deferred' in stored) return remember({ deferred: true }); + if ('$secret' in stored) return remember(stored.$secret); + if ('$bytes' in stored) return remember({ bytes: stored.$bytes }); + if ('$undefined' in stored) return remember(undefined); + if ('$number' in stored) return remember(stored.$number === 'NaN' ? NaN : stored.$number === 'Infinity' ? Infinity : -Infinity); + if ('$bigint' in stored) return remember(BigInt(stored.$bigint as string)); + const value: Record = {}; + // Cache the container before its children: Error causes, collection members and + // ordinary facts can all refer back to the very node currently being decoded. + remember(value); + if ('$error' in stored) value['error'] = fields(stored.$error as Record, {}); + else if ('$map' in stored) value['map'] = decode(stored.$map as Stored); + else if ('$set' in stored) value['set'] = decode(stored.$set as Stored); + else if ('$date' in stored) value['date'] = decode(stored.$date as Stored); + else fields(stored, value); + return value; + }; + + const read = (event: DumpEvent): ReadEvent | null => { + if (event.type === 'object') { + event.nodes.forEach((stored, index) => { nodes.set(event.fromObjectId + index, stored); }); + return null; + } + if (event.type === 'stream.frame') return { frames: event.frames.map(value => decode(value)) }; + if (event.type === 'stage.failed') return { error: decode(event.error) }; + if (event.type === 'stage.entered' || event.type === 'stage.leaved') { + return event.facts === undefined ? {} : { facts: fields(event.facts, {}) }; + } + if (event.type === 'deferred.settled') { + const outcome = decode(event.outcome) as PromiseSettledResult; + settlements.set((event.deferred as { readonly $: number }).$, outcome); + return { deferred: decode(event.deferred), outcome }; + } + return {}; + }; + + return Object.assign(read, { node, decode: (value: Stored) => decode(value), settlement: (id: number) => settlements.get(id) }); +}; diff --git a/packages/pipeline/src/run.ts b/packages/pipeline/src/run.ts new file mode 100644 index 0000000000..b11022e61d --- /dev/null +++ b/packages/pipeline/src/run.ts @@ -0,0 +1,434 @@ +// The runner. Four jobs beyond calling stages in order: it holds each stage to its own +// declaration, it records what happened, it tracks what the run owns, and it drains that +// when the caller says so. +// +// **Declarations are checked, never applied.** After a stage hands on — in either +// direction, and on the answering path too — every key it declared `provides` is there +// and every key it declared `consumes`, and did not also declare providing, is gone. +// Nothing is removed on the stage's behalf: a declaration that acts cannot also be +// checked, and checking is the whole of what a declaration is for. A key in both +// `consumes` and `provides` is a modify: going down, a translation taking the source and +// putting the target at another key; coming up, a fork taking ownership of every branch's +// releasable and handing one of them onward. + +import type { Facts } from './facts.ts'; +import { assertHandedOver, move } from './facts.ts'; +import type { Descend, ErasedSide, LogLevel, Pipeline, RunScope, RunServices, Stage } from './stage.ts'; + +/** Ownership is claimed, never sniffed. + * + * `Symbol.asyncDispose` is a release *mechanism* the language hands out on its own terms, + * and those terms are not stable across the hosts this runs on. Measured: + * + * Node 24 Node 22 + * Symbol.asyncDispose in (async function*(){})() true false + * Symbol.asyncDispose in new ReadableStream() false false + * + * Neither column is what a run needs. On Node 24 a structural predicate adopts every + * generator-shaped fact as a run resource — a transducer's own iterator gets `.return()`ed + * the moment its stage hands up, and a fork throws on receiving one at a key nobody declared + * consuming, which is every failed-over streaming request. On Node 22, which + * `apps/platform-node` supports, it adopts nothing at all. Either way it misses the upstream + * body it exists for, and a predicate whose answer depends on the host cannot be what decides + * which resources a gateway closes. + * + * `own()` is what says "the run is answerable for this". Which is also what the + * architecture already says: `consumes` on the response side *declares* the keys whose + * values a stage takes ownership of. Declaration was always the mechanism; this makes the + * runner read it instead of guessing. */ +const OWNED = Symbol('floway.owned'); + +export type Owned = AsyncDisposable & { readonly [OWNED]: true }; + +interface Ownership { + release: () => Promise; + disposal: Promise | undefined; +} + +const ownership = new WeakMap(); + +/** Marks a value the run must release, and gives it the release the runner will call. + * A value that owns nothing is never marked, so it rides through untouched. */ +export const own = (value: T, release: () => Promise): T & Owned => { + if (ownership.has(value)) throw new Error('Resource is already owned; use setRelease to change its release action'); + const state: Ownership = { release, disposal: undefined }; + const releaseOnce = (): Promise => { + state.disposal ??= (async () => { await state.release(); })(); + return state.disposal; + }; + const resource = Object.assign(value, { [OWNED]: true as const, [Symbol.asyncDispose]: releaseOnce }); + ownership.set(resource, state); + return resource; +}; + +export const setRelease = (value: Owned, release: () => Promise): (() => Promise) => { + const state = ownership.get(value)!; + if (state.disposal !== undefined) throw new Error('Cannot change a resource release action after disposal has started'); + const previous = state.release; + state.release = release; + return previous; +}; + +export const isOwned = (value: unknown): value is Owned => + typeof value === 'object' && value !== null && OWNED in value; + +const DEFERRED = Symbol('floway.deferred'); + +/** A value the run has started and has not finished. It is a property of the value, not a + * capability a stage was handed: what a run must wait for is legible from its own record. */ +export type Deferred = Promise & { readonly [DEFERRED]: true }; + +/** + * Marks a promise as this run's to finish. + * + * Declared rather than sniffed. Testing for a `then` method would make a fact that happens to + * hold a promise indistinguishable from one the run owes work to, and it would be quietly + * awaited instead of reported — so the claim is explicit, exactly as ownership is. + * + * Helpers call this and hand out the promise; a stage never creates one. That is what + * "stages have no fire-and-forget" means in practice: work a stage starts becomes a fact + * with a name, and the runner waits for it where it can see it. + */ +export const defer = (promise: Promise): Deferred => + Object.assign(promise, { [DEFERRED]: true as const }); + +export const isDeferred = (value: unknown): value is Deferred => + typeof value === 'object' && value !== null && DEFERRED in value; + +/** + * Frozen in place, never copied. A stage that hands on what it received hands on the + * same object, so the record has an identity of its own and "this side changed nothing" + * is the same test as everywhere else in the design — which is what the dump's folding + * reads. Freezing is what makes the copy unnecessary: nobody can write to it afterwards. + * + * Ownership is per key, which is what `consumes` declares, so this is also where the run + * learns about a resource: every releasable at a top-level key of a record the runner + * accepts is one the run is answerable for until somebody releases it. + */ +const handOn = (record: Facts, decl: ErasedSide, stage: string, way: 'down' | 'up', scope: RunScope): Facts => { + for (const [key, value] of Object.entries(record)) { + assertHandedOver(`${stage} handing ${way} ${key}`, value); + registerFact(value, record, scope); + } + for (const key of decl.provides) { + if (!(key in record)) throw new Error(`${stage}: declared providing ${key} but did not`); + } + for (const key of decl.consumes) { + if (decl.provides.includes(key)) continue; + if (key in record) throw new Error(`${stage}: declared consuming ${key} but handed it ${way}`); + } + return Object.freeze(record); +}; + +const registerFact = (value: unknown, facts: Facts, scope: RunScope): void => { + if (isOwned(value)) scope.outstanding.add(value); + if (isDeferred(value) && !scope.deferred.has(value)) { + const settled = value.then(async result => { + try { + await scope.emit({ type: 'deferred.settled', deferred: value, outcome: move({ status: 'fulfilled', value: result }) }); + } catch (error) { throw captureFailure(error, facts, scope); } + }, async error => { + const failure = captureFailure(error, facts, scope); + try { + await scope.emit({ type: 'deferred.settled', deferred: value, outcome: move({ status: 'rejected', reason: error }) }); + } catch (recordingError) { + if (recordingError !== error) { + const combined = new AggregateError([error, recordingError], 'Deferred fact failed and recording also failed', { cause: error }); + throw captureFailure(combined, facts, scope); + } + } + throw failure; + }); + scope.deferred.set(value, settled); + // Teardown observes the original rejection; marking it handled now prevents + // a streamed answer from making that rejection unhandled before drain runs. + void settled.catch(() => {}); + } +}; + +const NONE: readonly string[] = []; + +interface FailureContext { readonly owner: object; readonly facts: Facts; readonly source: unknown } +const failureFacts = new WeakMap(); +const scopeOwners = new WeakMap(); + +export const getFailureFacts = (error: unknown): Facts | undefined => + typeof error === 'object' && error !== null ? failureFacts.get(error)?.facts : undefined; + +const captureFailure = (error: unknown, facts: Facts, scope: RunScope): unknown => { + const isReference = typeof error === 'object' && error !== null; + if (isReference) { + const known = scope.failures.get(error); + if (known !== undefined) return known; + } + const owner = scopeOwners.get(scope)!; + const previous = isReference ? failureFacts.get(error) : undefined; + // Shared Error objects and primitive stream rejection values need a per-run + // cause wrapper, so the diagnostic context never overwrites another run's. + const caught = !isReference + ? new Error(String(error), { cause: error }) + : previous !== undefined && previous.owner !== owner + ? new Error(error instanceof Error ? error.message : 'Pipeline execution failed', { cause: error }) + : error; + if (caught !== error && caught instanceof Error && error instanceof Error) caught.name = error.name; + if (isReference) scope.failures.set(error, caught); + scope.failures.set(caught, caught); + failureFacts.set(caught, { owner, facts, source: previous === undefined ? error : previous.source }); + return caught; +}; + +const failureSource = (error: unknown): unknown => { + const context = typeof error === 'object' && error !== null ? failureFacts.get(error) : undefined; + return context === undefined ? error : context.source; +}; + +export const walk = async ( + pipeline: string, + stages: readonly Stage[], + index: number, + facts: Facts, + services: object, + scope: RunScope, +): Promise => { + const stage = stages[index]; + if (stage === undefined) throw new Error(`${pipeline}: ran off the end without answering`); + const stageId = scope.nextStageId++; + const parentStageId = scope.parentStageId; + const pass = stage.through ?? stage.into; + const branches: Facts[] = []; + let current = facts; + + try { + // Once, on the way in: what this stage initially saw. A fork is not this event + // repeating — it is several *children* naming this stage as their parent, each entered + // by its own descent, so the shape of a run is in the ids. + await scope.emit({ type: 'stage.entered', stageId, name: stage.name, parentStageId, facts }); + if (pass !== undefined) requireFacts(facts, pass.request.needs, `${stage.name} entering:`); + + const descend: Descend = async (produced, target) => { + const handed = handOn(produced, pass!.request, stage.name, 'down', scope); + current = handed; + const outerParent = scope.parentStageId; + scope.parentStageId = stageId; + try { + if (target !== undefined) requireEntry(target, handed, `${stage.name} handing off`); + const out = target === undefined + ? await walk(pipeline, stages, index + 1, handed, services, scope) + : (await target.enter(handed as object, services, scope)) as Facts; + current = out; + requireFacts(out, pass!.response.needs, `${stage.name} receiving response:`); + branches.push(out); + return out; + } finally { + scope.parentStageId = outerParent; + } + }; + + // A stage that declared no way down is handed no continuation at all, which is the + // whole of what shape 1 means, so it is called with one fewer argument. + const use = { ...services, log: loggerFor(services, stage.name, stageId, scope) }; + const call = stage.execute as unknown as (...args: readonly unknown[]) => Promise; + const produced = pass === undefined + ? await call(facts, use) + : await call(facts, descend, use); + + // Trait one fired: it never went down, so there is no response side to check — only the + // closed set it declared it would answer with. Answering is the same rule as handing + // down: the stage returns the whole record, and nothing is merged on its behalf. + if (branches.length === 0) { + if (stage.return === undefined) { + throw new Error(pass === undefined + ? `${stage.name}: answered without declaring 'return'` + : `${stage.name}: returned without calling next, and declares no 'return'`); + } + const answer = handOn(produced, { needs: NONE, consumes: NONE, provides: stage.return.provides }, stage.name, 'up', scope); + current = answer; + await scope.emit({ type: 'stage.leaved', stageId, facts: answer }); + return answer; + } + + // It forked: every releasable it received must have been declared, because the branches + // it did not adopt are its own. This is checked at runtime because that is the level the + // property lives at — whether a stage branches is invisible in its signature. + if (branches.length > 1) { + const received = new Set(); + for (const branch of branches) { + for (const [key, value] of Object.entries(branch)) if (isOwned(value)) received.add(key); + } + const undeclared = [...received].filter(key => !pass!.response.consumes.includes(key)); + if (undeclared.length > 0) { + throw new Error( + `${stage.name}: called next ${branches.length} times and received releasables ` + + `it did not declare consuming: ${undeclared.join(', ')}`, + ); + } + } + + const handedUp = handOn(produced, pass!.response, stage.name, 'up', scope); + current = handedUp; + + // Consuming a response key takes ownership of every value received there. + // Release values absent from the returned record across all branches; + // ownership follows identity even when a resource changes keys. + const kept = new Set(Object.values(handedUp).filter(isOwned)); + for (const branch of branches) { + for (const key of pass!.response.consumes) { + const value = branch[key]; + if (isOwned(value) && !kept.has(value)) await release(value, scope); + } + } + + await scope.emit({ type: 'stage.leaved', stageId, facts: handedUp }); + return handedUp; + } catch (error) { + const failure = captureFailure(error, current, scope); + try { + await scope.emit({ type: 'stage.failed', stageId, error: failureSource(failure) }); + } catch (recordingError) { + if (recordingError !== error && recordingError !== failure) { + const combined = new AggregateError([failure, recordingError], 'Pipeline stage failed and recording also failed', { cause: failure }); + throw captureFailure(combined, getFailureFacts(failure) ?? current, scope); + } + } + throw failure; + } +}; + +/** Released once, by whoever gets there first. A stage may release in its own body with + * `await using`, and the runner is the backstop — so it must not drain the same body a + * second time. */ +const release = async (value: Owned, scope: RunScope): Promise => { + if (!scope.outstanding.delete(value)) return; + await value[Symbol.asyncDispose](); +}; + +/** + * Waits for what this run started, and says so loudly when something does not finish. + * + * Per run and not per root: a discarded branch's facts are private to that branch and + * unreachable from the root's record, so a root-level sweep could never see them. Each run + * tears down its own, and a branch nobody adopted still finishes what it began. + */ +const settleDeferred = async (scope: RunScope): Promise => { + const pending = [...scope.deferred.values()]; + if (pending.length === 0) return; + scope.deferred.clear(); + + const outcome = await Promise.allSettled(pending); + const errors = outcome.filter(result => result.status === 'rejected').map(result => result.reason as unknown); + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, 'Pipeline deferred facts failed'); +}; + +const requireFacts = (facts: Facts, keys: readonly string[], who: string): void => { + for (const key of keys) { + if (!(key in facts)) { + throw new Error(`${who} needs ${key}, which it was not given`); + } + } +}; + +const requireEntry = (target: Pipeline, handed: Facts, who: string): void => + requireFacts(handed, target.entryNeeds.map(String), `${who}: ${target.name}`); + +/** Each stage receives a logger tied to its execution position. With a dump open its lines land in that stage's record as well as going to + * the global sink, which is why `stage.log` is one of the dump's events. + * + * The top-level field record is copied and frozen; descendants remain shared + * immutable values supplied by the producer, as with facts. Awaiting the global + * logger before the run sink preserves each line's output order. + */ +const loggerFor = (services: object, name: string, stageId: number, scope: RunScope): import('./stage.ts').Use['log'] => { + const sink = (services as RunServices).log; + const line = (level: LogLevel) => + async (message: string, fields?: Readonly>): Promise => { + // The global sink is handed the stage as an ordinary field, because a `Logger` has + // nowhere else to put it; the record gets it as `context`, which is where a logg + // entry carries the same thing. + const entry = { + type: 'stage.log', + stageId, + level, + context: name, + message, + ...(fields === undefined ? {} : { fields: Object.freeze({ ...fields }) }), + } as const; + await sink?.[level](message, { stage: name, ...fields }); + await scope.emit(entry); + }; + return { debug: line('debug'), info: line('info'), warn: line('warn'), error: line('error') }; +}; + +export interface RunResult { + readonly facts: Exit; + /** + * What the run still owns. Release is not cancel — this drains to end-of-stream, because + * an aborted connection cannot be reused and leaves its billing unsettled — so the + * caller decides *when*, after the response is on its way. Awaiting it before handing + * the answer back would eat a streaming family's frames. + */ + readonly drain: () => Promise; +} + +/** + * The entry to the pipeline system. An HTTP handler, a WebSocket handler and a stage + * launching a sub-request all call this one function, and being outside the system is the + * ordinary position of a caller — which is what "requires no capability" means. The + * services are the prologue's own wiring, built per run and fixed for it. + */ +export const run = async ( + pipeline: Pipeline, + initial: Entry, + services: S, +): Promise> => { + // With no dump sink resolved in the prologue, none of the recording happens. + const sink = services.dump; + const scope: RunScope = { + emit: async event => { await sink?.(event); }, + outstanding: new Set(), + deferred: new Map, Promise>(), + failures: new WeakMap(), + parentStageId: null, + nextStageId: 1, + }; + scopeOwners.set(scope, {}); + let current = initial as Facts; + let draining: Promise | undefined; + const drain = (): Promise => { + if (draining !== undefined) return draining; + draining = (async () => { + const errors: unknown[] = []; + for (const value of scope.outstanding) { + try { await release(value, scope); } catch (error) { errors.push(captureFailure(error, current, scope)); } + } + try { await settleDeferred(scope); } catch (error) { errors.push(captureFailure(error, current, scope)); } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, 'Pipeline resource cleanup failed'); + })(); + return draining; + }; + + try { + Object.freeze(initial); + for (const value of Object.values(initial)) registerFact(value, initial as Facts, scope); + for (const [key, value] of Object.entries(initial)) assertHandedOver(`prologue ${key}`, value); + requireEntry(pipeline as unknown as Pipeline, initial as Facts, `run(${pipeline.name})`); + const facts = (await pipeline.enter(initial, services, scope)) as unknown as Exit; + current = facts as Facts; + return { facts, drain }; + } catch (error) { + const failure = captureFailure(error, initial as Facts, scope); + current = getFailureFacts(failure) ?? initial as Facts; + // A run that threw has nothing left to hand back, so there is nothing to defer for: + // draining here is what stops a bug from abandoning every body opened below it. The + // events are already with the sink, so the dump of the run that 500'd survives. + try { + await drain(); + } catch (cleanupError) { + if (cleanupError === failure) throw failure; + const combined = new AggregateError([failure, cleanupError], 'Pipeline failed and resource cleanup also failed', { cause: failure }); + throw captureFailure(combined, getFailureFacts(failure) ?? initial as Facts, scope); + } + throw failure; + } +}; diff --git a/packages/pipeline/src/stage.ts b/packages/pipeline/src/stage.ts new file mode 100644 index 0000000000..f82b42ec62 --- /dev/null +++ b/packages/pipeline/src/stage.ts @@ -0,0 +1,233 @@ +// A stage declares where control may go, and it may declare more than one place. There +// is no name for a *kind* of stage; the fields are the declaration. +// +// return: { provides } answer here, never go down +// through: { request, response } go down the rest of this pipeline +// into: { request, response } hand off to another pipeline, so it is last +// +// `through` and `into` are mutually exclusive, and that follows from what they mean: an +// `into` stage is last, and a last stage has nothing left to go through. So the legal +// shapes are five — `return`, `through`, `through` + `return`, `into`, `into` + `return` +// — and `defineStage`'s overloads admit exactly those. + +import type { Facts, Handed } from './facts.ts'; + +/** Trait one: answer here. Only `provides`, because nothing came back to need or + * consume — the facts below never existed. */ +export type ReturnDecl = { + readonly provides: readonly (keyof Rs)[]; +}; + +/** Trait two: go down. Four slices, two per direction, named the same way on both sides: + * what arrives, and what this stage hands on. Down — `Rq` arrives, `Down` is handed on. + * Up — `Up` arrives, `Rs` is handed on. A translation consumes on the way back as well + * as on the way down, so one type per direction would not be enough. */ +export type PassDecl = { + readonly request: { + readonly needs: readonly (keyof Rq)[]; + readonly consumes: readonly (keyof Rq)[]; + readonly provides: readonly (keyof Down)[]; + }; + readonly response: { + readonly needs: readonly (keyof Up)[]; + /** Also ownership: everything this stage receives at these keys, from every `next` + * call, is its to release. A key it also `provides` is one it hands onward, and + * ownership of that one goes with it. */ + readonly consumes: readonly (keyof Up)[]; + readonly provides: readonly (keyof Rs)[]; + }; +}; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +/** One line, in the shape `@guiiai/logg` writes: a level, the context that produced it, the + * message, and whatever structured fields came with it. The pipeline defines the shape it + * needs and never the implementation, so a foundation package stays runtime-independent — + * and a sink that speaks logg can take one of these unchanged. + * + * `context` is the stage's name. A reader with only the log lines in front of it needs to + * know which stage wrote them, and `stageId` alone answers that only for someone holding + * the rest of the record. */ +export interface LogEntry { + readonly level: LogLevel; + readonly context: string; + readonly message: string; + readonly fields?: Readonly>; +} + +/** Structured, to fit a logger like `@guiiai/logg`. The pipeline defines the shape it + * needs and never the implementation, so a foundation package stays runtime-independent. */ +export interface Logger { + debug(message: string, fields?: Readonly>): void | Promise; + info(message: string, fields?: Readonly>): void | Promise; + warn(message: string, fields?: Readonly>): void | Promise; + error(message: string, fields?: Readonly>): void | Promise; +} + +/** Services ride beside `next`, never in the facts: a live handle dumps as nothing, so it + * is not a fact. One container holds every package's services and a stage can only name + * the keys its own slice admits, exactly as with facts. The set is fixed for the run at + * the prologue, and `log` is the one the framework specializes by position — each stage + * gets its own, and with a dump open its lines land in that stage's record. */ +export type Use = S & { + readonly log: { readonly [Level in LogLevel]: (...args: Parameters) => Promise }; +}; + +/** What the prologue may put in the container that the runner itself reads. Everything + * else in `S` is the composition's own and the runner never looks at it. */ +export interface RunServices { + /** The global sink. A stage's lines always reach it. */ + readonly log?: Logger; + /** Resolved by the prologue when this request is being dumped, and absent otherwise — + * which is how recording stays conditional without a mode flag. */ + readonly dump?: (event: import('./dump.ts').Event) => void | Promise; + /** Whatever else the composition wires. The runner reads only the two above. */ + readonly [key: string]: unknown; +} + +export type ThroughNext = + (handed: Handed) => Promise; + +/** One extra parameter, and that is the entire difference between the two ways down. A + * stage that declared `through` cannot name a target: a second argument is a compile + * error at the definition site. */ +export type IntoNext = + (handed: Handed, target: Pipeline) => Promise; + +/** What one run holds, threaded rather than ambient. A module-level context saved and + * restored around an `await` interleaves two concurrent runs — one dump lost, the other + * holding both, one `stageId` issued twice — and a gateway serves concurrent requests by + * definition. */ +export interface RunScope { + /** What this run started and has not finished. Filled at handover, the same moment + * ownership is, because a value only becomes the run's when it enters the record. */ + readonly deferred: Map, Promise>; + /** A no-op when the prologue resolved no dump sink, which is what makes recording + * conditional rather than a mode flag. */ + readonly emit: (event: import('./dump.ts').Event) => Promise; + /** Every owned value accepted at entry or handover and not yet released. */ + readonly outstanding: Set; + readonly failures: WeakMap; + parentStageId: number | null; + nextStageId: number; +} + +export interface Pipeline { + readonly name: string; + /** Derived by `compose` from the stages: what nobody below provides, the caller brings. */ + readonly entryNeeds: readonly (keyof Entry)[]; + readonly enter: (facts: Entry, services: object, scope: RunScope) => Promise; +} + +/** What assembly and the runner hold: the same declarations with the key names as + * strings, plus something callable. Slice types are the author's concern and the + * caller's concern and nobody's in between, so erasing them here is the one widening + * point in the design — and it is what lets a stage written against the gateway's space + * compose beside one written against a provider's, with no variance question to lose. */ +export type ErasedPass = { + readonly request: ErasedSide; + readonly response: ErasedSide; +}; + +export type ErasedSide = { + readonly needs: readonly string[]; + readonly consumes: readonly string[]; + readonly provides: readonly string[]; +}; + +/** `ThroughNext` and `IntoNext` collapsed: an untyped record, and an optional target + * that only an `into` stage ever passes. */ +export type Descend = (handed: Facts, target?: Pipeline) => Promise; + +/** The arity is variadic because it really is two arities: a stage that declared no way + * down is handed no continuation at all — that is what shape 1 means — so it is called + * `(facts, use)`, and every other shape is called `(facts, next, use)`. Which one applies + * is read off the same declared fields that select the overload. */ +export type Stage = { + readonly name: string; + readonly return?: { readonly provides: readonly string[] }; + readonly through?: ErasedPass; + readonly into?: ErasedPass; + readonly execute: (facts: Facts, ...rest: readonly never[]) => Promise; +}; + +// One definer. Which fields are present selects an overload, and the overload fixes what +// `next` accepts — no marker boolean and no conditional type. The runtime is identity; +// the whole job is threading inference across the fields of one object literal. + +// 1. return — answers, never goes down. `execute` is handed no continuation. +export function defineStage(s: { + name: string; + return: ReturnDecl; + execute: (facts: Rq, use: Use) => Promise>; +}): Stage; + +// 2. through + return — may answer, may go down. A cache has this shape. +// +// `Answer` is its own parameter, because what a stage provides when it short-circuits and +// what it provides when it descends are two statements, not one — which is what the +// declaration already says by having two blocks, and what `compose` already checks +// separately. A stage that answers with a key its descend path never carries is ordinary: +// a resolver refusing a request it found no upstream for has exactly that shape. +export function defineStage(s: { + name: string; + through: PassDecl; + return: ReturnDecl; + execute: (facts: Rq, next: ThroughNext, use: Use) => Promise | Handed>; +}): Stage; + +// 3. through — must go down; it cannot answer on its own. +export function defineStage(s: { + name: string; + through: PassDecl; + execute: (facts: Rq, next: ThroughNext, use: Use) => Promise>; +}): Stage; + +// 4. into + return — may answer, or hand off to a named pipeline. `Answer` is separate for +// the same reason as shape 2. +export function defineStage(s: { + name: string; + into: PassDecl; + return: ReturnDecl; + execute: (facts: Rq, next: IntoNext, use: Use) => Promise | Handed>; +}): Stage; + +// 5. into — must hand off. +export function defineStage(s: { + name: string; + into: PassDecl; + execute: (facts: Rq, next: IntoNext, use: Use) => Promise>; +}): Stage; + +export function defineStage(s: object): Stage { + return s as unknown as Stage; +} + +// `transform` fills the `execute` slot. Two layers: `open` holds the per-invocation state +// that spans both directions, and each direction is one function returning the record it +// hands on. Either side may be omitted, and an omitted one passes through unchanged — +// which is the point, because today's interceptors call the continuation 107 times across +// 68 files and 50 of those calls are a bare `return run();`. +// +// A stream, a value and a failure sit at one key, so telling them apart is reading a +// value, and the stage does that where it needs to. There is no outcome-shape dispatch. + +export type Open = + (use: Use) => { + /** `open` does no I/O; its only job is the closure, so state that spans both + * directions has somewhere to live that is neither a fact nor the stage — which is + * shared across concurrent runs. Async belongs in the two directions. */ + readonly request?: (facts: Rq) => Handed | Promise>; + readonly response?: (facts: Up) => Handed | Promise>; + }; + +export const transform = ( + open: Open, +) => async (facts: Rq, next: ThroughNext, use: Use): Promise> => { + const { request, response } = open(use); + const back = await next(request === undefined ? (facts as unknown as Handed) : await request(facts)); + if (response === undefined) return back as unknown as Handed; + // The record it hands up goes to the runner, which is where the handover gate lives — + // there are two call sites and this is not a third. + return await response(back); +}; diff --git a/packages/pipeline/tsconfig.json b/packages/pipeline/tsconfig.json new file mode 100644 index 0000000000..4cffc03465 --- /dev/null +++ b/packages/pipeline/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["vitest.config.ts", "src/**/*.ts", "__tests__/**/*.ts"] +} diff --git a/packages/pipeline/vitest.config.ts b/packages/pipeline/vitest.config.ts new file mode 100644 index 0000000000..bf72d73952 --- /dev/null +++ b/packages/pipeline/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +import { requireNamedTestFiles } from '../../tools/src/verification-scope.ts'; + +requireNamedTestFiles(); + +export default defineConfig({ + test: { + include: ['__tests__/**/*_test.ts'], + environment: 'node', + }, +}); diff --git a/packages/platform/__tests__/log-stream-http_test.ts b/packages/platform/__tests__/log-stream-http_test.ts new file mode 100644 index 0000000000..5a89fc2411 --- /dev/null +++ b/packages/platform/__tests__/log-stream-http_test.ts @@ -0,0 +1,113 @@ +import { expect, test } from 'vitest'; + +import { LogStreamTruncatedError, readLogStream, serveLogStream } from '../src/log-stream-http.ts'; +import type { LogStream } from '../src/log-stream.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +// A stream whose read yields exactly what it was given, and optionally throws instead of +// finishing. Only `read` is exercised here — the framing is about how a read's outcome +// reaches a client, not about storage. +const streamOf = (chunks: readonly Uint8Array[], failAfter?: number): LogStream => ({ + append: () => Promise.reject(new Error('not used')), + end: () => Promise.reject(new Error('not used')), + read: () => ({ + async *[Symbol.asyncIterator]() { + for (const [index, chunk] of chunks.entries()) { + if (failAfter !== undefined && index === failAfter) throw new Error('interrupted'); + yield chunk; + } + if (failAfter !== undefined && failAfter >= chunks.length) throw new Error('interrupted'); + }, + }), +}); + +const collect = async (response: Response): Promise => { + const out: Uint8Array[] = []; + for await (const chunk of readLogStream(response)) out.push(chunk); + return out; +}; + +const bytes = (...values: number[]): Uint8Array => new Uint8Array(values); + +test('a completed read round-trips its chunks', async () => { + const chunks = [bytes(1, 2, 3), bytes(), bytes(4)]; + const response = serveLogStream(streamOf(chunks), 0, new AbortController().signal); + + // The empty chunk carries nothing and is not framed: a zero length is the terminator. + assertEquals(await collect(response), [bytes(1, 2, 3), bytes(4)]); +}); + +test('framing pulls only the chunk requested by the HTTP reader', async () => { + let pulled = 0; + const stream: LogStream = { + append: async () => {}, end: async () => {}, + read: () => ({ + async *[Symbol.asyncIterator]() { + for (let index = 0; index < 100; index++) { pulled++; yield bytes(index); } + }, + }), + }; + const response = serveLogStream(stream, 0, new AbortController().signal); + await Promise.resolve(); + expect(pulled).toBe(0); + const reader = response.body!.getReader(); + expect((await reader.read()).value).toEqual(bytes(0, 0, 0, 1)); + expect(pulled).toBe(1); + expect((await reader.read()).value).toEqual(bytes(0)); + expect(pulled).toBe(1); + await reader.cancel(); +}); + +test('an interrupted read ends the body without its terminator, and the reader says so', async () => { + const response = serveLogStream(streamOf([bytes(1, 2, 3)], 1), 0, new AbortController().signal); + + // This is the case the framing exists for. `controller.error()` reaches a client as a clean + // end, so the absence of the terminating frame is the only thing that can carry it. + await expect(collect(response)).rejects.toThrow(LogStreamTruncatedError); +}); + +test('a body that stops part-way through a frame is caught too', async () => { + // Four bytes of header declaring three, then two. A terminal marker inside the content + // could not see this; a length prefix can. + const partial = bytes(0, 0, 0, 3, 9, 9); + const truncated = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(partial); + controller.close(); + }, + })); + + await expect(collect(truncated)).rejects.toThrow(/ended 2 bytes into a 3-byte frame/); +}); + +test('frames are reassembled across arbitrary network boundaries', async () => { + // One frame of three bytes plus the terminator, delivered one byte at a time — every + // boundary lands inside a header or inside a frame. + const framed = [0, 0, 0, 3, 7, 8, 9, 0, 0, 0, 0]; + const body = new ReadableStream({ + start(controller) { + for (const byte of framed) controller.enqueue(bytes(byte)); + controller.close(); + }, + }); + + assertEquals(await collect(new Response(body)), [bytes(7, 8, 9)]); +}); + +test('a chunk boundary may split a UTF-8 sequence, and the reader keeps the halves apart', async () => { + // The reason `read()` yields bytes and never strings. `é` is two bytes; a writer that + // chunked between them must not have them silently repaired or merged. + const text = new TextEncoder().encode('café'); + const response = serveLogStream(streamOf([text.subarray(0, 4), text.subarray(4)]), 0, new AbortController().signal); + + const received = await collect(response); + assertEquals(received.map(chunk => chunk.byteLength), [4, 1]); + + const decoder = new TextDecoder(); + const decoded = received.map((chunk, index) => decoder.decode(chunk, { stream: index < received.length - 1 })).join(''); + assertEquals(decoded, 'café'); +}); + +test('a response with no body is a truncation rather than an empty stream', async () => { + await expect(collect(new Response(null))).rejects.toThrow(LogStreamTruncatedError); +}); diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 4c5722c610..35f3fcd101 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -7,6 +7,8 @@ export * from './fetch.ts'; export * from './file-store.ts'; export * from './image-cache-store.ts'; export * from './image-processor.ts'; +export * from './log-stream.ts'; +export * from './log-stream-http.ts'; export * from './runtime-kind.ts'; export * from './sha256.ts'; export * from './socket-dial.ts'; diff --git a/packages/platform/src/log-stream-http.ts b/packages/platform/src/log-stream-http.ts new file mode 100644 index 0000000000..2277afcec5 --- /dev/null +++ b/packages/platform/src/log-stream-http.ts @@ -0,0 +1,163 @@ +// Getting a reader over HTTP. +// +// A live stream has no length known in advance, so nothing in the transport tells a reader +// whether the body it just finished was the whole thing. Worse, measured against both the +// Cloudflare edge and local workerd: a Worker response body built from a `ReadableStream` +// that calls `controller.error()` — or whose `pull` throws — arrives at the client as a +// *clean end*. `fetch()` reports `done: true` and `curl` exits 0, indistinguishable from a +// normal close. So deliberately aborting the body signals nothing, and any design relying on +// the reader noticing the truncation relies on something that does not happen. +// +// The transport therefore frames. Each chunk is preceded by its length as a big-endian +// uint32, and a length of zero terminates: +// +// [uint32 len][len bytes] [uint32 len][len bytes] … [00 00 00 00] +// +// This is chunked transfer encoding's own design, chosen over a terminal marker inside the +// content because a marker is a reserved value, and the obligation never to emit it would +// land on whoever produces the content. A length prefix puts the signal beside the bytes +// instead of inside them, so the content stays opaque and nothing needs escaping. It also +// detects strictly more: a marker reveals only a missing tail, while a length prefix also +// catches a body that stops part-way through a frame. +// +// Four bytes rather than two, because a uint16 caps a frame at 65535 — one byte short of the +// 64 KiB segment size — and 4 bytes on a 64 KiB frame is 0.006%. +// +// Reading the finished artifact needs none of this: a stored object has a `Content-Length`, +// so truncation is caught by the HTTP stack. The framing exists precisely because the live +// case has no length to state. Both paths hand the same bytes to the same NDJSON parser. + +import type { LogStream } from './log-stream.ts'; + +const LENGTH_PREFIX_BYTES = 4; + +/** Serves a live read as a framed body. The terminating frame is emitted only where the read + * completed on its own; where it threw, the body simply stops, which is what the reader half + * detects. */ +export const serveLogStream = (stream: LogStream, fromOffset: number, signal: AbortSignal): Response => { + const abort = new AbortController(); + const forwardAbort = () => { abort.abort(signal.reason); }; + if (signal.aborted) forwardAbort(); + else signal.addEventListener('abort', forwardAbort, { once: true }); + const iterator = stream.read(fromOffset, abort.signal)[Symbol.asyncIterator](); + let cancelled = false; + const finish = () => { signal.removeEventListener('abort', forwardAbort); }; + const body = new ReadableStream({ + async pull(controller) { + try { + for (;;) { + const { value: chunk, done } = await iterator.next(); + if (cancelled) return; + if (done) { + controller.enqueue(new Uint8Array(LENGTH_PREFIX_BYTES)); + controller.close(); + finish(); + return; + } + if (chunk.byteLength === 0) continue; + const header = new Uint8Array(LENGTH_PREFIX_BYTES); + new DataView(header.buffer).setUint32(0, chunk.byteLength, false); + controller.enqueue(header); + controller.enqueue(chunk); + return; + } + } catch { + // Ending without the terminator is the whole signal. Erroring the controller would + // reach the client as a clean end, which is the measurement this framing exists for. + if (!cancelled) controller.close(); + finish(); + } + }, + async cancel(reason) { + cancelled = true; + abort.abort(reason); + finish(); + await iterator.return?.(); + }, + }, { highWaterMark: 0 }); + return new Response(body, { headers: { 'content-type': 'application/octet-stream' } }); +}; + +/** Thrown where a framed body ended without its terminator, or part-way through a frame. */ +export class LogStreamTruncatedError extends Error { + constructor(message: string) { + super(message); + this.name = 'LogStreamTruncatedError'; + } +} + +/** + * Reads a framed body back into the chunks it carried. + * + * Chunk boundaries here are the writer's, not the network's: a frame is reassembled from + * however many pieces the body arrived in. What a consumer gets is what the stream yielded. + */ +export const readLogStream = async function* (response: Response): AsyncGenerator { + if (response.body === null) throw new LogStreamTruncatedError('log stream response carried no body'); + + // One growing buffer with a read cursor, compacted only when the cursor has passed + // everything held. Frames are up to a segment, so this settles at about one segment. + let held = new Uint8Array(0); + let cursor = 0; + let terminated = false; + // The length a header declared while its bytes are still arriving, so a body that stops + // here can say how far into the frame it got rather than how much is in the buffer. + let awaiting: number | null = null; + + const take = (count: number): Uint8Array | null => { + if (held.byteLength - cursor < count) return null; + const slice = held.subarray(cursor, cursor + count); + cursor += count; + return slice; + }; + + for await (const piece of iterate(response.body)) { + const remaining = held.subarray(cursor); + const next = new Uint8Array(remaining.byteLength + piece.byteLength); + next.set(remaining); + next.set(piece, remaining.byteLength); + held = next; + cursor = 0; + + while (!terminated) { + if (awaiting === null) { + const header = take(LENGTH_PREFIX_BYTES); + if (header === null) break; + const length = new DataView(header.buffer, header.byteOffset, header.byteLength).getUint32(0, false); + if (length === 0) { + terminated = true; + break; + } + awaiting = length; + } + const frame = take(awaiting); + if (frame === null) break; + awaiting = null; + yield frame.slice(); + } + if (terminated) break; + } + + if (!terminated) { + throw new LogStreamTruncatedError( + awaiting === null + ? 'log stream body ended without its terminating frame' + : `log stream body ended ${held.byteLength - cursor} bytes into a ${awaiting}-byte frame`, + ); + } +}; + +const iterate = (body: ReadableStream): AsyncIterable => ({ + async *[Symbol.asyncIterator]() { + const reader = body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + if (value !== undefined) yield value; + } + } finally { + reader.releaseLock(); + } + }, +}); diff --git a/packages/platform/src/log-stream.ts b/packages/platform/src/log-stream.ts new file mode 100644 index 0000000000..fb0798e357 --- /dev/null +++ b/packages/platform/src/log-stream.ts @@ -0,0 +1,108 @@ +// The transient carrier for one dump while it is being produced. One writer appends bytes; +// any number of readers attach at any moment and read from wherever they ask, following the +// tail as it grows. When the run finishes the stream is redundant — the durable artifact is +// already stored — and it expires. +// +// Deliberately not a durable store. Durability is the writer's job and happens on a separate +// path, so losing a stream costs the live view of one run and nothing else. +// +// The name is CloudWatch Logs': a log stream is a sequence of events sharing one source, +// creatable, readable from the head, followable live, and expiring on a retention policy. + +/** + * A stream of opaque bytes. + * + * It carries bytes rather than records on purpose. The dump layer serializes an entry to an + * NDJSON line and appends `line + "\n"`; this never parses one and does not know what NDJSON + * is. Keeping records out is what leaves an implementation free to split and merge at any + * byte position, and it makes a stream's bytes identical to the durable artifact's, so the + * two paths can be checked against each other. + */ +export interface LogStream { + /** + * Appends `bytes` so that they occupy the stream from `atOffset`. + * + * The offset is not decoration. An append that failed cannot be told apart from one that + * landed and lost its acknowledgement, so the writer retries; without a position a retry + * would splice duplicate bytes into the middle of a line. + * + * A store resolves the write by position alone: it writes only the part of `bytes` beyond + * the current length, and **assumes the overlapping range is identical** rather than + * comparing it. There is one writer and it retries the same bytes at the same offset, so a + * mismatch is reachable only through a bug in that writer — and reading back to compare on + * every retry would spend real work defending a position that cannot be defended. + * + * An `atOffset` *above* the current length leaves a hole, which is not representable, and + * every implementation rejects it loudly. + * + * Working in positions rather than call identity also lets the writer re-chunk between + * attempts: a failed append may be retried inside a larger one that subsumes it. + */ + append(atOffset: number, bytes: Uint8Array): Promise; + + /** Seals the append side. A `read()` in progress drains what remains and completes + * normally, and a reader attaching afterwards still gets everything for as long as the + * stream exists. */ + end(): Promise; + + /** + * Reads from `fromOffset` to the end of the stream, following the tail until `end()`. + * + * Completing normally means the stream ended. Throwing means the read was interrupted. + * Nothing else distinguishes the two, which is why the HTTP binding frames. + * + * `fromOffset` makes an interrupted reader cheap: deployments terminate every reader + * unconditionally, so a reader records the offset just past the last complete line it + * processed and resumes there. The offset lives in the caller, so there is no server-side + * cursor and no seam between backlog and live tail. Zero is the ordinary case. + * + * Chunk boundaries are arbitrary. Yields are `Uint8Array` and never `string`, because a + * split can land in the middle of a UTF-8 sequence and a consumer handed half a code point + * cannot repair it — decode with `TextDecoder` and `{ stream: true }`, and split lines + * yourself. Assuming one chunk is one line is the bug this shape invites. + */ + read(fromOffset: number, signal: AbortSignal): AsyncIterable; +} + +/** Opens streams by id. A stream is created for one run and expires after it, so there is no + * delete: reclamation is the implementation's own idle rule. */ +export interface LogStreamStore { + open(streamId: string): Promise; + get(streamId: string): Promise; +} + +export const LOG_STREAM_IDLE_MS = 60_000; + +let logStreamStore: LogStreamStore | null = null; + +export const initLogStreamStore = (store: LogStreamStore): void => { + logStreamStore = store; +}; + +export const getLogStreamStore = (): LogStreamStore => { + if (!logStreamStore) throw new Error('LogStreamStore not initialized - call initLogStreamStore() first'); + return logStreamStore; +}; + +/** Thrown when an append would leave a hole. Named so a caller can tell a writer defect from + * a transport failure: the transport failure is worth retrying and this is not. */ +export class LogStreamHoleError extends Error { + constructor(readonly atOffset: number, readonly length: number) { + super(`LogStream append at ${atOffset} would leave a hole: the stream is ${length} bytes long`); + this.name = 'LogStreamHoleError'; + } +} + +export class LogStreamExpiredError extends Error { + constructor() { + super('LogStream expired'); + this.name = 'LogStreamExpiredError'; + } +} + +export class LogStreamEndedError extends Error { + constructor() { + super('LogStream append side is already ended'); + this.name = 'LogStreamEndedError'; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cc98f90e1..d0ded1e4fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: apps/platform-cloudflare: dependencies: + '@floway-dev/dump': + specifier: workspace:* + version: link:../../packages/dump '@floway-dev/gateway': specifier: workspace:* version: link:../../packages/gateway @@ -111,6 +114,9 @@ importers: apps/platform-node: dependencies: + '@floway-dev/dump': + specifier: workspace:* + version: link:../../packages/dump '@floway-dev/gateway': specifier: workspace:* version: link:../../packages/gateway @@ -361,11 +367,32 @@ importers: specifier: ^22 version: 22.19.19 + packages/dump: + dependencies: + '@floway-dev/pipeline': + specifier: workspace:* + version: link:../pipeline + '@floway-dev/platform': + specifier: workspace:* + version: link:../platform + '@floway-dev/protocols': + specifier: workspace:* + version: link:../protocols + '@floway-dev/provider': + specifier: workspace:* + version: link:../provider + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages/gateway: dependencies: '@floway-dev/agent-setup': specifier: workspace:* version: link:../agent-setup + '@floway-dev/dump': + specifier: workspace:* + version: link:../dump '@floway-dev/http': specifier: workspace:* version: link:../http @@ -468,6 +495,15 @@ importers: specifier: workspace:* version: link:../test-utils + packages/pipeline: + dependencies: + '@noble/hashes': + specifier: ^2.2.0 + version: 2.2.0 + '@scure/base': + specifier: ^2.2.0 + version: 2.2.0 + packages/platform: dependencies: '@floway-dev/http': @@ -4128,6 +4164,7 @@ packages: image-size@2.0.2: resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} engines: {node: '>=16.x'} + deprecated: Use latest 2.x please hasBin: true imask@7.6.1: diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc index 332d4d0fd0..e11ccbb031 100644 --- a/wrangler.example.jsonc +++ b/wrangler.example.jsonc @@ -126,6 +126,10 @@ { "name": "EXECUTION_DO", "class_name": "ExecutionDO" + }, + { + "name": "LOG_STREAM_DO", + "class_name": "LogStreamDO" } ] }, @@ -152,6 +156,10 @@ "to": "ExecutionDO" } ] + }, + { + "tag": "v3", + "new_sqlite_classes": ["LogStreamDO"] } ] }