@tanstack/ai-sandbox 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/approvals.js +5 -4
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/bridge-events.js +7 -6
- package/dist/esm/bridge-events.js.map +1 -1
- package/dist/esm/chunk-identity.d.ts +0 -17
- package/dist/esm/chunk-identity.js +28 -5
- package/dist/esm/chunk-identity.js.map +1 -1
- package/dist/esm/testkit/reaper-conformance.d.ts +8 -0
- package/dist/esm/testkit/reaper-conformance.js +8 -3
- package/dist/esm/testkit/reaper-conformance.js.map +1 -1
- package/dist/esm/tool-history.js +4 -5
- package/dist/esm/tool-history.js.map +1 -1
- package/package.json +5 -5
- package/src/approvals.ts +13 -12
- package/src/bridge-events.ts +16 -10
- package/src/chunk-identity.ts +36 -5
- package/src/testkit/reaper-conformance.ts +20 -6
- package/src/tool-history.ts +9 -13
package/dist/esm/approvals.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { evaluateCommand } from "./policy.js";
|
|
2
|
-
import { EventType } from "@tanstack/ai";
|
|
2
|
+
import { EventType, withTanstackMetadata } from "@tanstack/ai";
|
|
3
3
|
//#region src/approvals.ts
|
|
4
4
|
/**
|
|
5
5
|
* Shared interactive-approval logic for harness adapters.
|
|
@@ -51,7 +51,7 @@ function resolveApproval(input) {
|
|
|
51
51
|
}
|
|
52
52
|
/** Build the AG-UI `approval-requested` CUSTOM event for a harness action. */
|
|
53
53
|
function buildApprovalRequestedEvent(input) {
|
|
54
|
-
return {
|
|
54
|
+
return withTanstackMetadata({
|
|
55
55
|
type: EventType.CUSTOM,
|
|
56
56
|
name: APPROVAL_REQUESTED_EVENT,
|
|
57
57
|
value: {
|
|
@@ -59,10 +59,11 @@ function buildApprovalRequestedEvent(input) {
|
|
|
59
59
|
title: input.title,
|
|
60
60
|
...input.detail ?? {}
|
|
61
61
|
},
|
|
62
|
-
timestamp: Date.now()
|
|
62
|
+
timestamp: Date.now()
|
|
63
|
+
}, {
|
|
63
64
|
threadId: input.threadId,
|
|
64
65
|
runId: input.runId
|
|
65
|
-
};
|
|
66
|
+
});
|
|
66
67
|
}
|
|
67
68
|
//#endregion
|
|
68
69
|
export { APPROVAL_REQUESTED_EVENT, approvalId, buildApprovalRequestedEvent, resolveApproval };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"approvals.js","names":[],"sources":["../../src/approvals.ts"],"sourcesContent":["/**\n * Shared interactive-approval logic for harness adapters.\n *\n * Flow (rides chat()'s existing resume-based approval mechanism):\n * 1. The agent (inside the sandbox) asks to run a risky action; the harness's\n * host-side permission callback fires.\n * 2. `resolveApproval` evaluates the sandbox policy: `allow`/`deny` are final;\n * `ask` consults the client's approval decisions (threaded via\n * `TextOptions.approvals`, keyed by a stable `approvalId`).\n * 3. On `ask` with no decision yet, the adapter emits an `approval-requested`\n * CUSTOM event (carrying the `approvalId`) and denies the action this turn.\n * The client shows UI, then re-runs chat() with the decision in the message;\n * the engine surfaces it as `approvals`, and the next run allows it.\n *\n * `approvalId` is stable for a given (provider, kind, target) so a client grant\n * matches the same action on the resumed run.\n */\nimport { EventType } from '@tanstack/ai'\nimport { evaluateCommand } from './policy'\nimport type { SandboxPolicy } from './policy'\nimport type { StreamChunk } from '@tanstack/ai'\n\n/** CUSTOM event name emitted when a harness action needs client approval. */\nexport const APPROVAL_REQUESTED_EVENT = 'approval-requested'\n\n/** A stable, opaque approval id for a harness action. */\nexport function approvalId(input: {\n provider: string\n kind: 'command' | 'fileWrite' | 'network' | 'tool'\n target: string\n}): string {\n return `${input.provider}:${input.kind}:${input.target}`\n}\n\nexport interface ResolveApprovalInput {\n policy: SandboxPolicy | undefined\n /** Client approval decisions, keyed by `approvalId`. */\n approvals: ReadonlyMap<string, boolean> | undefined\n /** Precomputed approval id for this action. */\n id: string\n /** A shell command to match against `policy.commands`. */\n command?: string\n /** Named workspace scripts for policy alias resolution. */\n scripts?: Record<string, string>\n /** A coarse capability to match against `policy.capabilities`. */\n capability?: 'fileWrite' | 'network'\n}\n\nexport interface ApprovalOutcome {\n decision: 'allow' | 'deny'\n /** True when policy said `ask` and the client hasn't decided yet. */\n needsApproval: boolean\n}\n\n/** Resolve a harness permission request against policy + client approvals. */\nexport function resolveApproval(input: ResolveApprovalInput): ApprovalOutcome {\n const base =\n input.command !== undefined\n ? evaluateCommand(input.command, input.policy, input.scripts)\n : input.capability !== undefined\n ? (input.policy?.capabilities?.[input.capability] ??\n input.policy?.default ??\n 'ask')\n : (input.policy?.default ?? 'ask')\n\n if (base === 'allow') return { decision: 'allow', needsApproval: false }\n if (base === 'deny') return { decision: 'deny', needsApproval: false }\n\n // base === 'ask' — consult the client's decision.\n const granted = input.approvals?.get(input.id)\n if (granted === true) return { decision: 'allow', needsApproval: false }\n if (granted === false) return { decision: 'deny', needsApproval: false }\n return { decision: 'deny', needsApproval: true }\n}\n\n/** Build the AG-UI `approval-requested` CUSTOM event for a harness action. */\nexport function buildApprovalRequestedEvent(input: {\n approvalId: string\n title: string\n threadId: string\n runId: string\n detail?: Record<string, unknown>\n}): StreamChunk {\n return {\n
|
|
1
|
+
{"version":3,"file":"approvals.js","names":[],"sources":["../../src/approvals.ts"],"sourcesContent":["/**\n * Shared interactive-approval logic for harness adapters.\n *\n * Flow (rides chat()'s existing resume-based approval mechanism):\n * 1. The agent (inside the sandbox) asks to run a risky action; the harness's\n * host-side permission callback fires.\n * 2. `resolveApproval` evaluates the sandbox policy: `allow`/`deny` are final;\n * `ask` consults the client's approval decisions (threaded via\n * `TextOptions.approvals`, keyed by a stable `approvalId`).\n * 3. On `ask` with no decision yet, the adapter emits an `approval-requested`\n * CUSTOM event (carrying the `approvalId`) and denies the action this turn.\n * The client shows UI, then re-runs chat() with the decision in the message;\n * the engine surfaces it as `approvals`, and the next run allows it.\n *\n * `approvalId` is stable for a given (provider, kind, target) so a client grant\n * matches the same action on the resumed run.\n */\nimport { EventType, withTanstackMetadata } from '@tanstack/ai'\nimport { evaluateCommand } from './policy'\nimport type { SandboxPolicy } from './policy'\nimport type { StreamChunk } from '@tanstack/ai'\n\n/** CUSTOM event name emitted when a harness action needs client approval. */\nexport const APPROVAL_REQUESTED_EVENT = 'approval-requested'\n\n/** A stable, opaque approval id for a harness action. */\nexport function approvalId(input: {\n provider: string\n kind: 'command' | 'fileWrite' | 'network' | 'tool'\n target: string\n}): string {\n return `${input.provider}:${input.kind}:${input.target}`\n}\n\nexport interface ResolveApprovalInput {\n policy: SandboxPolicy | undefined\n /** Client approval decisions, keyed by `approvalId`. */\n approvals: ReadonlyMap<string, boolean> | undefined\n /** Precomputed approval id for this action. */\n id: string\n /** A shell command to match against `policy.commands`. */\n command?: string\n /** Named workspace scripts for policy alias resolution. */\n scripts?: Record<string, string>\n /** A coarse capability to match against `policy.capabilities`. */\n capability?: 'fileWrite' | 'network'\n}\n\nexport interface ApprovalOutcome {\n decision: 'allow' | 'deny'\n /** True when policy said `ask` and the client hasn't decided yet. */\n needsApproval: boolean\n}\n\n/** Resolve a harness permission request against policy + client approvals. */\nexport function resolveApproval(input: ResolveApprovalInput): ApprovalOutcome {\n const base =\n input.command !== undefined\n ? evaluateCommand(input.command, input.policy, input.scripts)\n : input.capability !== undefined\n ? (input.policy?.capabilities?.[input.capability] ??\n input.policy?.default ??\n 'ask')\n : (input.policy?.default ?? 'ask')\n\n if (base === 'allow') return { decision: 'allow', needsApproval: false }\n if (base === 'deny') return { decision: 'deny', needsApproval: false }\n\n // base === 'ask' — consult the client's decision.\n const granted = input.approvals?.get(input.id)\n if (granted === true) return { decision: 'allow', needsApproval: false }\n if (granted === false) return { decision: 'deny', needsApproval: false }\n return { decision: 'deny', needsApproval: true }\n}\n\n/** Build the AG-UI `approval-requested` CUSTOM event for a harness action. */\nexport function buildApprovalRequestedEvent(input: {\n approvalId: string\n title: string\n threadId: string\n runId: string\n detail?: Record<string, unknown>\n}): StreamChunk {\n return withTanstackMetadata(\n {\n type: EventType.CUSTOM,\n name: APPROVAL_REQUESTED_EVENT,\n value: {\n approvalId: input.approvalId,\n title: input.title,\n ...(input.detail ?? {}),\n },\n timestamp: Date.now(),\n },\n { threadId: input.threadId, runId: input.runId },\n ) as StreamChunk\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAuBA,IAAa,2BAA2B;;AAGxC,SAAgB,WAAW,OAIhB;CACT,OAAO,GAAG,MAAM,SAAS,GAAG,MAAM,KAAK,GAAG,MAAM;AAClD;;AAuBA,SAAgB,gBAAgB,OAA8C;CAC5E,MAAM,OACJ,MAAM,YAAY,KAAA,IACd,gBAAgB,MAAM,SAAS,MAAM,QAAQ,MAAM,OAAO,IAC1D,MAAM,eAAe,KAAA,IAClB,MAAM,QAAQ,eAAe,MAAM,eACpC,MAAM,QAAQ,WACd,QACC,MAAM,QAAQ,WAAW;CAElC,IAAI,SAAS,SAAS,OAAO;EAAE,UAAU;EAAS,eAAe;CAAM;CACvE,IAAI,SAAS,QAAQ,OAAO;EAAE,UAAU;EAAQ,eAAe;CAAM;CAGrE,MAAM,UAAU,MAAM,WAAW,IAAI,MAAM,EAAE;CAC7C,IAAI,YAAY,MAAM,OAAO;EAAE,UAAU;EAAS,eAAe;CAAM;CACvE,IAAI,YAAY,OAAO,OAAO;EAAE,UAAU;EAAQ,eAAe;CAAM;CACvE,OAAO;EAAE,UAAU;EAAQ,eAAe;CAAK;AACjD;;AAGA,SAAgB,4BAA4B,OAM5B;CACd,OAAO,qBACL;EACE,MAAM,UAAU;EAChB,MAAM;EACN,OAAO;GACL,YAAY,MAAM;GAClB,OAAO,MAAM;GACb,GAAI,MAAM,UAAU,CAAC;EACvB;EACA,WAAW,KAAK,IAAI;CACtB,GACA;EAAE,UAAU,MAAM;EAAU,OAAO,MAAM;CAAM,CACjD;AACF"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EventType } from "@tanstack/ai";
|
|
1
|
+
import { EventType, withTanstackMetadata } from "@tanstack/ai";
|
|
2
2
|
//#region src/bridge-events.ts
|
|
3
3
|
/**
|
|
4
4
|
* Helpers that let a harness adapter surface custom events emitted by BRIDGED
|
|
@@ -34,15 +34,16 @@ function createBridgeEventChannel(meta) {
|
|
|
34
34
|
return {
|
|
35
35
|
emitCustomEvent(eventName, value) {
|
|
36
36
|
if (closed) return;
|
|
37
|
-
buffer.push({
|
|
37
|
+
buffer.push(withTanstackMetadata({
|
|
38
38
|
type: EventType.CUSTOM,
|
|
39
39
|
name: eventName,
|
|
40
40
|
value,
|
|
41
|
-
timestamp: Date.now()
|
|
41
|
+
timestamp: Date.now()
|
|
42
|
+
}, {
|
|
42
43
|
model: meta.model,
|
|
43
|
-
...meta.threadId !== void 0
|
|
44
|
-
...meta.runId !== void 0
|
|
45
|
-
});
|
|
44
|
+
...meta.threadId !== void 0 ? { threadId: meta.threadId } : {},
|
|
45
|
+
...meta.runId !== void 0 ? { runId: meta.runId } : {}
|
|
46
|
+
}));
|
|
46
47
|
notify?.();
|
|
47
48
|
},
|
|
48
49
|
close() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bridge-events.js","names":[],"sources":["../../src/bridge-events.ts"],"sourcesContent":["/**\n * Helpers that let a harness adapter surface custom events emitted by BRIDGED\n * tools (via {@link ToolBridgeCoreOptions.emitCustomEvent}) on its live output\n * stream.\n *\n * The bridge runs out-of-band from the harness's own event stream, so a bridged\n * tool's progress/console events have no path to the client on their own. An\n * adapter creates a {@link BridgeEventChannel}, hands its `emitCustomEvent` to\n * the bridge provisioner, and {@link mergeChunkStreams | merges} the channel's\n * stream into its translated output — so events interleave live while the agent\n * runs (e.g. code mode's `code_mode:console` logs during a long execution).\n */\nimport { EventType } from '@tanstack/ai'\nimport type { StreamChunk } from '@tanstack/ai'\n\nexport interface BridgeEventChannel {\n /** Pass as the bridge's `emitCustomEvent`; buffers a CUSTOM chunk for the stream. */\n emitCustomEvent: (eventName: string, value: Record<string, unknown>) => void\n /** Live CUSTOM-chunk stream; ends after {@link close} once drained. */\n stream: AsyncIterable<StreamChunk>\n /** Stop the stream (call when the run's main output is done). */\n close: () => void\n}\n\n/** Create a channel whose emitted events become CUSTOM {@link StreamChunk}s. */\nexport function createBridgeEventChannel(meta: {\n model: string\n threadId?: string\n runId?: string\n}): BridgeEventChannel {\n const buffer: Array<StreamChunk> = []\n let notify: (() => void) | null = null\n let closed = false\n\n async function* stream(): AsyncIterable<StreamChunk> {\n for (;;) {\n const next = buffer.shift()\n if (next !== undefined) {\n yield next\n continue\n }\n if (closed) return\n await new Promise<void>((resolve) => {\n notify = resolve\n })\n notify = null\n }\n }\n\n return {\n emitCustomEvent(eventName, value) {\n if (closed) return\n buffer.push({\n
|
|
1
|
+
{"version":3,"file":"bridge-events.js","names":[],"sources":["../../src/bridge-events.ts"],"sourcesContent":["/**\n * Helpers that let a harness adapter surface custom events emitted by BRIDGED\n * tools (via {@link ToolBridgeCoreOptions.emitCustomEvent}) on its live output\n * stream.\n *\n * The bridge runs out-of-band from the harness's own event stream, so a bridged\n * tool's progress/console events have no path to the client on their own. An\n * adapter creates a {@link BridgeEventChannel}, hands its `emitCustomEvent` to\n * the bridge provisioner, and {@link mergeChunkStreams | merges} the channel's\n * stream into its translated output — so events interleave live while the agent\n * runs (e.g. code mode's `code_mode:console` logs during a long execution).\n */\nimport { EventType, withTanstackMetadata } from '@tanstack/ai'\nimport type { StreamChunk } from '@tanstack/ai'\n\nexport interface BridgeEventChannel {\n /** Pass as the bridge's `emitCustomEvent`; buffers a CUSTOM chunk for the stream. */\n emitCustomEvent: (eventName: string, value: Record<string, unknown>) => void\n /** Live CUSTOM-chunk stream; ends after {@link close} once drained. */\n stream: AsyncIterable<StreamChunk>\n /** Stop the stream (call when the run's main output is done). */\n close: () => void\n}\n\n/** Create a channel whose emitted events become CUSTOM {@link StreamChunk}s. */\nexport function createBridgeEventChannel(meta: {\n model: string\n threadId?: string\n runId?: string\n}): BridgeEventChannel {\n const buffer: Array<StreamChunk> = []\n let notify: (() => void) | null = null\n let closed = false\n\n async function* stream(): AsyncIterable<StreamChunk> {\n for (;;) {\n const next = buffer.shift()\n if (next !== undefined) {\n yield next\n continue\n }\n if (closed) return\n await new Promise<void>((resolve) => {\n notify = resolve\n })\n notify = null\n }\n }\n\n return {\n emitCustomEvent(eventName, value) {\n if (closed) return\n buffer.push(\n withTanstackMetadata(\n {\n type: EventType.CUSTOM,\n name: eventName,\n value,\n timestamp: Date.now(),\n },\n {\n model: meta.model,\n ...(meta.threadId !== undefined ? { threadId: meta.threadId } : {}),\n ...(meta.runId !== undefined ? { runId: meta.runId } : {}),\n },\n ) as StreamChunk,\n )\n notify?.()\n },\n close() {\n closed = true\n notify?.()\n },\n stream: stream(),\n }\n}\n\n/**\n * Merge a `side` chunk stream into a `base` chunk stream, yielding from whichever\n * settles first. Terminates when `base` ends (the run is over), then releases the\n * side iterator — so a never-ending channel (until closed) doesn't hang the merge.\n */\nexport async function* mergeChunkStreams(\n base: AsyncIterable<StreamChunk>,\n side: AsyncIterable<StreamChunk>,\n): AsyncIterable<StreamChunk> {\n const baseIt = base[Symbol.asyncIterator]()\n const sideIt = side[Symbol.asyncIterator]()\n let baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r }))\n let sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r }))\n let sideLive = true\n try {\n for (;;) {\n const winner = await Promise.race(\n sideLive ? [baseNext, sideNext] : [baseNext],\n )\n if (winner.from === 'base') {\n if (winner.r.done) return\n yield winner.r.value\n baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r }))\n } else if (winner.r.done) {\n sideLive = false\n } else {\n yield winner.r.value\n sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r }))\n }\n }\n } finally {\n // Fire-and-forget: do NOT await the side return. The channel generator is\n // suspended on a promise that only `close()` resolves, and `close()` runs in\n // the adapter's `finally` AFTER this merge completes — awaiting here would\n // deadlock. The adapter's `close()` lets the generator unwind afterwards.\n const baseReturn = baseIt.return?.(undefined)\n if (baseReturn) void baseReturn.catch(() => {})\n const sideReturn = sideIt.return?.(undefined)\n if (sideReturn) void sideReturn.catch(() => {})\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAyBA,SAAgB,yBAAyB,MAIlB;CACrB,MAAM,SAA6B,CAAC;CACpC,IAAI,SAA8B;CAClC,IAAI,SAAS;CAEb,gBAAgB,SAAqC;EACnD,SAAS;GACP,MAAM,OAAO,OAAO,MAAM;GAC1B,IAAI,SAAS,KAAA,GAAW;IACtB,MAAM;IACN;GACF;GACA,IAAI,QAAQ;GACZ,MAAM,IAAI,SAAe,YAAY;IACnC,SAAS;GACX,CAAC;GACD,SAAS;EACX;CACF;CAEA,OAAO;EACL,gBAAgB,WAAW,OAAO;GAChC,IAAI,QAAQ;GACZ,OAAO,KACL,qBACE;IACE,MAAM,UAAU;IAChB,MAAM;IACN;IACA,WAAW,KAAK,IAAI;GACtB,GACA;IACE,OAAO,KAAK;IACZ,GAAI,KAAK,aAAa,KAAA,IAAY,EAAE,UAAU,KAAK,SAAS,IAAI,CAAC;IACjE,GAAI,KAAK,UAAU,KAAA,IAAY,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC;GAC1D,CACF,CACF;GACA,SAAS;EACX;EACA,QAAQ;GACN,SAAS;GACT,SAAS;EACX;EACA,QAAQ,OAAO;CACjB;AACF;;;;;;AAOA,gBAAuB,kBACrB,MACA,MAC4B;CAC5B,MAAM,SAAS,KAAK,OAAO,cAAc,CAAC;CAC1C,MAAM,SAAS,KAAK,OAAO,cAAc,CAAC;CAC1C,IAAI,WAAW,OAAO,KAAK,CAAC,CAAC,MAAM,OAAO;EAAE,MAAM;EAAiB;CAAE,EAAE;CACvE,IAAI,WAAW,OAAO,KAAK,CAAC,CAAC,MAAM,OAAO;EAAE,MAAM;EAAiB;CAAE,EAAE;CACvE,IAAI,WAAW;CACf,IAAI;EACF,SAAS;GACP,MAAM,SAAS,MAAM,QAAQ,KAC3B,WAAW,CAAC,UAAU,QAAQ,IAAI,CAAC,QAAQ,CAC7C;GACA,IAAI,OAAO,SAAS,QAAQ;IAC1B,IAAI,OAAO,EAAE,MAAM;IACnB,MAAM,OAAO,EAAE;IACf,WAAW,OAAO,KAAK,CAAC,CAAC,MAAM,OAAO;KAAE,MAAM;KAAiB;IAAE,EAAE;GACrE,OAAO,IAAI,OAAO,EAAE,MAClB,WAAW;QACN;IACL,MAAM,OAAO,EAAE;IACf,WAAW,OAAO,KAAK,CAAC,CAAC,MAAM,OAAO;KAAE,MAAM;KAAiB;IAAE,EAAE;GACrE;EACF;CACF,UAAU;EAKR,MAAM,aAAa,OAAO,SAAS,KAAA,CAAS;EAC5C,IAAI,YAAY,WAAgB,YAAY,CAAC,CAAC;EAC9C,MAAM,aAAa,OAAO,SAAS,KAAA,CAAS;EAC5C,IAAI,YAAY,WAAgB,YAAY,CAAC,CAAC;CAChD;AACF"}
|
|
@@ -11,23 +11,6 @@ import { StreamChunk } from '@tanstack/ai';
|
|
|
11
11
|
* seeded fresh for every call to this factory.
|
|
12
12
|
*/
|
|
13
13
|
export declare function createRunScopedIdGen(runId: string): () => string;
|
|
14
|
-
/**
|
|
15
|
-
* A stable, order-independent identity for a chunk, excluding wall-clock
|
|
16
|
-
* fields. Used to recognize the chunks a previous host already appended.
|
|
17
|
-
*
|
|
18
|
-
* - **Key-order independent**: object keys are sorted before stringifying, so
|
|
19
|
-
* a JSON round trip through the journal (which does not preserve key order)
|
|
20
|
-
* cannot spuriously diverge.
|
|
21
|
-
* - **Recurses into nested arrays and objects**: tool-call arguments are
|
|
22
|
-
* nested, and a shallow fingerprint would miss a changed argument.
|
|
23
|
-
* - **Excludes exactly `VOLATILE_FIELDS`** (`timestamp`) — everything else
|
|
24
|
-
* participates, including fields whose value is `undefined`.
|
|
25
|
-
* - **Distinguishes present-but-`undefined` from absent**: `undefined` is
|
|
26
|
-
* encoded as the sentinel string `"__undefined__"` rather than dropped, so
|
|
27
|
-
* `{a: undefined}` and `{}` do not collide. A translator emitting an
|
|
28
|
-
* explicit `undefined` is a different chunk shape and must fingerprint
|
|
29
|
-
* differently.
|
|
30
|
-
*/
|
|
31
14
|
export declare function chunkFingerprint(chunk: StreamChunk): string;
|
|
32
15
|
/**
|
|
33
16
|
* {@link chunkFingerprint} with the chunk's own `threadId` also excluded.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSpecTopLevelKey, tanstackMetadata } from "@tanstack/ai/adapter-internals";
|
|
1
2
|
//#region src/chunk-identity.ts
|
|
2
3
|
/**
|
|
3
4
|
* A deterministic id generator scoped to one run.
|
|
@@ -59,16 +60,36 @@ function stableStringify(value, dropped) {
|
|
|
59
60
|
* cannot spuriously diverge.
|
|
60
61
|
* - **Recurses into nested arrays and objects**: tool-call arguments are
|
|
61
62
|
* nested, and a shallow fingerprint would miss a changed argument.
|
|
62
|
-
* - **Excludes
|
|
63
|
-
*
|
|
63
|
+
* - **Excludes `timestamp` and leftover adapter extras.** Spec keys
|
|
64
|
+
* participate, including `undefined` values. `metadata.tanstack` is dropped
|
|
65
|
+
* so stored spec chunks match live adapter yields.
|
|
64
66
|
* - **Distinguishes present-but-`undefined` from absent**: `undefined` is
|
|
65
67
|
* encoded as the sentinel string `"__undefined__"` rather than dropped, so
|
|
66
68
|
* `{a: undefined}` and `{}` do not collide. A translator emitting an
|
|
67
69
|
* explicit `undefined` is a different chunk shape and must fingerprint
|
|
68
70
|
* differently.
|
|
69
71
|
*/
|
|
72
|
+
function fingerprintableChunk(chunk) {
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const [key, value] of Object.entries(chunk)) {
|
|
75
|
+
if (key === "timestamp") continue;
|
|
76
|
+
if (!isSpecTopLevelKey(chunk.type, key)) continue;
|
|
77
|
+
if (key === "metadata" && value != null && typeof value === "object") {
|
|
78
|
+
const rest = {};
|
|
79
|
+
for (const [metaKey, metaValue] of Object.entries(value)) {
|
|
80
|
+
if (metaKey === "tanstack") continue;
|
|
81
|
+
rest[metaKey] = metaValue;
|
|
82
|
+
}
|
|
83
|
+
if (Object.keys(rest).length === 0) continue;
|
|
84
|
+
out.metadata = rest;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
out[key] = value;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
70
91
|
function chunkFingerprint(chunk) {
|
|
71
|
-
return stableStringify(chunk, VOLATILE_FIELDS);
|
|
92
|
+
return stableStringify(fingerprintableChunk(chunk), VOLATILE_FIELDS);
|
|
72
93
|
}
|
|
73
94
|
/**
|
|
74
95
|
* {@link chunkFingerprint} with the chunk's own `threadId` also excluded.
|
|
@@ -82,7 +103,7 @@ function chunkFingerprint(chunk) {
|
|
|
82
103
|
* `JournalReplayThreadIdMismatchError` in `align.ts`).
|
|
83
104
|
*/
|
|
84
105
|
function chunkFingerprintIgnoringThreadId(chunk) {
|
|
85
|
-
return stableStringify(chunk, VOLATILE_AND_THREAD_ID);
|
|
106
|
+
return stableStringify(fingerprintableChunk(chunk), VOLATILE_AND_THREAD_ID);
|
|
86
107
|
}
|
|
87
108
|
/**
|
|
88
109
|
* A chunk's own `threadId`, or `undefined` when it carries none.
|
|
@@ -94,7 +115,9 @@ function chunkFingerprintIgnoringThreadId(chunk) {
|
|
|
94
115
|
*/
|
|
95
116
|
function chunkThreadId(chunk) {
|
|
96
117
|
const value = chunk[THREAD_ID_FIELD];
|
|
97
|
-
|
|
118
|
+
if (typeof value === "string") return value;
|
|
119
|
+
const nested = tanstackMetadata(chunk)?.threadId;
|
|
120
|
+
return typeof nested === "string" ? nested : void 0;
|
|
98
121
|
}
|
|
99
122
|
//#endregion
|
|
100
123
|
export { chunkFingerprint, chunkFingerprintIgnoringThreadId, chunkThreadId, createRunScopedIdGen };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chunk-identity.js","names":[],"sources":["../../src/chunk-identity.ts"],"sourcesContent":["/**\n * Deterministic chunk identity — the prerequisite that makes journal replay\n * exact.\n *\n * The design premise is that re-translating a journal prefix reproduces the\n * chunks a previous host already delivered, so a successor can recognize and\n * skip them (see `align.ts`, a later task). Two things in the default path\n * break that premise:\n *\n * 1. `ChatAdapter.generateId()` — `packages/ai/src/activities/chat/adapter.ts:227`,\n * read directly for this task — is:\n *\n * ```ts\n * protected generateId(): string {\n * return `${this.name}-${Date.now()}-${Math.random().toString(36).substring(7)}`\n * }\n * ```\n *\n * and every harness translator mints message ids through it (wired as\n * `genId` in the Grok Build, Claude Code, and Codex text adapters). Both\n * `Date.now()` and `Math.random()` are non-reproducible: replaying the same\n * journal bytes through a second `generateId()` call produces a different\n * id every time, so \"same bytes ⇒ same chunks\" is false on the journaled\n * path today. {@link createRunScopedIdGen} replaces it with a run-scoped\n * counter that has neither a clock nor randomness, so two generators built\n * from the same `runId` always produce the same sequence.\n * 2. Chunks also carry `timestamp: Date.now()`, which cannot be reproduced at\n * all, deterministic id or not. {@link chunkFingerprint} therefore excludes\n * exactly that field — nothing downstream keys on a chunk's timestamp, so\n * leaving it wall-clock is safe, but every other field must participate in\n * the comparison or a real divergence would go undetected.\n */\nimport type { StreamChunk } from '@tanstack/ai'\n\n/**\n * A deterministic id generator scoped to one run.\n *\n * Passed as the harness translators' `genId`, so translating the same journal\n * prefix twice mints the same message ids. The counter is per-generator, so a\n * replay must create a fresh one and start from the journal's first byte —\n * which is exactly what the alignment step (a later task) assumes.\n *\n * No clock, no `Math.random`, no crypto: `next` is the only state, and it is\n * seeded fresh for every call to this factory.\n */\nexport function createRunScopedIdGen(runId: string): () => string {\n let next = 0\n return () => {\n const id = `${runId}-${next}`\n next += 1\n return id\n }\n}\n\n/**\n * Fields excluded from a fingerprint because they are wall-clock and therefore\n * unreproducible. Kept as an explicit set so adding one is a deliberate,\n * reviewable act rather than a silent loosening of the comparison.\n */\nconst VOLATILE_FIELDS: ReadonlySet<string> = new Set(['timestamp'])\n\n/**\n * The conversation id every chunk carries. Excluded from\n * {@link chunkFingerprintIgnoringThreadId} — and ONLY from that variant — so\n * alignment can tell an id-only mismatch from a real content divergence.\n */\nconst THREAD_ID_FIELD = 'threadId'\n\nconst VOLATILE_AND_THREAD_ID: ReadonlySet<string> = new Set([\n ...VOLATILE_FIELDS,\n THREAD_ID_FIELD,\n])\n\n/**\n * `dropped` applies at the TOP LEVEL only (nested calls pass `undefined`).\n * A `threadId` nested inside, say, a tool call's arguments is real content and\n * must keep participating in the comparison.\n */\nfunction stableStringify(\n value: unknown,\n dropped: ReadonlySet<string> | undefined,\n): string {\n if (value === null) return 'null'\n if (Array.isArray(value)) {\n return `[${value.map((item) => stableStringify(item, undefined)).join(',')}]`\n }\n if (typeof value === 'object') {\n const record: Record<string, unknown> = value as Record<string, unknown>\n const keys = Object.keys(record)\n .filter((key) => dropped === undefined || !dropped.has(key))\n .sort()\n const parts = keys.map((key) => {\n const entry = record[key]\n const encoded =\n entry === undefined\n ? '\"__undefined__\"'\n : stableStringify(entry, undefined)\n return `${JSON.stringify(key)}:${encoded}`\n })\n return `{${parts.join(',')}}`\n }\n const encoded = JSON.stringify(value)\n return encoded === undefined ? 'null' : encoded\n}\n\n/**\n * A stable, order-independent identity for a chunk, excluding wall-clock\n * fields. Used to recognize the chunks a previous host already appended.\n *\n * - **Key-order independent**: object keys are sorted before stringifying, so\n * a JSON round trip through the journal (which does not preserve key order)\n * cannot spuriously diverge.\n * - **Recurses into nested arrays and objects**: tool-call arguments are\n * nested, and a shallow fingerprint would miss a changed argument.\n * - **Excludes
|
|
1
|
+
{"version":3,"file":"chunk-identity.js","names":[],"sources":["../../src/chunk-identity.ts"],"sourcesContent":["/**\n * Deterministic chunk identity — the prerequisite that makes journal replay\n * exact.\n *\n * The design premise is that re-translating a journal prefix reproduces the\n * chunks a previous host already delivered, so a successor can recognize and\n * skip them (see `align.ts`, a later task). Two things in the default path\n * break that premise:\n *\n * 1. `ChatAdapter.generateId()` — `packages/ai/src/activities/chat/adapter.ts:227`,\n * read directly for this task — is:\n *\n * ```ts\n * protected generateId(): string {\n * return `${this.name}-${Date.now()}-${Math.random().toString(36).substring(7)}`\n * }\n * ```\n *\n * and every harness translator mints message ids through it (wired as\n * `genId` in the Grok Build, Claude Code, and Codex text adapters). Both\n * `Date.now()` and `Math.random()` are non-reproducible: replaying the same\n * journal bytes through a second `generateId()` call produces a different\n * id every time, so \"same bytes ⇒ same chunks\" is false on the journaled\n * path today. {@link createRunScopedIdGen} replaces it with a run-scoped\n * counter that has neither a clock nor randomness, so two generators built\n * from the same `runId` always produce the same sequence.\n * 2. Chunks also carry `timestamp: Date.now()`, which cannot be reproduced at\n * all, deterministic id or not. {@link chunkFingerprint} therefore excludes\n * exactly that field — nothing downstream keys on a chunk's timestamp, so\n * leaving it wall-clock is safe, but every other field must participate in\n * the comparison or a real divergence would go undetected.\n * 3. Adapter yields still carry leftover TanStack extras (`content`, `args`,\n * `finishReason`). The durability log stores spec chunks. Fingerprints keep\n * only AG-UI spec keys and drop `metadata.tanstack`, so a live adapter yield\n * matches the stored spec chunk.\n */\nimport type { StreamChunk } from '@tanstack/ai'\nimport {\n isSpecTopLevelKey,\n tanstackMetadata,\n} from '@tanstack/ai/adapter-internals'\n\n/**\n * A deterministic id generator scoped to one run.\n *\n * Passed as the harness translators' `genId`, so translating the same journal\n * prefix twice mints the same message ids. The counter is per-generator, so a\n * replay must create a fresh one and start from the journal's first byte —\n * which is exactly what the alignment step (a later task) assumes.\n *\n * No clock, no `Math.random`, no crypto: `next` is the only state, and it is\n * seeded fresh for every call to this factory.\n */\nexport function createRunScopedIdGen(runId: string): () => string {\n let next = 0\n return () => {\n const id = `${runId}-${next}`\n next += 1\n return id\n }\n}\n\n/**\n * Fields excluded from a fingerprint because they are wall-clock and therefore\n * unreproducible. Kept as an explicit set so adding one is a deliberate,\n * reviewable act rather than a silent loosening of the comparison.\n */\nconst VOLATILE_FIELDS: ReadonlySet<string> = new Set(['timestamp'])\n\n/**\n * The conversation id every chunk carries. Excluded from\n * {@link chunkFingerprintIgnoringThreadId} — and ONLY from that variant — so\n * alignment can tell an id-only mismatch from a real content divergence.\n */\nconst THREAD_ID_FIELD = 'threadId'\n\nconst VOLATILE_AND_THREAD_ID: ReadonlySet<string> = new Set([\n ...VOLATILE_FIELDS,\n THREAD_ID_FIELD,\n])\n\n/**\n * `dropped` applies at the TOP LEVEL only (nested calls pass `undefined`).\n * A `threadId` nested inside, say, a tool call's arguments is real content and\n * must keep participating in the comparison.\n */\nfunction stableStringify(\n value: unknown,\n dropped: ReadonlySet<string> | undefined,\n): string {\n if (value === null) return 'null'\n if (Array.isArray(value)) {\n return `[${value.map((item) => stableStringify(item, undefined)).join(',')}]`\n }\n if (typeof value === 'object') {\n const record: Record<string, unknown> = value as Record<string, unknown>\n const keys = Object.keys(record)\n .filter((key) => dropped === undefined || !dropped.has(key))\n .sort()\n const parts = keys.map((key) => {\n const entry = record[key]\n const encoded =\n entry === undefined\n ? '\"__undefined__\"'\n : stableStringify(entry, undefined)\n return `${JSON.stringify(key)}:${encoded}`\n })\n return `{${parts.join(',')}}`\n }\n const encoded = JSON.stringify(value)\n return encoded === undefined ? 'null' : encoded\n}\n\n/**\n * A stable, order-independent identity for a chunk, excluding wall-clock\n * fields. Used to recognize the chunks a previous host already appended.\n *\n * - **Key-order independent**: object keys are sorted before stringifying, so\n * a JSON round trip through the journal (which does not preserve key order)\n * cannot spuriously diverge.\n * - **Recurses into nested arrays and objects**: tool-call arguments are\n * nested, and a shallow fingerprint would miss a changed argument.\n * - **Excludes `timestamp` and leftover adapter extras.** Spec keys\n * participate, including `undefined` values. `metadata.tanstack` is dropped\n * so stored spec chunks match live adapter yields.\n * - **Distinguishes present-but-`undefined` from absent**: `undefined` is\n * encoded as the sentinel string `\"__undefined__\"` rather than dropped, so\n * `{a: undefined}` and `{}` do not collide. A translator emitting an\n * explicit `undefined` is a different chunk shape and must fingerprint\n * differently.\n */\nfunction fingerprintableChunk(chunk: StreamChunk): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(chunk)) {\n if (key === 'timestamp') continue\n if (!isSpecTopLevelKey(chunk.type, key)) continue\n if (key === 'metadata' && value != null && typeof value === 'object') {\n const rest: Record<string, unknown> = {}\n for (const [metaKey, metaValue] of Object.entries(value)) {\n if (metaKey === 'tanstack') continue\n rest[metaKey] = metaValue\n }\n if (Object.keys(rest).length === 0) continue\n out.metadata = rest\n continue\n }\n out[key] = value\n }\n return out\n}\n\nexport function chunkFingerprint(chunk: StreamChunk): string {\n return stableStringify(fingerprintableChunk(chunk), VOLATILE_FIELDS)\n}\n\n/**\n * {@link chunkFingerprint} with the chunk's own `threadId` also excluded.\n *\n * NOT an alternative identity — never use it to decide that two chunks are the\n * same. Its single purpose is DIAGNOSIS: when a replay diverges from the stored\n * log, comparing both fingerprints answers \"did the agent behave differently, or\n * did only the conversation id move?\". Two chunks that match here but not under\n * {@link chunkFingerprint} differ in `threadId` and nothing else, which is a\n * misconfigured attach route rather than a determinism regression (see\n * `JournalReplayThreadIdMismatchError` in `align.ts`).\n */\nexport function chunkFingerprintIgnoringThreadId(chunk: StreamChunk): string {\n return stableStringify(fingerprintableChunk(chunk), VOLATILE_AND_THREAD_ID)\n}\n\n/**\n * A chunk's own `threadId`, or `undefined` when it carries none.\n *\n * Reads the field structurally rather than narrowing on `chunk.type`: nearly\n * every member of the `StreamChunk` union declares `threadId?: string`, and an\n * exhaustive switch would have to be revisited for each new member while adding\n * nothing — a chunk with no `threadId` is exactly the `undefined` case.\n */\nexport function chunkThreadId(chunk: StreamChunk): string | undefined {\n const record: Record<string, unknown> = chunk as Record<string, unknown>\n const value = record[THREAD_ID_FIELD]\n if (typeof value === 'string') return value\n const nested = tanstackMetadata(chunk)?.threadId\n return typeof nested === 'string' ? nested : undefined\n}\n"],"mappings":";;;;;;;;;;;;;AAqDA,SAAgB,qBAAqB,OAA6B;CAChE,IAAI,OAAO;CACX,aAAa;EACX,MAAM,KAAK,GAAG,MAAM,GAAG;EACvB,QAAQ;EACR,OAAO;CACT;AACF;;;;;;AAOA,IAAM,kCAAuC,IAAI,IAAI,CAAC,WAAW,CAAC;;;;;;AAOlE,IAAM,kBAAkB;AAExB,IAAM,yCAA8C,IAAI,IAAI,CAC1D,GAAG,iBACH,eACF,CAAC;;;;;;AAOD,SAAS,gBACP,OACA,SACQ;CACR,IAAI,UAAU,MAAM,OAAO;CAC3B,IAAI,MAAM,QAAQ,KAAK,GACrB,OAAO,IAAI,MAAM,KAAK,SAAS,gBAAgB,MAAM,KAAA,CAAS,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE;CAE7E,IAAI,OAAO,UAAU,UAAU;EAC7B,MAAM,SAAkC;EAYxC,OAAO,IAXM,OAAO,KAAK,MAAM,CAAC,CAC7B,QAAQ,QAAQ,YAAY,KAAA,KAAa,CAAC,QAAQ,IAAI,GAAG,CAAC,CAAC,CAC3D,KACW,CAAA,CAAK,KAAK,QAAQ;GAC9B,MAAM,QAAQ,OAAO;GACrB,MAAM,UACJ,UAAU,KAAA,IACN,sBACA,gBAAgB,OAAO,KAAA,CAAS;GACtC,OAAO,GAAG,KAAK,UAAU,GAAG,EAAE,GAAG;EACnC,CACW,CAAA,CAAM,KAAK,GAAG,EAAE;CAC7B;CACA,MAAM,UAAU,KAAK,UAAU,KAAK;CACpC,OAAO,YAAY,KAAA,IAAY,SAAS;AAC1C;;;;;;;;;;;;;;;;;;;AAoBA,SAAS,qBAAqB,OAA6C;CACzE,MAAM,MAA+B,CAAC;CACtC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,GAAG;EAChD,IAAI,QAAQ,aAAa;EACzB,IAAI,CAAC,kBAAkB,MAAM,MAAM,GAAG,GAAG;EACzC,IAAI,QAAQ,cAAc,SAAS,QAAQ,OAAO,UAAU,UAAU;GACpE,MAAM,OAAgC,CAAC;GACvC,KAAK,MAAM,CAAC,SAAS,cAAc,OAAO,QAAQ,KAAK,GAAG;IACxD,IAAI,YAAY,YAAY;IAC5B,KAAK,WAAW;GAClB;GACA,IAAI,OAAO,KAAK,IAAI,CAAC,CAAC,WAAW,GAAG;GACpC,IAAI,WAAW;GACf;EACF;EACA,IAAI,OAAO;CACb;CACA,OAAO;AACT;AAEA,SAAgB,iBAAiB,OAA4B;CAC3D,OAAO,gBAAgB,qBAAqB,KAAK,GAAG,eAAe;AACrE;;;;;;;;;;;;AAaA,SAAgB,iCAAiC,OAA4B;CAC3E,OAAO,gBAAgB,qBAAqB,KAAK,GAAG,sBAAsB;AAC5E;;;;;;;;;AAUA,SAAgB,cAAc,OAAwC;CAEpE,MAAM,QAAQ,MAAO;CACrB,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,MAAM,SAAS,iBAAiB,KAAK,CAAC,EAAE;CACxC,OAAO,OAAO,WAAW,WAAW,SAAS,KAAA;AAC/C"}
|
|
@@ -28,6 +28,14 @@ export interface ReaperConformanceConfig {
|
|
|
28
28
|
followUnsupported?: {
|
|
29
29
|
reason: string;
|
|
30
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Declare that this provider cannot run GNU `stat -c '%Y %n'`. The three
|
|
33
|
+
* age-gate cases skip with this reason. Docker alpine is the authority on
|
|
34
|
+
* the witness line; local-process on Darwin is BSD `stat`.
|
|
35
|
+
*/
|
|
36
|
+
mtimeListUnsupported?: {
|
|
37
|
+
reason: string;
|
|
38
|
+
};
|
|
31
39
|
}
|
|
32
40
|
/**
|
|
33
41
|
* Assert `createHandle` satisfies the sweep conformance contract. Each `it` gets
|
|
@@ -515,7 +515,12 @@ function runReaperConformance(config) {
|
|
|
515
515
|
await dispose();
|
|
516
516
|
}
|
|
517
517
|
});
|
|
518
|
-
|
|
518
|
+
const mtimeSkip = config.mtimeListUnsupported;
|
|
519
|
+
const itMtime = (title, timeout, fn) => it(mtimeSkip ? `${title} (unsupported: ${mtimeSkip.reason})` : title, {
|
|
520
|
+
timeout,
|
|
521
|
+
skip: Boolean(mtimeSkip)
|
|
522
|
+
}, fn);
|
|
523
|
+
itMtime("emits stat's self-witness line for a populated directory, so the age gate is usable", 6e4, async () => {
|
|
519
524
|
const { handle, dispose } = await config.createHandle();
|
|
520
525
|
const dir = caseDir();
|
|
521
526
|
const runId = uniqueRunId("witness");
|
|
@@ -531,7 +536,7 @@ function runReaperConformance(config) {
|
|
|
531
536
|
await dispose();
|
|
532
537
|
}
|
|
533
538
|
});
|
|
534
|
-
|
|
539
|
+
itMtime("reports an EMPTY journal directory as witness-only rather than unavailable", 6e4, async () => {
|
|
535
540
|
const { handle, dispose } = await config.createHandle();
|
|
536
541
|
const dir = caseDir();
|
|
537
542
|
try {
|
|
@@ -560,7 +565,7 @@ function runReaperConformance(config) {
|
|
|
560
565
|
await dispose();
|
|
561
566
|
}
|
|
562
567
|
});
|
|
563
|
-
|
|
568
|
+
itMtime("keeps an orphan younger than orphanTtlMs and sweeps the older one, in the same pass", 12e4, async () => {
|
|
564
569
|
const { handle, dispose } = await config.createHandle();
|
|
565
570
|
const dir = caseDir();
|
|
566
571
|
const olderId = uniqueRunId("older");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reaper-conformance.js","names":[],"sources":["../../../src/testkit/reaper-conformance.ts"],"sourcesContent":["/**\n * Provider conformance for the two unattended sweeps: `pruneJournals`\n * (`journal-sweep.ts`) and `reapDetachedRuns` (`reap.ts`), against a REAL\n * sandbox.\n *\n * WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS. Both sweeps are almost\n * entirely *shell* — `ls -1`, `stat -c '%Y %n'`, `rm -f`, `tail -c -N | base64`\n * — composed as strings by `journal.ts` and executed by a provider. The unit\n * suites drive fakes: an `exec` that answers from a scripted table, a\n * filesystem that is a `Map`. A fake cannot be wrong about `stat` the way a\n * BusyBox actually is, and on this feature that gap has already produced four\n * defects that every unit test passed (see `takeover-conformance.ts`'s module\n * doc for the roster). So the four properties the sweeps rest on are asserted\n * here through a real shell against real files:\n *\n * 1. **A deletion really deletes, and a keep really keeps.** Asserted with\n * `test -f` through the provider's shell, NEVER `handle.fs.exists`: on\n * local-process the two resolve `/tmp` differently, so an `fs` probe answers\n * about a path the journal was never written to (`journal.ts` rule 3). A\n * sweep that \"succeeded\" while deleting nothing passes an `fs` probe.\n * 2. **The age gate's self-witness works on THIS shell.** `journalMtimeListCommand`\n * passes the directory as `stat`'s own first operand precisely because\n * BusyBox exits 1 with EMPTY stdout on an unrecognised flag, and an empty\n * parse read as an empty directory would delete every live run's journal. The\n * docker provider's image is `alpine:3` — BusyBox 1.37, where `find -newermt`\n * and `find -printf` are unrecognised — so the docker matrix is the authority\n * on this case, not the local-process one (on Windows local-process execs\n * through git-bash, whose `find`/`stat` are GNU-flavoured).\n * 3. **The reaper never drives a live run.** The `'producing'` case asserts\n * ABSENCE — nothing appended, `close()` not called, not one `runs.update`,\n * `detachedSince` intact — because that is the shape of the defect\n * `probeRunExit` exists to prevent: entering `pipeToRunLog` to \"check\" writes\n * a terminal status and drops the run out of `listReclaimable` forever.\n * 4. **A shell-hostile runId cannot become a shell-hostile command.** The encode\n * → journal → follow → `ls` → decode → `rm` round trip runs on a runId\n * containing `/`, a space, `;`, `$( )` and an embedded `touch`, with a canary\n * file asserted absent. An ENCODING bug here is arbitrary command execution\n * inside the sandbox, not a cosmetic defect.\n *\n * **What the canary proves, exactly, and what it does not.** It detects a\n * runId reaching the shell WITHOUT `encodeRunId` — that is the mutation it\n * bites on, and it bites hard: `journaledCommand`, `journalFollowCommand`,\n * `journalExitProbeCommand`, `journalStderrReadCommand` and\n * `journalCleanupCommand` all interpolate the path, so the `;touch` executes\n * and the canary appears. It is BLIND to the loss of `journal.ts`'s\n * `shellQuote`, the second and independent layer. Measured: with `shellQuote`\n * reduced to the identity while `encodeRunId` stays, the redirect target\n * becomes `>> /tmp/…/rp-a_3btouch_20_2ftmp…ndjson` — a single shell word of\n * `[A-Za-z0-9._/-]`, because the encoder already removed every character a\n * shell can act on — so no canary fires and NOTHING in this suite, or in any\n * other real-provider suite, changes. Do not read a green run here as licence\n * to \"simplify\" `shellQuote` away.\n *\n * The quoting is pinned instead by exact-string unit tests in\n * `packages/ai-sandbox/tests/journal.test.ts`, which compare each composed\n * command to a literal containing the quotes. By name, one per command:\n * `journaledCommand` — \"redirects stdout to the journal, stderr to its own\n * file, and appends the exit sentinel\" plus \"quotes an adversarial runId so it\n * cannot inject shell metacharacters\"; `journalFollowCommand` — \"translates a\n * 0-based consumed-byte count into tail -c +N (1-based)\";\n * `journalReadCommand` — \"the bounded read drops -f and keeps the base64\n * frame, so a poll cannot hang\"; `journalExistsCommand` — \"probes through the\n * shell, never through fs.*\"; `journalStderrReadCommand` — \"reads a BOUNDED\n * tail of the sidecar, base64-framed, stderr silenced\";\n * `journalCleanupCommand`, `journalMtimeListCommand` and\n * `journalExitProbeCommand` — the first `it` under each of their `describe`s.\n * Those are the tests that go red on a dropped `shellQuote`; keep them exact.\n *\n * A provider that cannot satisfy the contract MUST declare `unsupported.reason`.\n * As in the journal and takeover suites there is deliberately no silent-skip\n * path: a conformance case that quietly returns prints as a pass, which is how\n * an unimplemented capability ships green.\n *\n * EVERY WAIT IN THIS FILE IS BOUNDED, and every journal directory is unique per\n * case — see {@link caseDir}. This suite DELETES FILES, and\n * `DEFAULT_JOURNAL_DIR` is a fixed absolute path shared with every other test\n * and, on local-process, with a developer's real runs.\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { randomUUID } from 'node:crypto'\nimport { describe, expect, it } from 'vitest'\nimport { EventType, InMemoryRunStore } from '@tanstack/ai'\nimport { InMemoryLockStore } from '@tanstack/ai/locks'\nimport {\n EXIT_SENTINEL_KEY,\n decodeJournalRunId,\n exitSentinelLine,\n journalCleanupCommand,\n journalExistsCommand,\n journalListCommand,\n journalMtimeListCommand,\n journalPaths,\n journalReadCommand,\n journalStderrReadCommand,\n journaledCommand,\n parseJournalMtimeListing,\n} from '../journal'\nimport { journalReadStrategy, readJournal } from '../journal-reader'\nimport { pruneJournals } from '../journal-sweep'\nimport { probeRunExit, reapDetachedRuns } from '../reap'\nimport { readJournalNdjson } from '../runner'\nimport { chunkFingerprint, createRunScopedIdGen } from '../chunk-identity'\nimport { waitForJournal } from './journal-conformance'\nimport type { JournalPaths } from '../journal'\nimport type { SandboxHandle } from '../contracts'\nimport type { RunStore, StreamChunk, StreamDurability } from '@tanstack/ai'\n\nexport interface ReaperConformanceConfig {\n /** Provider name, used in the describe title. */\n name: string\n /** Create a live sandbox plus its teardown. */\n createHandle: () => Promise<{\n handle: SandboxHandle\n dispose: () => Promise<void>\n }>\n /**\n * Declare that this provider cannot support the sweeps, with the reason.\n * Registers a skipped case whose title carries the reason — a NAMED skip,\n * visible in the reporter. Omit it and the suite runs.\n */\n unsupported?: { reason: string }\n /**\n * Declare that this provider's reads take the POLL strategy rather than the\n * FOLLOW one — i.e. `journalReadStrategy` answers `'poll'` for its handles.\n *\n * Only the FOLLOW half of the shell-hostile-runId case depends on it, so this\n * does not skip a case; it names itself in that case's title and the follow\n * read is omitted. The declaration is checked against the live handle there, in\n * both directions, so it cannot quietly remove coverage from a provider that\n * can in fact follow.\n */\n followUnsupported?: { reason: string }\n}\n\n/** Poll interval handed to providers that cannot follow a growing file. */\nconst POLL_INTERVAL_MS = 50\n\n/**\n * Quiescence window for the reaper's first append. Short because the agent in\n * these cases has provably stopped (the suite waited for its sentinel) — the\n * gate still runs, it just does not need to wait 5s to observe nothing.\n */\nconst FENCE_QUIET_MS = 25\n\n/**\n * Bound on a real journal read, so a reader that delivers nothing FAILS instead\n * of parking CI.\n *\n * Never an assertion, and deliberately far above anything a healthy read needs\n * (measured: 10–18s for the follow cases on both providers). Every use site\n * pairs it with a `backstopped: false` witness, so a read the CLOCK ended fails\n * naming this backstop rather than as a downstream transcript mismatch — which\n * means this number can be raised freely and must never be the thing a case is\n * tuned against.\n */\nconst READ_BACKSTOP_MS = 90_000\n\n/** Long enough that nothing in this suite is ever classified as expired. */\nconst NEVER_EXPIRES_MS = 60 * 60 * 1000\n\n/**\n * A journal directory nothing else on the machine writes to, created fresh for\n * EVERY case.\n *\n * Not `DEFAULT_JOURNAL_DIR`, and not even one directory per suite. Both sweeps\n * under test enumerate a whole directory and then DELETE from it, so a shared\n * directory would let one case's leftovers become another's input — and on\n * local-process the sandbox shell shares the host's real `/tmp`, where\n * `DEFAULT_JOURNAL_DIR` holds a developer's actual runs.\n */\nfunction caseDir(): string {\n return `/tmp/tanstack-reaper-conformance-${randomUUID()}`\n}\n\n/**\n * Unique per run, and it must be: `journalPaths` derives the filename from the\n * runId and the journal is append-only, so a reused id appends BEHIND the\n * previous run's `{\"__exit\":N}` sentinel and the new run appears to emit nothing\n * at all (see `journal.ts`).\n */\nfunction uniqueRunId(label: string): string {\n return `rp-${label}-${randomUUID()}`\n}\n\n/**\n * Single-quote a shell word, POSIX-style — the same rule `journal.ts`'s private\n * `shellQuote` applies.\n *\n * Duplicated rather than exported from production code on purpose: this exists\n * only for this suite's `rm -rf` teardown, which is not a production operation\n * and must not become one by growing an export for it.\n */\nfunction quote(value: string): string {\n return `'${value.replaceAll(\"'\", `'\\\\''`)}'`\n}\n\n/** Remove a case's journal directory and everything in it. Best effort. */\nasync function removeDir(handle: SandboxHandle, dir: string): Promise<void> {\n try {\n await handle.process.exec(`rm -rf ${quote(dir)}`)\n } catch {\n // The sandbox may already be gone, and on docker it is about to be. Nothing\n // under test depends on the directory being absent afterwards — the cases\n // that DO assert deletion assert it directly, per file.\n }\n}\n\n/**\n * Does `path` exist, according to the SANDBOX'S SHELL?\n *\n * `journalExistsCommand` rather than `handle.fs.exists`, for any path and not\n * just a journal: `journal.ts` rule 3 — on local-process `fs.*` resolves `/tmp`\n * under the sandbox root while a shell redirect hits the host's real `/tmp`, so\n * an `fs` probe would answer about a different file and every deletion\n * assertion in this suite would pass vacuously.\n */\nasync function fileExists(\n handle: SandboxHandle,\n path: string,\n): Promise<boolean> {\n const probe = await handle.process.exec(\n // Only `journal` is read by the probe, and its parameter is typed\n // `Pick<JournalPaths, 'journal'>` for exactly this reason: an arbitrary path\n // has no run behind it, so there is no nonce or sidecar to invent.\n journalExistsCommand({ journal: path }),\n )\n return probe.exitCode === 0\n}\n\n/** Filename as `ls -1` reports it, for a path inside `dir`. */\nfunction basename(dir: string, path: string): string {\n return path.slice(dir.length + 1)\n}\n\n/**\n * A real agent: a shell command printing one NDJSON line per delta, then\n * exiting.\n *\n * `printf '%s\\n' a b c` reuses the format for every operand on GNU coreutils\n * and on BusyBox alike, so this needs no loop. The JSON contains only double\n * quotes, so it is safe inside the POSIX single-quoted words this builds.\n */\nfunction emitLines(deltas: Array<string>): string {\n return `printf '%s\\\\n' ${deltas.map((delta) => `'{\"delta\":\"${delta}\"}'`).join(' ')}`\n}\n\n/**\n * Run a journaled agent to completion, so the `{\"__exit\":N}` sentinel is in the\n * journal by the time this resolves.\n *\n * `exec`, not `spawn`: `exec` waits, and a bounded wait is the only kind this\n * suite allows. (`SpawnHandle.wait()` is also not safe to call after the fact on\n * every provider — see `journal-conformance.ts`.)\n */\nasync function runAgent(\n handle: SandboxHandle,\n paths: JournalPaths,\n deltas: Array<string>,\n): Promise<void> {\n await handle.process.exec(journaledCommand(emitLines(deltas), paths))\n}\n\n/** `ls -1` output as a list of names. */\nasync function listNames(\n handle: SandboxHandle,\n dir: string,\n): Promise<Array<string>> {\n const listing = await handle.process.exec(journalListCommand(dir))\n return listing.stdout\n .split('\\n')\n .map((line) => line.trim())\n .filter((line) => line !== '')\n}\n\n/** Decode the base64 frame a bounded journal read produces. */\nfunction decodeJournalRead(stdout: string): string {\n return Buffer.from(stdout.replace(/\\s+/g, ''), 'base64').toString('utf8')\n}\n\n/**\n * The `stat -c '%Y %n'` listing for `dir`, plus the raw stdout so a case can\n * assert the WITNESS LINE itself rather than only its parsed consequence.\n */\nasync function mtimeListing(\n handle: SandboxHandle,\n dir: string,\n): Promise<{ stdout: string; entries: Map<string, number> }> {\n const probe = await handle.process.exec(journalMtimeListCommand(dir))\n const parsed = parseJournalMtimeListing(probe.stdout, dir)\n if (parsed.kind !== 'listed') {\n throw new Error(\n `reaper conformance: the mtime listing for ${dir} came back unavailable — ` +\n `stat -c '%Y %n' produced no witness line. stdout: ${JSON.stringify(probe.stdout)}`,\n )\n }\n return {\n stdout: probe.stdout,\n entries: new Map(\n parsed.entries.map((entry) => [entry.name, entry.mtimeMs]),\n ),\n }\n}\n\n/**\n * Is there a `<seconds> <dir>` line — `stat`'s report on its own first operand?\n *\n * That line, not the exit status, is the evidence the mechanism ran: BusyBox\n * exits 1 both for an EMPTY directory (whose unexpanded glob it cannot stat) and\n * for an unrecognised flag, and only the witness distinguishes them.\n */\nfunction hasWitnessLine(stdout: string, dir: string): boolean {\n return stdout\n .split('\\n')\n .some((line) => /^\\d+ (?<path>.+)$/.exec(line.trim())?.[1] === dir)\n}\n\n/** Read one file's mtime out of a listing, loudly when it is missing. */\nfunction mtimeOf(entries: Map<string, number>, name: string): number {\n const mtimeMs = entries.get(name)\n if (mtimeMs === undefined) {\n throw new Error(\n `reaper conformance: ${name} has no mtime in the stat listing, so the age gate cannot be exercised`,\n )\n }\n return mtimeMs\n}\n\n/**\n * An in-process event log with real accumulated state, plus the two facts the\n * reaper assertions need: what was appended, and how many times `close()` ran.\n *\n * `close()` is the load-bearing counter. `pipeToRunLog` ALWAYS calls it, so a\n * reaper that entered the pipe to find out whether a run finished would show up\n * here as `closes() === 1` — which ends every attached client's stream — even if\n * it happened to append nothing.\n */\ninterface ConformanceLog {\n log: StreamDurability\n stored: () => Array<StreamChunk>\n closes: () => number\n}\n\nfunction conformanceLog(): ConformanceLog {\n const entries: Array<{ offset: string; chunk: StreamChunk }> = []\n let closes = 0\n return {\n log: {\n resumeFrom: () => null,\n append: (chunks) =>\n Promise.resolve(\n chunks.map((chunk) => {\n const offset = `reap:${entries.length}`\n entries.push({ offset, chunk })\n return offset\n }),\n ),\n // Nothing here tails the log — every assertion reads the appended\n // transcript, and a `read` would park until `close()` (see `align.ts`).\n read: () => (async function* empty() {})(),\n close: () => {\n closes += 1\n return Promise.resolve()\n },\n snapshot: () => Promise.resolve(entries.map((entry) => ({ ...entry }))),\n },\n stored: () => entries.map((entry) => entry.chunk),\n closes: () => closes,\n }\n}\n\n/**\n * A `RunStore` that counts its MUTATIONS, so the leave-alone case can assert\n * that a producing run's record was not written at all.\n *\n * \"Status still `'running'`\" is too weak on its own: `driverEpoch` is bumped by\n * `withRunClaim` before any status is written, so a reaper that claimed a live\n * run and then bailed would still read as `'running'`. Counting `update` sees\n * that; reading the status does not.\n */\ninterface CountingRunStore {\n runs: RunStore\n updates: () => number\n}\n\nfunction countingRunStore(inner: InMemoryRunStore): CountingRunStore {\n let updates = 0\n return {\n runs: {\n createOrResume: (...args) => inner.createOrResume(...args),\n update: (...args) => {\n updates += 1\n return inner.update(...args)\n },\n get: (...args) => inner.get(...args),\n listByThread: (...args) => inner.listByThread(...args),\n listReclaimable: (...args) => inner.listReclaimable(...args),\n findActiveRun: (...args) => inner.findActiveRun(...args),\n },\n updates: () => updates,\n }\n}\n\n/** The event a journal line translates into. `timestamp` is excluded from `chunkFingerprint`. */\nfunction contentChunk(messageId: string, delta: string): StreamChunk {\n return {\n type: EventType.TEXT_MESSAGE_CONTENT,\n messageId,\n delta,\n timestamp: Date.now(),\n }\n}\n\n/**\n * Narrow one parsed journal line into its chunk.\n *\n * Fields are validated and the chunk REBUILT from them rather than asserted into\n * shape: a cast would let a provider that mangles the bytes reach\n * `chunkFingerprint` as a structurally invalid chunk and fail somewhere\n * unrelated.\n */\nfunction toChunk(\n runId: string,\n messageId: string,\n value: unknown,\n): StreamChunk {\n if (typeof value !== 'object' || value === null || !('delta' in value)) {\n throw new Error(\n `reaper conformance: run ${runId} journal line is not an agent event: ${JSON.stringify(value)}`,\n )\n }\n const delta = value.delta\n if (typeof delta !== 'string') {\n throw new Error(\n `reaper conformance: run ${runId} journal line has a non-string delta: ${JSON.stringify(value)}`,\n )\n }\n return contentChunk(messageId, delta)\n}\n\n/** Deterministic translator: re-reading the journal reproduces the same chunks. */\nasync function* translate(\n runId: string,\n lines: AsyncIterable<unknown>,\n): AsyncIterable<StreamChunk> {\n const messageId = createRunScopedIdGen(runId)()\n for await (const line of lines) yield toChunk(runId, messageId, line)\n}\n\n/** A comparable transcript: each chunk reduced to its fingerprint. */\nfunction transcript(chunks: Array<StreamChunk>): Array<string> {\n return chunks.map(chunkFingerprint)\n}\n\n/** The chunks a run over `deltas` must deliver, exactly once and in order. */\nfunction expectedTranscript(\n runId: string,\n deltas: Array<string>,\n): Array<StreamChunk> {\n const messageId = createRunScopedIdGen(runId)()\n return deltas.map((delta) => contentChunk(messageId, delta))\n}\n\n/**\n * The reaper's `drive`: read the run's journal from byte 0 and translate it.\n *\n * The read is bounded independently of `signal` so a journal that stops growing\n * fails the case instead of hanging CI.\n *\n * Returns the drive alongside `backstopped()`, the causal witness for\n * {@link READ_BACKSTOP_MS}: the case must assert it is `false` before its\n * transcript assertions, so a read the CLOCK ended fails naming the backstop\n * instead of as a truncated-transcript diff.\n */\nfunction driveFromJournal(\n handle: SandboxHandle,\n dir: string,\n): {\n drive: (input: {\n runId: string\n threadId: string\n signal: AbortSignal\n }) => AsyncIterable<StreamChunk>\n /** True if any read this drive started was ended by the backstop clock. */\n backstopped: () => boolean\n} {\n // One entry per `drive` invocation, so a sweep that drives more than one run\n // cannot hide a backstopped read behind a healthy one.\n const backstops: Array<AbortSignal> = []\n return {\n drive: ({ runId, signal }) => {\n // Not the assertion — see {@link READ_BACKSTOP_MS}. `backstopped()` is what\n // proves the clock was not what ended the read.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n backstops.push(backstop)\n return translate(\n runId,\n readJournalNdjson(handle, {\n signal: AbortSignal.any([signal, backstop]),\n journal: { runId, dir, pollIntervalMs: POLL_INTERVAL_MS },\n }),\n )\n },\n backstopped: () => backstops.some((s) => s.aborted),\n }\n}\n\n/** A `'running'`, DETACHED record — the shape `listReclaimable` selects on. */\nasync function detachedRun(\n store: RunStore,\n runId: string,\n threadId: string,\n detachedSince: number,\n): Promise<void> {\n await store.createOrResume({ runId, threadId, startedAt: Date.now() })\n await store.update(runId, { detachedSince })\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n/**\n * Assert `createHandle` satisfies the sweep conformance contract. Each `it` gets\n * a fresh sandbox via `createHandle`/`dispose`, a fresh journal directory, and\n * unique runIds, so no case can observe another's files.\n */\nexport function runReaperConformance(config: ReaperConformanceConfig): void {\n describe(`reaper conformance — ${config.name}`, () => {\n if (config.unsupported) {\n it.skip(`unsupported: ${config.unsupported.reason}`, () => {\n expect(true).toBe(true)\n })\n return\n }\n\n // -----------------------------------------------------------------------\n // 1. `pruneJournals` against a real filesystem.\n // -----------------------------------------------------------------------\n it(\n \"deletes a terminal run's journal AND its .err sidecar, while a running run's journal survives the same sweep\",\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const terminalId = uniqueRunId('terminal')\n const liveId = uniqueRunId('live')\n const terminal = journalPaths(terminalId, dir)\n const live = journalPaths(liveId, dir)\n try {\n await runAgent(handle, terminal, ['1'])\n await runAgent(handle, live, ['1'])\n // Premise: all four files really exist before the sweep, otherwise\n // \"deleted\" below would be indistinguishable from \"never written\".\n expect({\n terminalJournal: await fileExists(handle, terminal.journal),\n terminalSidecar: await fileExists(handle, terminal.stderr),\n liveJournal: await fileExists(handle, live.journal),\n }).toEqual({\n terminalJournal: true,\n terminalSidecar: true,\n liveJournal: true,\n })\n\n const runs = new InMemoryRunStore()\n await runs.createOrResume({\n runId: terminalId,\n threadId: `${terminalId}-t`,\n startedAt: Date.now(),\n })\n await runs.update(terminalId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n await runs.createOrResume({\n runId: liveId,\n threadId: `${liveId}-t`,\n startedAt: Date.now(),\n })\n\n const result = await pruneJournals({ handle, runs, dir })\n expect(result.deleted).toEqual([terminalId])\n expect(result.failures).toEqual([])\n expect(result.kept).toEqual([\n { runId: liveId, names: expect.any(Array), reason: 'non-terminal' },\n ])\n\n // The files, through the shell. A sweep that reported a deletion it did\n // not perform passes every assertion above and fails here.\n expect({\n terminalJournal: await fileExists(handle, terminal.journal),\n terminalSidecar: await fileExists(handle, terminal.stderr),\n liveJournal: await fileExists(handle, live.journal),\n liveSidecar: await fileExists(handle, live.stderr),\n }).toEqual({\n terminalJournal: false,\n terminalSidecar: false,\n liveJournal: true,\n liveSidecar: true,\n })\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'sweeps the same terminal run twice without a failure, and rm -f of an already-absent journal exits 0',\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('twice')\n const paths = journalPaths(runId, dir)\n try {\n await runAgent(handle, paths, ['1'])\n const runs = new InMemoryRunStore()\n await runs.createOrResume({\n runId,\n threadId: `${runId}-t`,\n startedAt: Date.now(),\n })\n await runs.update(runId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n\n const first = await pruneJournals({ handle, runs, dir })\n expect(first.deleted).toEqual([runId])\n\n // The second sweep sees an empty directory. It must report nothing to\n // do rather than a failure — a cron runs this every tick forever.\n const second = await pruneJournals({ handle, runs, dir })\n expect({\n listed: second.listed,\n runIds: second.runIds,\n deleted: second.deleted,\n kept: second.kept,\n failures: second.failures,\n }).toEqual({\n listed: 0,\n runIds: 0,\n deleted: [],\n kept: [],\n failures: [],\n })\n\n // And the `rm -f` the sweep issues is itself idempotent on this shell.\n // Asserted directly because the sweep folds a non-zero `rm` into\n // `kept: 'delete-failed'` and would therefore hide it as a keep.\n const rerun = await handle.process.exec(journalCleanupCommand(paths))\n expect(rerun.exitCode).toBe(0)\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'leaves a filename it cannot decode alone, while still sweeping the terminal run beside it',\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('undecodable')\n const paths = journalPaths(runId, dir)\n // `_1.` is not a two-hex-digit escape, so this name is `malformed` — the\n // shape a truncated or foreign file has. `decodeJournalRunId` must refuse\n // it, and the sweep must keep it WITHOUT asking the store, because a\n // plausible-but-wrong runId could answer `terminal` for someone else.\n const strayName = 'reaper-conformance-stray_1.ndjson'\n const strayPath = `${dir}/${strayName}`\n try {\n await runAgent(handle, paths, ['1'])\n await handle.process.exec(\n `printf 'not a journal\\\\n' >> ${quote(strayPath)}`,\n )\n expect(await fileExists(handle, strayPath)).toBe(true)\n expect(decodeJournalRunId(strayName).kind).toBe('malformed')\n\n const runs = new InMemoryRunStore()\n await runs.createOrResume({\n runId,\n threadId: `${runId}-t`,\n startedAt: Date.now(),\n })\n await runs.update(runId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n\n const result = await pruneJournals({ handle, runs, dir })\n expect(result.deleted).toEqual([runId])\n expect(result.kept).toEqual([\n { names: [strayName], reason: 'undecodable-name' },\n ])\n expect(result.failures).toEqual([])\n expect({\n strayKept: await fileExists(handle, strayPath),\n journalDeleted: !(await fileExists(handle, paths.journal)),\n }).toEqual({ strayKept: true, journalDeleted: true })\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n // -----------------------------------------------------------------------\n // 2. The age gate on a real shell.\n // -----------------------------------------------------------------------\n it(\n \"emits stat's self-witness line for a populated directory, so the age gate is usable\",\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('witness')\n const paths = journalPaths(runId, dir)\n try {\n await runAgent(handle, paths, ['1'])\n const listing = await mtimeListing(handle, dir)\n // The witness is what makes \"no files\" distinguishable from \"the\n // mechanism is unavailable\". On BusyBox 1.37 — the docker provider's\n // `alpine:3` — `find -newermt`/`-printf` are unrecognised and exit 1\n // with empty stdout, which is exactly why the design is a witness line\n // rather than a `find` and an exit code.\n expect(hasWitnessLine(listing.stdout, dir)).toBe(true)\n expect([...listing.entries.keys()].sort()).toEqual(\n [basename(dir, paths.journal), basename(dir, paths.stderr)].sort(),\n )\n // Real epoch times, not the parser's zeroes: a `%Y` the shell did not\n // expand would parse as no entry at all, and a `stat` that printed\n // something else would land far from now.\n for (const mtimeMs of listing.entries.values()) {\n expect(Math.abs(Date.now() - mtimeMs)).toBeLessThan(120_000)\n }\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'reports an EMPTY journal directory as witness-only rather than unavailable',\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n try {\n await handle.process.exec(`mkdir -p ${quote(dir)}`)\n const listing = await mtimeListing(handle, dir)\n expect(hasWitnessLine(listing.stdout, dir)).toBe(true)\n expect([...listing.entries.keys()]).toEqual([])\n\n // And the sweep agrees: an empty directory is a LISTED age gate, not an\n // unavailable one. `'unavailable'` here would silently disable orphan\n // expiry forever on this provider.\n const result = await pruneJournals({\n handle,\n runs: new InMemoryRunStore(),\n dir,\n })\n expect({\n listed: result.listed,\n ageGate: result.ageGate,\n deleted: result.deleted,\n failures: result.failures,\n }).toEqual({\n listed: 0,\n ageGate: 'listed',\n deleted: [],\n failures: [],\n })\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'keeps an orphan younger than orphanTtlMs and sweeps the older one, in the same pass',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const olderId = uniqueRunId('older')\n const newerId = uniqueRunId('newer')\n const older = journalPaths(olderId, dir)\n const newer = journalPaths(newerId, dir)\n try {\n await runAgent(handle, older, ['1'])\n // `stat -c '%Y'` is second-granular, so the two runs must be more than\n // one second apart for their ages to be distinguishable at all.\n await sleep(2_500)\n await runAgent(handle, newer, ['1'])\n\n // The cutoff is computed from the REAL mtimes the real shell reported,\n // not from a fabricated timestamp: that is the whole point of running\n // this against a provider. `pruneJournals` keeps when the NEWEST of a\n // run's files is strictly newer than the cutoff, so placing the cutoff\n // between the two runs must expire exactly one of them.\n const listing = await mtimeListing(handle, dir)\n const newestOf = (paths: JournalPaths): number =>\n Math.max(\n mtimeOf(listing.entries, basename(dir, paths.journal)),\n mtimeOf(listing.entries, basename(dir, paths.stderr)),\n )\n const olderMtime = newestOf(older)\n const newerMtime = newestOf(newer)\n expect(newerMtime - olderMtime).toBeGreaterThanOrEqual(1_000)\n\n const now = Date.now()\n const cutoff = olderMtime + Math.floor((newerMtime - olderMtime) / 2)\n // NEITHER run is in the store, so both take the orphan arm and only the\n // age gate decides between them.\n const result = await pruneJournals({\n handle,\n runs: new InMemoryRunStore(),\n dir,\n now,\n orphanTtlMs: now - cutoff,\n })\n expect(result.ageGate).toBe('listed')\n expect(result.deleted).toEqual([olderId])\n expect(result.kept).toEqual([\n {\n runId: newerId,\n names: expect.any(Array),\n reason: 'orphan-too-recent',\n },\n ])\n expect(result.failures).toEqual([])\n expect({\n olderJournal: await fileExists(handle, older.journal),\n olderSidecar: await fileExists(handle, older.stderr),\n newerJournal: await fileExists(handle, newer.journal),\n newerSidecar: await fileExists(handle, newer.stderr),\n }).toEqual({\n olderJournal: false,\n olderSidecar: false,\n newerJournal: true,\n newerSidecar: true,\n })\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n // -----------------------------------------------------------------------\n // 3. `reapDetachedRuns` end to end.\n // -----------------------------------------------------------------------\n it(\n 'finalizes a detached run whose agent reached its sentinel, and its transcript lands',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('finalize')\n const threadId = `${runId}-t`\n const deltas = ['1', '2', '3']\n const paths = journalPaths(runId, dir)\n try {\n const runs = new InMemoryRunStore()\n const detachedSince = Date.now()\n await detachedRun(runs, runId, threadId, detachedSince)\n await runAgent(handle, paths, deltas)\n\n // The probe, on its own, before any sweep: this read is what makes the\n // reaper safe, and it must answer from the JOURNAL rather than from the\n // delivery log (which a detached run's dead host stopped appending to).\n expect(await probeRunExit({ handle, runId, dir })).toEqual({\n state: 'finished',\n exitCode: 0,\n })\n\n const log = conformanceLog()\n const journalDrive = driveFromJournal(handle, dir)\n const result = await reapDetachedRuns({\n runs,\n locks: new InMemoryLockStore(),\n durability: () => log.log,\n hasFinished: (record) =>\n probeRunExit({ handle, runId: record.runId, dir }),\n drive: journalDrive.drive,\n now: Date.now(),\n detachedRunTtlMs: NEVER_EXPIRES_MS,\n fenceQuietMs: FENCE_QUIET_MS,\n })\n\n // The causal witness, before anything downstream — see\n // {@link READ_BACKSTOP_MS}. The reaper's read ends at the sentinel; if\n // the clock ended it instead, the transcript below is short and the\n // failure must name the backstop rather than a missing chunk.\n expect({ backstopped: journalDrive.backstopped() }).toEqual({\n backstopped: false,\n })\n expect({\n considered: result.considered,\n probed: result.probed,\n finalized: result.outcomes.finalized,\n }).toEqual({ considered: 1, probed: 1, finalized: 1 })\n expect(result.runs).toEqual([\n { runId, outcome: 'finalized', status: 'completed', exitCode: 0 },\n ])\n // The transcript, element for element — the reaper's whole purpose is\n // that the run a nobody watched still ends up saved.\n expect(transcript(log.stored())).toEqual(\n transcript(expectedTranscript(runId, deltas)),\n )\n const record = await runs.get(runId)\n expect(record?.status).toBe('completed')\n // NEVER CLEARED: `detachedSince` is what the next sweep selects on, and\n // clearing it would reset the TTL on every pass.\n expect(record?.detachedSince).toBe(detachedSince)\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'reports a still-producing detached run as producing and leaves it completely untouched',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('producing')\n const threadId = `${runId}-t`\n const paths = journalPaths(runId, dir)\n const store = countingRunStore(new InMemoryRunStore())\n // A REAL agent that has written a line and is genuinely still alive: no\n // sentinel can be in the journal, and driving it would truncate a healthy\n // run's transcript at line one.\n const agent = await handle.process.spawn(\n journaledCommand(`${emitLines(['1'])}; sleep 30`, paths),\n )\n try {\n const detachedSince = Date.now()\n await detachedRun(store.runs, runId, threadId, detachedSince)\n await waitForJournal(handle, paths)\n const read = await handle.process.exec(journalReadCommand(paths, 0))\n const text = decodeJournalRead(read.stdout)\n // Producing, provably: the first line is there and the sentinel is not.\n expect(text).toContain('{\"delta\":\"1\"}')\n expect(text).not.toContain('__exit')\n expect(await probeRunExit({ handle, runId, dir })).toEqual({\n state: 'producing',\n })\n\n const log = conformanceLog()\n let driveCalled = false\n let durabilityCalls = 0\n const result = await reapDetachedRuns({\n runs: store.runs,\n locks: new InMemoryLockStore(),\n durability: () => {\n durabilityCalls += 1\n return log.log\n },\n hasFinished: (record) =>\n probeRunExit({ handle, runId: record.runId, dir }),\n drive: () => {\n driveCalled = true\n return (async function* never() {})()\n },\n now: Date.now(),\n detachedRunTtlMs: NEVER_EXPIRES_MS,\n fenceQuietMs: FENCE_QUIET_MS,\n })\n\n expect(result.runs).toEqual([{ runId, outcome: 'producing' }])\n expect({\n considered: result.considered,\n probed: result.probed,\n producing: result.outcomes.producing,\n finalized: result.outcomes.finalized,\n expired: result.outcomes.expired,\n failed: result.outcomes.failed,\n }).toEqual({\n considered: 1,\n probed: 1,\n producing: 1,\n finalized: 0,\n expired: 0,\n failed: 0,\n })\n\n // ABSENCE, asserted in one object so a regression names which\n // guarantee broke instead of failing on whichever line came first.\n // Every one of these is a way the pre-`probeRunExit` design destroyed a\n // live run: an append duplicates its prefix, a `close()` ends every\n // attached client's stream, an `update` writes `'completed'` and drops\n // the run out of `listReclaimable` forever, and a moved\n // `detachedSince` restarts its TTL.\n const record = await store.runs.get(runId)\n expect({\n driveCalled,\n durabilityCalls,\n appended: log.stored().length,\n closes: log.closes(),\n updatesAfterSetup: store.updates() - 1,\n status: record?.status,\n detachedSince: record?.detachedSince,\n driverEpoch: record?.driverEpoch,\n }).toEqual({\n driveCalled: false,\n durabilityCalls: 0,\n appended: 0,\n closes: 0,\n updatesAfterSetup: 0,\n status: 'running',\n detachedSince,\n driverEpoch: undefined,\n })\n } finally {\n // The agent outlives the sweep on purpose; reap it here so no `sleep`\n // survives the case.\n try {\n await agent.kill()\n } catch {\n // Already gone, or a provider whose sandbox teardown covers it.\n }\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n // -----------------------------------------------------------------------\n // 4. A shell-hostile runId, end to end. SECURITY-RELEVANT.\n // -----------------------------------------------------------------------\n it(\n 'round-trips a shell-hostile runId through encode, journal, follow, sidecar read, list, decode and delete without executing any of it' +\n (config.followUnsupported === undefined\n ? ''\n : ` (follow read omitted: ${config.followUnsupported.reason})`),\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const nonce = randomUUID().slice(0, 8)\n // The canary lives OUTSIDE `dir` so the teardown `rm -rf` cannot be what\n // makes the final assertion pass.\n const canary = `/tmp/rp-pwn-${nonce}`\n // `/` would escape the directory, the space would split the word, `;` and\n // `$( )` would start new commands, and the `touch` is a real payload with\n // an observable effect. Every one of these must survive as DATA.\n //\n // THE ORDER OF THE PAYLOAD IS DELIBERATE and was measured: the `;touch`\n // comes BEFORE the space and the `/`. With raw interpolation, the\n // journaled command's redirect target is one word, so a payload whose\n // space precedes the `;` (`rp-a b;touch …`) makes the mangled command a\n // SYNTAX ERROR — the injected `touch` never runs and the canary below\n // would be decoration that can never fire. With the `;` first, the\n // vulnerable form parses as a command LIST and the payload really\n // executes (verified against a hand-composed unquoted, unencoded command\n // on this provider: canary present). So the canary is a live detector.\n const runId = `rp-a;touch ${canary};b c/d$(x)-${nonce}`\n const threadId = `${runId}-t`\n const paths = journalPaths(runId, dir)\n const journalName = basename(dir, paths.journal)\n try {\n // This case's agent writes to STDERR as well, so the sidecar read\n // below has real bytes to compare against: an empty sidecar is also\n // what a `journalStderrReadCommand` that read the wrong path (or\n // nothing at all) would return, and that read is the only coverage\n // that command has anywhere.\n await handle.process.exec(\n journaledCommand(\n `${emitLines(['1'])}; printf 'boom\\\\n' 1>&2`,\n paths,\n ),\n )\n // THE SECURITY ASSERTION, and deliberately the FIRST one: nothing the\n // runId contains was executed. It is stated before the cheaper\n // structural checks below on purpose — a defect that reintroduces raw\n // interpolation would also fail the filename shape, and a case that\n // short-circuited there would never prove this probe is live rather\n // than decorative. Re-asserted after the delete, because the sweep\n // composes a DIFFERENT command (`rm -f`) from the same id.\n expect(await fileExists(handle, canary)).toBe(false)\n expect(await probeRunExit({ handle, runId, dir })).toEqual({\n state: 'finished',\n exitCode: 0,\n })\n // The encoding is what bought that: the filename carries no character\n // a shell can act on, and it stays inside the journal directory.\n expect(journalName).toMatch(/^[A-Za-z0-9._-]+\\.ndjson$/)\n expect(paths.journal.startsWith(`${dir}/`)).toBe(true)\n\n // THE FOLLOW PATH, against this same hostile id.\n //\n // `journalFollowCommand` is the WORST command in the set under a\n // dropped `encodeRunId`: it interpolates the journal path THREE times\n // (`mkdir -p`, `: >> path`, `tail -c +N -f path`) and joins its prep\n // steps with `;` rather than `&&`, so `: >> /tmp/dir/rp-a;touch\n // <canary>;…` is a complete redirect followed by a command LIST — the\n // payload runs on EVERY attach, and a failing prep step does not stop\n // it. Nothing else reaches this command with a hostile runId: the\n // reaper's own probes are all bounded reads, and the takeover suite,\n // the only other real-provider consumer of the follow path, builds\n // alnum-only ids. So it is exercised here, where the hostile id and a\n // live canary already exist, for the cost of one read.\n //\n // The strategy is FORCED rather than capability-derived so this is the\n // follow command and not the bounded one, and the declaration is\n // checked against the live handle in both directions — a config that\n // does not describe the provider must fail rather than silently drop\n // this read.\n expect(journalReadStrategy(handle)).toBe(\n config.followUnsupported === undefined ? 'follow' : 'poll',\n )\n if (config.followUnsupported === undefined) {\n const followed: Array<string> = []\n // A backstop, so a reader that delivers nothing fails instead of\n // parking CI — a `tail -f` never ends on its own, so this read has no\n // other floor. Not the assertion — `backstopped` below proves it was\n // not what ended the loop.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n for await (const line of readJournal(handle, {\n paths,\n fromByte: 0,\n strategy: 'follow',\n signal: backstop,\n })) {\n followed.push(line.line)\n // The agent has already reached its sentinel, so this arrives; a\n // `tail -f` never ends on its own.\n if (line.line.includes(EXIT_SENTINEL_KEY)) break\n }\n // The causal witness, first: the loop must end on the sentinel\n // `break`, not on the clock. A backstopped follow read otherwise\n // reports as a one-element-vs-two array diff that says nothing about\n // why.\n expect({ backstopped: backstop.aborted }).toEqual({\n backstopped: false,\n })\n expect(followed).toEqual([\n '{\"delta\":\"1\"}',\n exitSentinelLine(paths, 0),\n ])\n expect(await fileExists(handle, canary)).toBe(false)\n }\n\n // The stderr SIDECAR read, which no other conformance case reaches at\n // all. Same hostile id, same canary, one `exec`.\n const sidecar = await handle.process.exec(\n journalStderrReadCommand(paths),\n )\n expect(decodeJournalRead(sidecar.stdout)).toBe('boom\\n')\n expect(await fileExists(handle, canary)).toBe(false)\n\n // encode → journal → list → decode: the sweep's actual path back to a\n // runId, over a real `ls -1`.\n const names = await listNames(handle, dir)\n expect(names.sort()).toEqual(\n [journalName, basename(dir, paths.stderr)].sort(),\n )\n expect(decodeJournalRunId(journalName)).toEqual({\n kind: 'runId',\n runId,\n })\n\n const runs = new InMemoryRunStore()\n await runs.createOrResume({ runId, threadId, startedAt: Date.now() })\n await runs.update(runId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n const result = await pruneJournals({ handle, runs, dir })\n expect(result.deleted).toEqual([runId])\n expect(result.failures).toEqual([])\n expect({\n journalDeleted: !(await fileExists(handle, paths.journal)),\n sidecarDeleted: !(await fileExists(handle, paths.stderr)),\n canaryAbsent: !(await fileExists(handle, canary)),\n }).toEqual({\n journalDeleted: true,\n sidecarDeleted: true,\n canaryAbsent: true,\n })\n } finally {\n await handle.process\n .exec(`rm -f ${quote(canary)}`)\n .catch(() => undefined)\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n })\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyIA,IAAM,mBAAmB;;;;;;AAOzB,IAAM,iBAAiB;;;;;;;;;;;;AAavB,IAAM,mBAAmB;;AAGzB,IAAM,mBAAmB;;;;;;;;;;;AAYzB,SAAS,UAAkB;CACzB,OAAO,oCAAoC,WAAW;AACxD;;;;;;;AAQA,SAAS,YAAY,OAAuB;CAC1C,OAAO,MAAM,MAAM,GAAG,WAAW;AACnC;;;;;;;;;AAUA,SAAS,MAAM,OAAuB;CACpC,OAAO,IAAI,MAAM,WAAW,KAAK,OAAO,EAAE;AAC5C;;AAGA,eAAe,UAAU,QAAuB,KAA4B;CAC1E,IAAI;EACF,MAAM,OAAO,QAAQ,KAAK,UAAU,MAAM,GAAG,GAAG;CAClD,QAAQ,CAIR;AACF;;;;;;;;;;AAWA,eAAe,WACb,QACA,MACkB;CAOlB,QAAO,MANa,OAAO,QAAQ,KAIjC,qBAAqB,EAAE,SAAS,KAAK,CAAC,CACxC,EAAA,CACa,aAAa;AAC5B;;AAGA,SAAS,SAAS,KAAa,MAAsB;CACnD,OAAO,KAAK,MAAM,IAAI,SAAS,CAAC;AAClC;;;;;;;;;AAUA,SAAS,UAAU,QAA+B;CAChD,OAAO,kBAAkB,OAAO,KAAK,UAAU,cAAc,MAAM,IAAI,CAAC,CAAC,KAAK,GAAG;AACnF;;;;;;;;;AAUA,eAAe,SACb,QACA,OACA,QACe;CACf,MAAM,OAAO,QAAQ,KAAK,iBAAiB,UAAU,MAAM,GAAG,KAAK,CAAC;AACtE;;AAGA,eAAe,UACb,QACA,KACwB;CAExB,QAAO,MADe,OAAO,QAAQ,KAAK,mBAAmB,GAAG,CAAC,EAAA,CAClD,OACZ,MAAM,IAAI,CAAC,CACX,KAAK,SAAS,KAAK,KAAK,CAAC,CAAC,CAC1B,QAAQ,SAAS,SAAS,EAAE;AACjC;;AAGA,SAAS,kBAAkB,QAAwB;CACjD,OAAO,OAAO,KAAK,OAAO,QAAQ,QAAQ,EAAE,GAAG,QAAQ,CAAC,CAAC,SAAS,MAAM;AAC1E;;;;;AAMA,eAAe,aACb,QACA,KAC2D;CAC3D,MAAM,QAAQ,MAAM,OAAO,QAAQ,KAAK,wBAAwB,GAAG,CAAC;CACpE,MAAM,SAAS,yBAAyB,MAAM,QAAQ,GAAG;CACzD,IAAI,OAAO,SAAS,UAClB,MAAM,IAAI,MACR,6CAA6C,IAAI,6EACM,KAAK,UAAU,MAAM,MAAM,GACpF;CAEF,OAAO;EACL,QAAQ,MAAM;EACd,SAAS,IAAI,IACX,OAAO,QAAQ,KAAK,UAAU,CAAC,MAAM,MAAM,MAAM,OAAO,CAAC,CAC3D;CACF;AACF;;;;;;;;AASA,SAAS,eAAe,QAAgB,KAAsB;CAC5D,OAAO,OACJ,MAAM,IAAI,CAAC,CACX,MAAM,SAAS,oBAAoB,KAAK,KAAK,KAAK,CAAC,CAAC,GAAG,OAAO,GAAG;AACtE;;AAGA,SAAS,QAAQ,SAA8B,MAAsB;CACnE,MAAM,UAAU,QAAQ,IAAI,IAAI;CAChC,IAAI,YAAY,KAAA,GACd,MAAM,IAAI,MACR,uBAAuB,KAAK,uEAC9B;CAEF,OAAO;AACT;AAiBA,SAAS,iBAAiC;CACxC,MAAM,UAAyD,CAAC;CAChE,IAAI,SAAS;CACb,OAAO;EACL,KAAK;GACH,kBAAkB;GAClB,SAAS,WACP,QAAQ,QACN,OAAO,KAAK,UAAU;IACpB,MAAM,SAAS,QAAQ,QAAQ;IAC/B,QAAQ,KAAK;KAAE;KAAQ;IAAM,CAAC;IAC9B,OAAO;GACT,CAAC,CACH;GAGF,aAAa,gBAAgB,QAAQ,CAAC,EAAA,CAAG;GACzC,aAAa;IACX,UAAU;IACV,OAAO,QAAQ,QAAQ;GACzB;GACA,gBAAgB,QAAQ,QAAQ,QAAQ,KAAK,WAAW,EAAE,GAAG,MAAM,EAAE,CAAC;EACxE;EACA,cAAc,QAAQ,KAAK,UAAU,MAAM,KAAK;EAChD,cAAc;CAChB;AACF;AAgBA,SAAS,iBAAiB,OAA2C;CACnE,IAAI,UAAU;CACd,OAAO;EACL,MAAM;GACJ,iBAAiB,GAAG,SAAS,MAAM,eAAe,GAAG,IAAI;GACzD,SAAS,GAAG,SAAS;IACnB,WAAW;IACX,OAAO,MAAM,OAAO,GAAG,IAAI;GAC7B;GACA,MAAM,GAAG,SAAS,MAAM,IAAI,GAAG,IAAI;GACnC,eAAe,GAAG,SAAS,MAAM,aAAa,GAAG,IAAI;GACrD,kBAAkB,GAAG,SAAS,MAAM,gBAAgB,GAAG,IAAI;GAC3D,gBAAgB,GAAG,SAAS,MAAM,cAAc,GAAG,IAAI;EACzD;EACA,eAAe;CACjB;AACF;;AAGA,SAAS,aAAa,WAAmB,OAA4B;CACnE,OAAO;EACL,MAAM,UAAU;EAChB;EACA;EACA,WAAW,KAAK,IAAI;CACtB;AACF;;;;;;;;;AAUA,SAAS,QACP,OACA,WACA,OACa;CACb,IAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,EAAE,WAAW,QAC9D,MAAM,IAAI,MACR,2BAA2B,MAAM,uCAAuC,KAAK,UAAU,KAAK,GAC9F;CAEF,MAAM,QAAQ,MAAM;CACpB,IAAI,OAAO,UAAU,UACnB,MAAM,IAAI,MACR,2BAA2B,MAAM,wCAAwC,KAAK,UAAU,KAAK,GAC/F;CAEF,OAAO,aAAa,WAAW,KAAK;AACtC;;AAGA,gBAAgB,UACd,OACA,OAC4B;CAC5B,MAAM,YAAY,qBAAqB,KAAK,CAAC,CAAC;CAC9C,WAAW,MAAM,QAAQ,OAAO,MAAM,QAAQ,OAAO,WAAW,IAAI;AACtE;;AAGA,SAAS,WAAW,QAA2C;CAC7D,OAAO,OAAO,IAAI,gBAAgB;AACpC;;AAGA,SAAS,mBACP,OACA,QACoB;CACpB,MAAM,YAAY,qBAAqB,KAAK,CAAC,CAAC;CAC9C,OAAO,OAAO,KAAK,UAAU,aAAa,WAAW,KAAK,CAAC;AAC7D;;;;;;;;;;;;AAaA,SAAS,iBACP,QACA,KASA;CAGA,MAAM,YAAgC,CAAC;CACvC,OAAO;EACL,QAAQ,EAAE,OAAO,aAAa;GAG5B,MAAM,WAAW,YAAY,QAAQ,gBAAgB;GACrD,UAAU,KAAK,QAAQ;GACvB,OAAO,UACL,OACA,kBAAkB,QAAQ;IACxB,QAAQ,YAAY,IAAI,CAAC,QAAQ,QAAQ,CAAC;IAC1C,SAAS;KAAE;KAAO;KAAK,gBAAgB;IAAiB;GAC1D,CAAC,CACH;EACF;EACA,mBAAmB,UAAU,MAAM,MAAM,EAAE,OAAO;CACpD;AACF;;AAGA,eAAe,YACb,OACA,OACA,UACA,eACe;CACf,MAAM,MAAM,eAAe;EAAE;EAAO;EAAU,WAAW,KAAK,IAAI;CAAE,CAAC;CACrE,MAAM,MAAM,OAAO,OAAO,EAAE,cAAc,CAAC;AAC7C;AAEA,SAAS,MAAM,IAA2B;CACxC,OAAO,IAAI,SAAS,YAAY,WAAW,SAAS,EAAE,CAAC;AACzD;;;;;;AAOA,SAAgB,qBAAqB,QAAuC;CAC1E,SAAS,wBAAwB,OAAO,cAAc;EACpD,IAAI,OAAO,aAAa;GACtB,GAAG,KAAK,gBAAgB,OAAO,YAAY,gBAAgB;IACzD,OAAO,IAAI,CAAC,CAAC,KAAK,IAAI;GACxB,CAAC;GACD;EACF;EAKA,GACE,gHACA,EAAE,SAAS,IAAO,GAClB,YAAY;GACV,MAAM,EAAE,QAAQ,YAAY,MAAM,OAAO,aAAa;GACtD,MAAM,MAAM,QAAQ;GACpB,MAAM,aAAa,YAAY,UAAU;GACzC,MAAM,SAAS,YAAY,MAAM;GACjC,MAAM,WAAW,aAAa,YAAY,GAAG;GAC7C,MAAM,OAAO,aAAa,QAAQ,GAAG;GACrC,IAAI;IACF,MAAM,SAAS,QAAQ,UAAU,CAAC,GAAG,CAAC;IACtC,MAAM,SAAS,QAAQ,MAAM,CAAC,GAAG,CAAC;IAGlC,OAAO;KACL,iBAAiB,MAAM,WAAW,QAAQ,SAAS,OAAO;KAC1D,iBAAiB,MAAM,WAAW,QAAQ,SAAS,MAAM;KACzD,aAAa,MAAM,WAAW,QAAQ,KAAK,OAAO;IACpD,CAAC,CAAC,CAAC,QAAQ;KACT,iBAAiB;KACjB,iBAAiB;KACjB,aAAa;IACf,CAAC;IAED,MAAM,OAAO,IAAI,iBAAiB;IAClC,MAAM,KAAK,eAAe;KACxB,OAAO;KACP,UAAU,GAAG,WAAW;KACxB,WAAW,KAAK,IAAI;IACtB,CAAC;IACD,MAAM,KAAK,OAAO,YAAY;KAC5B,QAAQ;KACR,YAAY,KAAK,IAAI;IACvB,CAAC;IACD,MAAM,KAAK,eAAe;KACxB,OAAO;KACP,UAAU,GAAG,OAAO;KACpB,WAAW,KAAK,IAAI;IACtB,CAAC;IAED,MAAM,SAAS,MAAM,cAAc;KAAE;KAAQ;KAAM;IAAI,CAAC;IACxD,OAAO,OAAO,OAAO,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC;IAC3C,OAAO,OAAO,QAAQ,CAAC,CAAC,QAAQ,CAAC,CAAC;IAClC,OAAO,OAAO,IAAI,CAAC,CAAC,QAAQ,CAC1B;KAAE,OAAO;KAAQ,OAAO,OAAO,IAAI,KAAK;KAAG,QAAQ;IAAe,CACpE,CAAC;IAID,OAAO;KACL,iBAAiB,MAAM,WAAW,QAAQ,SAAS,OAAO;KAC1D,iBAAiB,MAAM,WAAW,QAAQ,SAAS,MAAM;KACzD,aAAa,MAAM,WAAW,QAAQ,KAAK,OAAO;KAClD,aAAa,MAAM,WAAW,QAAQ,KAAK,MAAM;IACnD,CAAC,CAAC,CAAC,QAAQ;KACT,iBAAiB;KACjB,iBAAiB;KACjB,aAAa;KACb,aAAa;IACf,CAAC;GACH,UAAU;IACR,MAAM,UAAU,QAAQ,GAAG;IAC3B,MAAM,QAAQ;GAChB;EACF,CACF;EAEA,GACE,wGACA,EAAE,SAAS,IAAO,GAClB,YAAY;GACV,MAAM,EAAE,QAAQ,YAAY,MAAM,OAAO,aAAa;GACtD,MAAM,MAAM,QAAQ;GACpB,MAAM,QAAQ,YAAY,OAAO;GACjC,MAAM,QAAQ,aAAa,OAAO,GAAG;GACrC,IAAI;IACF,MAAM,SAAS,QAAQ,OAAO,CAAC,GAAG,CAAC;IACnC,MAAM,OAAO,IAAI,iBAAiB;IAClC,MAAM,KAAK,eAAe;KACxB;KACA,UAAU,GAAG,MAAM;KACnB,WAAW,KAAK,IAAI;IACtB,CAAC;IACD,MAAM,KAAK,OAAO,OAAO;KACvB,QAAQ;KACR,YAAY,KAAK,IAAI;IACvB,CAAC;IAED,MAAM,QAAQ,MAAM,cAAc;KAAE;KAAQ;KAAM;IAAI,CAAC;IACvD,OAAO,MAAM,OAAO,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC;IAIrC,MAAM,SAAS,MAAM,cAAc;KAAE;KAAQ;KAAM;IAAI,CAAC;IACxD,OAAO;KACL,QAAQ,OAAO;KACf,QAAQ,OAAO;KACf,SAAS,OAAO;KAChB,MAAM,OAAO;KACb,UAAU,OAAO;IACnB,CAAC,CAAC,CAAC,QAAQ;KACT,QAAQ;KACR,QAAQ;KACR,SAAS,CAAC;KACV,MAAM,CAAC;KACP,UAAU,CAAC;IACb,CAAC;IAKD,MAAM,QAAQ,MAAM,OAAO,QAAQ,KAAK,sBAAsB,KAAK,CAAC;IACpE,OAAO,MAAM,QAAQ,CAAC,CAAC,KAAK,CAAC;GAC/B,UAAU;IACR,MAAM,UAAU,QAAQ,GAAG;IAC3B,MAAM,QAAQ;GAChB;EACF,CACF;EAEA,GACE,6FACA,EAAE,SAAS,IAAO,GAClB,YAAY;GACV,MAAM,EAAE,QAAQ,YAAY,MAAM,OAAO,aAAa;GACtD,MAAM,MAAM,QAAQ;GACpB,MAAM,QAAQ,YAAY,aAAa;GACvC,MAAM,QAAQ,aAAa,OAAO,GAAG;GAKrC,MAAM,YAAY;GAClB,MAAM,YAAY,GAAG,IAAI,GAAG;GAC5B,IAAI;IACF,MAAM,SAAS,QAAQ,OAAO,CAAC,GAAG,CAAC;IACnC,MAAM,OAAO,QAAQ,KACnB,gCAAgC,MAAM,SAAS,GACjD;IACA,OAAO,MAAM,WAAW,QAAQ,SAAS,CAAC,CAAC,CAAC,KAAK,IAAI;IACrD,OAAO,mBAAmB,SAAS,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,WAAW;IAE3D,MAAM,OAAO,IAAI,iBAAiB;IAClC,MAAM,KAAK,eAAe;KACxB;KACA,UAAU,GAAG,MAAM;KACnB,WAAW,KAAK,IAAI;IACtB,CAAC;IACD,MAAM,KAAK,OAAO,OAAO;KACvB,QAAQ;KACR,YAAY,KAAK,IAAI;IACvB,CAAC;IAED,MAAM,SAAS,MAAM,cAAc;KAAE;KAAQ;KAAM;IAAI,CAAC;IACxD,OAAO,OAAO,OAAO,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC;IACtC,OAAO,OAAO,IAAI,CAAC,CAAC,QAAQ,CAC1B;KAAE,OAAO,CAAC,SAAS;KAAG,QAAQ;IAAmB,CACnD,CAAC;IACD,OAAO,OAAO,QAAQ,CAAC,CAAC,QAAQ,CAAC,CAAC;IAClC,OAAO;KACL,WAAW,MAAM,WAAW,QAAQ,SAAS;KAC7C,gBAAgB,CAAE,MAAM,WAAW,QAAQ,MAAM,OAAO;IAC1D,CAAC,CAAC,CAAC,QAAQ;KAAE,WAAW;KAAM,gBAAgB;IAAK,CAAC;GACtD,UAAU;IACR,MAAM,UAAU,QAAQ,GAAG;IAC3B,MAAM,QAAQ;GAChB;EACF,CACF;EAKA,GACE,uFACA,EAAE,SAAS,IAAO,GAClB,YAAY;GACV,MAAM,EAAE,QAAQ,YAAY,MAAM,OAAO,aAAa;GACtD,MAAM,MAAM,QAAQ;GACpB,MAAM,QAAQ,YAAY,SAAS;GACnC,MAAM,QAAQ,aAAa,OAAO,GAAG;GACrC,IAAI;IACF,MAAM,SAAS,QAAQ,OAAO,CAAC,GAAG,CAAC;IACnC,MAAM,UAAU,MAAM,aAAa,QAAQ,GAAG;IAM9C,OAAO,eAAe,QAAQ,QAAQ,GAAG,CAAC,CAAC,CAAC,KAAK,IAAI;IACrD,OAAO,CAAC,GAAG,QAAQ,QAAQ,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QACzC,CAAC,SAAS,KAAK,MAAM,OAAO,GAAG,SAAS,KAAK,MAAM,MAAM,CAAC,CAAC,CAAC,KAAK,CACnE;IAIA,KAAK,MAAM,WAAW,QAAQ,QAAQ,OAAO,GAC3C,OAAO,KAAK,IAAI,KAAK,IAAI,IAAI,OAAO,CAAC,CAAC,CAAC,aAAa,IAAO;GAE/D,UAAU;IACR,MAAM,UAAU,QAAQ,GAAG;IAC3B,MAAM,QAAQ;GAChB;EACF,CACF;EAEA,GACE,8EACA,EAAE,SAAS,IAAO,GAClB,YAAY;GACV,MAAM,EAAE,QAAQ,YAAY,MAAM,OAAO,aAAa;GACtD,MAAM,MAAM,QAAQ;GACpB,IAAI;IACF,MAAM,OAAO,QAAQ,KAAK,YAAY,MAAM,GAAG,GAAG;IAClD,MAAM,UAAU,MAAM,aAAa,QAAQ,GAAG;IAC9C,OAAO,eAAe,QAAQ,QAAQ,GAAG,CAAC,CAAC,CAAC,KAAK,IAAI;IACrD,OAAO,CAAC,GAAG,QAAQ,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;IAK9C,MAAM,SAAS,MAAM,cAAc;KACjC;KACA,MAAM,IAAI,iBAAiB;KAC3B;IACF,CAAC;IACD,OAAO;KACL,QAAQ,OAAO;KACf,SAAS,OAAO;KAChB,SAAS,OAAO;KAChB,UAAU,OAAO;IACnB,CAAC,CAAC,CAAC,QAAQ;KACT,QAAQ;KACR,SAAS;KACT,SAAS,CAAC;KACV,UAAU,CAAC;IACb,CAAC;GACH,UAAU;IACR,MAAM,UAAU,QAAQ,GAAG;IAC3B,MAAM,QAAQ;GAChB;EACF,CACF;EAEA,GACE,uFACA,EAAE,SAAS,KAAQ,GACnB,YAAY;GACV,MAAM,EAAE,QAAQ,YAAY,MAAM,OAAO,aAAa;GACtD,MAAM,MAAM,QAAQ;GACpB,MAAM,UAAU,YAAY,OAAO;GACnC,MAAM,UAAU,YAAY,OAAO;GACnC,MAAM,QAAQ,aAAa,SAAS,GAAG;GACvC,MAAM,QAAQ,aAAa,SAAS,GAAG;GACvC,IAAI;IACF,MAAM,SAAS,QAAQ,OAAO,CAAC,GAAG,CAAC;IAGnC,MAAM,MAAM,IAAK;IACjB,MAAM,SAAS,QAAQ,OAAO,CAAC,GAAG,CAAC;IAOnC,MAAM,UAAU,MAAM,aAAa,QAAQ,GAAG;IAC9C,MAAM,YAAY,UAChB,KAAK,IACH,QAAQ,QAAQ,SAAS,SAAS,KAAK,MAAM,OAAO,CAAC,GACrD,QAAQ,QAAQ,SAAS,SAAS,KAAK,MAAM,MAAM,CAAC,CACtD;IACF,MAAM,aAAa,SAAS,KAAK;IACjC,MAAM,aAAa,SAAS,KAAK;IACjC,OAAO,aAAa,UAAU,CAAC,CAAC,uBAAuB,GAAK;IAE5D,MAAM,MAAM,KAAK,IAAI;IACrB,MAAM,SAAS,aAAa,KAAK,OAAO,aAAa,cAAc,CAAC;IAGpE,MAAM,SAAS,MAAM,cAAc;KACjC;KACA,MAAM,IAAI,iBAAiB;KAC3B;KACA;KACA,aAAa,MAAM;IACrB,CAAC;IACD,OAAO,OAAO,OAAO,CAAC,CAAC,KAAK,QAAQ;IACpC,OAAO,OAAO,OAAO,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC;IACxC,OAAO,OAAO,IAAI,CAAC,CAAC,QAAQ,CAC1B;KACE,OAAO;KACP,OAAO,OAAO,IAAI,KAAK;KACvB,QAAQ;IACV,CACF,CAAC;IACD,OAAO,OAAO,QAAQ,CAAC,CAAC,QAAQ,CAAC,CAAC;IAClC,OAAO;KACL,cAAc,MAAM,WAAW,QAAQ,MAAM,OAAO;KACpD,cAAc,MAAM,WAAW,QAAQ,MAAM,MAAM;KACnD,cAAc,MAAM,WAAW,QAAQ,MAAM,OAAO;KACpD,cAAc,MAAM,WAAW,QAAQ,MAAM,MAAM;IACrD,CAAC,CAAC,CAAC,QAAQ;KACT,cAAc;KACd,cAAc;KACd,cAAc;KACd,cAAc;IAChB,CAAC;GACH,UAAU;IACR,MAAM,UAAU,QAAQ,GAAG;IAC3B,MAAM,QAAQ;GAChB;EACF,CACF;EAKA,GACE,uFACA,EAAE,SAAS,KAAQ,GACnB,YAAY;GACV,MAAM,EAAE,QAAQ,YAAY,MAAM,OAAO,aAAa;GACtD,MAAM,MAAM,QAAQ;GACpB,MAAM,QAAQ,YAAY,UAAU;GACpC,MAAM,WAAW,GAAG,MAAM;GAC1B,MAAM,SAAS;IAAC;IAAK;IAAK;GAAG;GAC7B,MAAM,QAAQ,aAAa,OAAO,GAAG;GACrC,IAAI;IACF,MAAM,OAAO,IAAI,iBAAiB;IAClC,MAAM,gBAAgB,KAAK,IAAI;IAC/B,MAAM,YAAY,MAAM,OAAO,UAAU,aAAa;IACtD,MAAM,SAAS,QAAQ,OAAO,MAAM;IAKpC,OAAO,MAAM,aAAa;KAAE;KAAQ;KAAO;IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ;KACzD,OAAO;KACP,UAAU;IACZ,CAAC;IAED,MAAM,MAAM,eAAe;IAC3B,MAAM,eAAe,iBAAiB,QAAQ,GAAG;IACjD,MAAM,SAAS,MAAM,iBAAiB;KACpC;KACA,OAAO,IAAI,kBAAkB;KAC7B,kBAAkB,IAAI;KACtB,cAAc,WACZ,aAAa;MAAE;MAAQ,OAAO,OAAO;MAAO;KAAI,CAAC;KACnD,OAAO,aAAa;KACpB,KAAK,KAAK,IAAI;KACd,kBAAkB;KAClB,cAAc;IAChB,CAAC;IAMD,OAAO,EAAE,aAAa,aAAa,YAAY,EAAE,CAAC,CAAC,CAAC,QAAQ,EAC1D,aAAa,MACf,CAAC;IACD,OAAO;KACL,YAAY,OAAO;KACnB,QAAQ,OAAO;KACf,WAAW,OAAO,SAAS;IAC7B,CAAC,CAAC,CAAC,QAAQ;KAAE,YAAY;KAAG,QAAQ;KAAG,WAAW;IAAE,CAAC;IACrD,OAAO,OAAO,IAAI,CAAC,CAAC,QAAQ,CAC1B;KAAE;KAAO,SAAS;KAAa,QAAQ;KAAa,UAAU;IAAE,CAClE,CAAC;IAGD,OAAO,WAAW,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC,QAC/B,WAAW,mBAAmB,OAAO,MAAM,CAAC,CAC9C;IACA,MAAM,SAAS,MAAM,KAAK,IAAI,KAAK;IACnC,OAAO,QAAQ,MAAM,CAAC,CAAC,KAAK,WAAW;IAGvC,OAAO,QAAQ,aAAa,CAAC,CAAC,KAAK,aAAa;GAClD,UAAU;IACR,MAAM,UAAU,QAAQ,GAAG;IAC3B,MAAM,QAAQ;GAChB;EACF,CACF;EAEA,GACE,0FACA,EAAE,SAAS,KAAQ,GACnB,YAAY;GACV,MAAM,EAAE,QAAQ,YAAY,MAAM,OAAO,aAAa;GACtD,MAAM,MAAM,QAAQ;GACpB,MAAM,QAAQ,YAAY,WAAW;GACrC,MAAM,WAAW,GAAG,MAAM;GAC1B,MAAM,QAAQ,aAAa,OAAO,GAAG;GACrC,MAAM,QAAQ,iBAAiB,IAAI,iBAAiB,CAAC;GAIrD,MAAM,QAAQ,MAAM,OAAO,QAAQ,MACjC,iBAAiB,GAAG,UAAU,CAAC,GAAG,CAAC,EAAE,aAAa,KAAK,CACzD;GACA,IAAI;IACF,MAAM,gBAAgB,KAAK,IAAI;IAC/B,MAAM,YAAY,MAAM,MAAM,OAAO,UAAU,aAAa;IAC5D,MAAM,eAAe,QAAQ,KAAK;IAElC,MAAM,OAAO,mBAAkB,MADZ,OAAO,QAAQ,KAAK,mBAAmB,OAAO,CAAC,CAAC,EAAA,CAC/B,MAAM;IAE1C,OAAO,IAAI,CAAC,CAAC,UAAU,mBAAe;IACtC,OAAO,IAAI,CAAC,CAAC,IAAI,UAAU,QAAQ;IACnC,OAAO,MAAM,aAAa;KAAE;KAAQ;KAAO;IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,EACzD,OAAO,YACT,CAAC;IAED,MAAM,MAAM,eAAe;IAC3B,IAAI,cAAc;IAClB,IAAI,kBAAkB;IACtB,MAAM,SAAS,MAAM,iBAAiB;KACpC,MAAM,MAAM;KACZ,OAAO,IAAI,kBAAkB;KAC7B,kBAAkB;MAChB,mBAAmB;MACnB,OAAO,IAAI;KACb;KACA,cAAc,WACZ,aAAa;MAAE;MAAQ,OAAO,OAAO;MAAO;KAAI,CAAC;KACnD,aAAa;MACX,cAAc;MACd,QAAQ,gBAAgB,QAAQ,CAAC,EAAA,CAAG;KACtC;KACA,KAAK,KAAK,IAAI;KACd,kBAAkB;KAClB,cAAc;IAChB,CAAC;IAED,OAAO,OAAO,IAAI,CAAC,CAAC,QAAQ,CAAC;KAAE;KAAO,SAAS;IAAY,CAAC,CAAC;IAC7D,OAAO;KACL,YAAY,OAAO;KACnB,QAAQ,OAAO;KACf,WAAW,OAAO,SAAS;KAC3B,WAAW,OAAO,SAAS;KAC3B,SAAS,OAAO,SAAS;KACzB,QAAQ,OAAO,SAAS;IAC1B,CAAC,CAAC,CAAC,QAAQ;KACT,YAAY;KACZ,QAAQ;KACR,WAAW;KACX,WAAW;KACX,SAAS;KACT,QAAQ;IACV,CAAC;IASD,MAAM,SAAS,MAAM,MAAM,KAAK,IAAI,KAAK;IACzC,OAAO;KACL;KACA;KACA,UAAU,IAAI,OAAO,CAAC,CAAC;KACvB,QAAQ,IAAI,OAAO;KACnB,mBAAmB,MAAM,QAAQ,IAAI;KACrC,QAAQ,QAAQ;KAChB,eAAe,QAAQ;KACvB,aAAa,QAAQ;IACvB,CAAC,CAAC,CAAC,QAAQ;KACT,aAAa;KACb,iBAAiB;KACjB,UAAU;KACV,QAAQ;KACR,mBAAmB;KACnB,QAAQ;KACR;KACA,aAAa,KAAA;IACf,CAAC;GACH,UAAU;IAGR,IAAI;KACF,MAAM,MAAM,KAAK;IACnB,QAAQ,CAER;IACA,MAAM,UAAU,QAAQ,GAAG;IAC3B,MAAM,QAAQ;GAChB;EACF,CACF;EAKA,GACE,0IACG,OAAO,sBAAsB,KAAA,IAC1B,KACA,0BAA0B,OAAO,kBAAkB,OAAO,KAChE,EAAE,SAAS,KAAQ,GACnB,YAAY;GACV,MAAM,EAAE,QAAQ,YAAY,MAAM,OAAO,aAAa;GACtD,MAAM,MAAM,QAAQ;GACpB,MAAM,QAAQ,WAAW,CAAC,CAAC,MAAM,GAAG,CAAC;GAGrC,MAAM,SAAS,eAAe;GAc9B,MAAM,QAAQ,cAAc,OAAO,aAAa;GAChD,MAAM,WAAW,GAAG,MAAM;GAC1B,MAAM,QAAQ,aAAa,OAAO,GAAG;GACrC,MAAM,cAAc,SAAS,KAAK,MAAM,OAAO;GAC/C,IAAI;IAMF,MAAM,OAAO,QAAQ,KACnB,iBACE,GAAG,UAAU,CAAC,GAAG,CAAC,EAAE,0BACpB,KACF,CACF;IAQA,OAAO,MAAM,WAAW,QAAQ,MAAM,CAAC,CAAC,CAAC,KAAK,KAAK;IACnD,OAAO,MAAM,aAAa;KAAE;KAAQ;KAAO;IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ;KACzD,OAAO;KACP,UAAU;IACZ,CAAC;IAGD,OAAO,WAAW,CAAC,CAAC,QAAQ,2BAA2B;IACvD,OAAO,MAAM,QAAQ,WAAW,GAAG,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI;IAqBrD,OAAO,oBAAoB,MAAM,CAAC,CAAC,CAAC,KAClC,OAAO,sBAAsB,KAAA,IAAY,WAAW,MACtD;IACA,IAAI,OAAO,sBAAsB,KAAA,GAAW;KAC1C,MAAM,WAA0B,CAAC;KAKjC,MAAM,WAAW,YAAY,QAAQ,gBAAgB;KACrD,WAAW,MAAM,QAAQ,YAAY,QAAQ;MAC3C;MACA,UAAU;MACV,UAAU;MACV,QAAQ;KACV,CAAC,GAAG;MACF,SAAS,KAAK,KAAK,IAAI;MAGvB,IAAI,KAAK,KAAK,SAAA,QAA0B,GAAG;KAC7C;KAKA,OAAO,EAAE,aAAa,SAAS,QAAQ,CAAC,CAAC,CAAC,QAAQ,EAChD,aAAa,MACf,CAAC;KACD,OAAO,QAAQ,CAAC,CAAC,QAAQ,CACvB,qBACA,iBAAiB,OAAO,CAAC,CAC3B,CAAC;KACD,OAAO,MAAM,WAAW,QAAQ,MAAM,CAAC,CAAC,CAAC,KAAK,KAAK;IACrD;IAIA,MAAM,UAAU,MAAM,OAAO,QAAQ,KACnC,yBAAyB,KAAK,CAChC;IACA,OAAO,kBAAkB,QAAQ,MAAM,CAAC,CAAC,CAAC,KAAK,QAAQ;IACvD,OAAO,MAAM,WAAW,QAAQ,MAAM,CAAC,CAAC,CAAC,KAAK,KAAK;IAInD,MAAM,QAAQ,MAAM,UAAU,QAAQ,GAAG;IACzC,OAAO,MAAM,KAAK,CAAC,CAAC,CAAC,QACnB,CAAC,aAAa,SAAS,KAAK,MAAM,MAAM,CAAC,CAAC,CAAC,KAAK,CAClD;IACA,OAAO,mBAAmB,WAAW,CAAC,CAAC,CAAC,QAAQ;KAC9C,MAAM;KACN;IACF,CAAC;IAED,MAAM,OAAO,IAAI,iBAAiB;IAClC,MAAM,KAAK,eAAe;KAAE;KAAO;KAAU,WAAW,KAAK,IAAI;IAAE,CAAC;IACpE,MAAM,KAAK,OAAO,OAAO;KACvB,QAAQ;KACR,YAAY,KAAK,IAAI;IACvB,CAAC;IACD,MAAM,SAAS,MAAM,cAAc;KAAE;KAAQ;KAAM;IAAI,CAAC;IACxD,OAAO,OAAO,OAAO,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC;IACtC,OAAO,OAAO,QAAQ,CAAC,CAAC,QAAQ,CAAC,CAAC;IAClC,OAAO;KACL,gBAAgB,CAAE,MAAM,WAAW,QAAQ,MAAM,OAAO;KACxD,gBAAgB,CAAE,MAAM,WAAW,QAAQ,MAAM,MAAM;KACvD,cAAc,CAAE,MAAM,WAAW,QAAQ,MAAM;IACjD,CAAC,CAAC,CAAC,QAAQ;KACT,gBAAgB;KAChB,gBAAgB;KAChB,cAAc;IAChB,CAAC;GACH,UAAU;IACR,MAAM,OAAO,QACV,KAAK,SAAS,MAAM,MAAM,GAAG,CAAC,CAC9B,YAAY,KAAA,CAAS;IACxB,MAAM,UAAU,QAAQ,GAAG;IAC3B,MAAM,QAAQ;GAChB;EACF,CACF;CACF,CAAC;AACH"}
|
|
1
|
+
{"version":3,"file":"reaper-conformance.js","names":[],"sources":["../../../src/testkit/reaper-conformance.ts"],"sourcesContent":["/**\n * Provider conformance for the two unattended sweeps: `pruneJournals`\n * (`journal-sweep.ts`) and `reapDetachedRuns` (`reap.ts`), against a REAL\n * sandbox.\n *\n * WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS. Both sweeps are almost\n * entirely *shell* — `ls -1`, `stat -c '%Y %n'`, `rm -f`, `tail -c -N | base64`\n * — composed as strings by `journal.ts` and executed by a provider. The unit\n * suites drive fakes: an `exec` that answers from a scripted table, a\n * filesystem that is a `Map`. A fake cannot be wrong about `stat` the way a\n * BusyBox actually is, and on this feature that gap has already produced four\n * defects that every unit test passed (see `takeover-conformance.ts`'s module\n * doc for the roster). So the four properties the sweeps rest on are asserted\n * here through a real shell against real files:\n *\n * 1. **A deletion really deletes, and a keep really keeps.** Asserted with\n * `test -f` through the provider's shell, NEVER `handle.fs.exists`: on\n * local-process the two resolve `/tmp` differently, so an `fs` probe answers\n * about a path the journal was never written to (`journal.ts` rule 3). A\n * sweep that \"succeeded\" while deleting nothing passes an `fs` probe.\n * 2. **The age gate's self-witness works on THIS shell.** `journalMtimeListCommand`\n * passes the directory as `stat`'s own first operand precisely because\n * BusyBox exits 1 with EMPTY stdout on an unrecognised flag, and an empty\n * parse read as an empty directory would delete every live run's journal. The\n * docker provider's image is `alpine:3` — BusyBox 1.37, where `find -newermt`\n * and `find -printf` are unrecognised — so the docker matrix is the authority\n * on this case, not the local-process one (on Windows local-process execs\n * through git-bash, whose `find`/`stat` are GNU-flavoured).\n * 3. **The reaper never drives a live run.** The `'producing'` case asserts\n * ABSENCE — nothing appended, `close()` not called, not one `runs.update`,\n * `detachedSince` intact — because that is the shape of the defect\n * `probeRunExit` exists to prevent: entering `pipeToRunLog` to \"check\" writes\n * a terminal status and drops the run out of `listReclaimable` forever.\n * 4. **A shell-hostile runId cannot become a shell-hostile command.** The encode\n * → journal → follow → `ls` → decode → `rm` round trip runs on a runId\n * containing `/`, a space, `;`, `$( )` and an embedded `touch`, with a canary\n * file asserted absent. An ENCODING bug here is arbitrary command execution\n * inside the sandbox, not a cosmetic defect.\n *\n * **What the canary proves, exactly, and what it does not.** It detects a\n * runId reaching the shell WITHOUT `encodeRunId` — that is the mutation it\n * bites on, and it bites hard: `journaledCommand`, `journalFollowCommand`,\n * `journalExitProbeCommand`, `journalStderrReadCommand` and\n * `journalCleanupCommand` all interpolate the path, so the `;touch` executes\n * and the canary appears. It is BLIND to the loss of `journal.ts`'s\n * `shellQuote`, the second and independent layer. Measured: with `shellQuote`\n * reduced to the identity while `encodeRunId` stays, the redirect target\n * becomes `>> /tmp/…/rp-a_3btouch_20_2ftmp…ndjson` — a single shell word of\n * `[A-Za-z0-9._/-]`, because the encoder already removed every character a\n * shell can act on — so no canary fires and NOTHING in this suite, or in any\n * other real-provider suite, changes. Do not read a green run here as licence\n * to \"simplify\" `shellQuote` away.\n *\n * The quoting is pinned instead by exact-string unit tests in\n * `packages/ai-sandbox/tests/journal.test.ts`, which compare each composed\n * command to a literal containing the quotes. By name, one per command:\n * `journaledCommand` — \"redirects stdout to the journal, stderr to its own\n * file, and appends the exit sentinel\" plus \"quotes an adversarial runId so it\n * cannot inject shell metacharacters\"; `journalFollowCommand` — \"translates a\n * 0-based consumed-byte count into tail -c +N (1-based)\";\n * `journalReadCommand` — \"the bounded read drops -f and keeps the base64\n * frame, so a poll cannot hang\"; `journalExistsCommand` — \"probes through the\n * shell, never through fs.*\"; `journalStderrReadCommand` — \"reads a BOUNDED\n * tail of the sidecar, base64-framed, stderr silenced\";\n * `journalCleanupCommand`, `journalMtimeListCommand` and\n * `journalExitProbeCommand` — the first `it` under each of their `describe`s.\n * Those are the tests that go red on a dropped `shellQuote`; keep them exact.\n *\n * A provider that cannot satisfy the contract MUST declare `unsupported.reason`.\n * As in the journal and takeover suites there is deliberately no silent-skip\n * path: a conformance case that quietly returns prints as a pass, which is how\n * an unimplemented capability ships green.\n *\n * EVERY WAIT IN THIS FILE IS BOUNDED, and every journal directory is unique per\n * case — see {@link caseDir}. This suite DELETES FILES, and\n * `DEFAULT_JOURNAL_DIR` is a fixed absolute path shared with every other test\n * and, on local-process, with a developer's real runs.\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { randomUUID } from 'node:crypto'\nimport { describe, expect, it } from 'vitest'\nimport { EventType, InMemoryRunStore } from '@tanstack/ai'\nimport { InMemoryLockStore } from '@tanstack/ai/locks'\nimport {\n EXIT_SENTINEL_KEY,\n decodeJournalRunId,\n exitSentinelLine,\n journalCleanupCommand,\n journalExistsCommand,\n journalListCommand,\n journalMtimeListCommand,\n journalPaths,\n journalReadCommand,\n journalStderrReadCommand,\n journaledCommand,\n parseJournalMtimeListing,\n} from '../journal'\nimport { journalReadStrategy, readJournal } from '../journal-reader'\nimport { pruneJournals } from '../journal-sweep'\nimport { probeRunExit, reapDetachedRuns } from '../reap'\nimport { readJournalNdjson } from '../runner'\nimport { chunkFingerprint, createRunScopedIdGen } from '../chunk-identity'\nimport { waitForJournal } from './journal-conformance'\nimport type { JournalPaths } from '../journal'\nimport type { SandboxHandle } from '../contracts'\nimport type { RunStore, StreamChunk, StreamDurability } from '@tanstack/ai'\n\nexport interface ReaperConformanceConfig {\n /** Provider name, used in the describe title. */\n name: string\n /** Create a live sandbox plus its teardown. */\n createHandle: () => Promise<{\n handle: SandboxHandle\n dispose: () => Promise<void>\n }>\n /**\n * Declare that this provider cannot support the sweeps, with the reason.\n * Registers a skipped case whose title carries the reason — a NAMED skip,\n * visible in the reporter. Omit it and the suite runs.\n */\n unsupported?: { reason: string }\n /**\n * Declare that this provider's reads take the POLL strategy rather than the\n * FOLLOW one — i.e. `journalReadStrategy` answers `'poll'` for its handles.\n *\n * Only the FOLLOW half of the shell-hostile-runId case depends on it, so this\n * does not skip a case; it names itself in that case's title and the follow\n * read is omitted. The declaration is checked against the live handle there, in\n * both directions, so it cannot quietly remove coverage from a provider that\n * can in fact follow.\n */\n followUnsupported?: { reason: string }\n /**\n * Declare that this provider cannot run GNU `stat -c '%Y %n'`. The three\n * age-gate cases skip with this reason. Docker alpine is the authority on\n * the witness line; local-process on Darwin is BSD `stat`.\n */\n mtimeListUnsupported?: { reason: string }\n}\n\n/** Poll interval handed to providers that cannot follow a growing file. */\nconst POLL_INTERVAL_MS = 50\n\n/**\n * Quiescence window for the reaper's first append. Short because the agent in\n * these cases has provably stopped (the suite waited for its sentinel) — the\n * gate still runs, it just does not need to wait 5s to observe nothing.\n */\nconst FENCE_QUIET_MS = 25\n\n/**\n * Bound on a real journal read, so a reader that delivers nothing FAILS instead\n * of parking CI.\n *\n * Never an assertion, and deliberately far above anything a healthy read needs\n * (measured: 10–18s for the follow cases on both providers). Every use site\n * pairs it with a `backstopped: false` witness, so a read the CLOCK ended fails\n * naming this backstop rather than as a downstream transcript mismatch — which\n * means this number can be raised freely and must never be the thing a case is\n * tuned against.\n */\nconst READ_BACKSTOP_MS = 90_000\n\n/** Long enough that nothing in this suite is ever classified as expired. */\nconst NEVER_EXPIRES_MS = 60 * 60 * 1000\n\n/**\n * A journal directory nothing else on the machine writes to, created fresh for\n * EVERY case.\n *\n * Not `DEFAULT_JOURNAL_DIR`, and not even one directory per suite. Both sweeps\n * under test enumerate a whole directory and then DELETE from it, so a shared\n * directory would let one case's leftovers become another's input — and on\n * local-process the sandbox shell shares the host's real `/tmp`, where\n * `DEFAULT_JOURNAL_DIR` holds a developer's actual runs.\n */\nfunction caseDir(): string {\n return `/tmp/tanstack-reaper-conformance-${randomUUID()}`\n}\n\n/**\n * Unique per run, and it must be: `journalPaths` derives the filename from the\n * runId and the journal is append-only, so a reused id appends BEHIND the\n * previous run's `{\"__exit\":N}` sentinel and the new run appears to emit nothing\n * at all (see `journal.ts`).\n */\nfunction uniqueRunId(label: string): string {\n return `rp-${label}-${randomUUID()}`\n}\n\n/**\n * Single-quote a shell word, POSIX-style — the same rule `journal.ts`'s private\n * `shellQuote` applies.\n *\n * Duplicated rather than exported from production code on purpose: this exists\n * only for this suite's `rm -rf` teardown, which is not a production operation\n * and must not become one by growing an export for it.\n */\nfunction quote(value: string): string {\n return `'${value.replaceAll(\"'\", `'\\\\''`)}'`\n}\n\n/** Remove a case's journal directory and everything in it. Best effort. */\nasync function removeDir(handle: SandboxHandle, dir: string): Promise<void> {\n try {\n await handle.process.exec(`rm -rf ${quote(dir)}`)\n } catch {\n // The sandbox may already be gone, and on docker it is about to be. Nothing\n // under test depends on the directory being absent afterwards — the cases\n // that DO assert deletion assert it directly, per file.\n }\n}\n\n/**\n * Does `path` exist, according to the SANDBOX'S SHELL?\n *\n * `journalExistsCommand` rather than `handle.fs.exists`, for any path and not\n * just a journal: `journal.ts` rule 3 — on local-process `fs.*` resolves `/tmp`\n * under the sandbox root while a shell redirect hits the host's real `/tmp`, so\n * an `fs` probe would answer about a different file and every deletion\n * assertion in this suite would pass vacuously.\n */\nasync function fileExists(\n handle: SandboxHandle,\n path: string,\n): Promise<boolean> {\n const probe = await handle.process.exec(\n // Only `journal` is read by the probe, and its parameter is typed\n // `Pick<JournalPaths, 'journal'>` for exactly this reason: an arbitrary path\n // has no run behind it, so there is no nonce or sidecar to invent.\n journalExistsCommand({ journal: path }),\n )\n return probe.exitCode === 0\n}\n\n/** Filename as `ls -1` reports it, for a path inside `dir`. */\nfunction basename(dir: string, path: string): string {\n return path.slice(dir.length + 1)\n}\n\n/**\n * A real agent: a shell command printing one NDJSON line per delta, then\n * exiting.\n *\n * `printf '%s\\n' a b c` reuses the format for every operand on GNU coreutils\n * and on BusyBox alike, so this needs no loop. The JSON contains only double\n * quotes, so it is safe inside the POSIX single-quoted words this builds.\n */\nfunction emitLines(deltas: Array<string>): string {\n return `printf '%s\\\\n' ${deltas.map((delta) => `'{\"delta\":\"${delta}\"}'`).join(' ')}`\n}\n\n/**\n * Run a journaled agent to completion, so the `{\"__exit\":N}` sentinel is in the\n * journal by the time this resolves.\n *\n * `exec`, not `spawn`: `exec` waits, and a bounded wait is the only kind this\n * suite allows. (`SpawnHandle.wait()` is also not safe to call after the fact on\n * every provider — see `journal-conformance.ts`.)\n */\nasync function runAgent(\n handle: SandboxHandle,\n paths: JournalPaths,\n deltas: Array<string>,\n): Promise<void> {\n await handle.process.exec(journaledCommand(emitLines(deltas), paths))\n}\n\n/** `ls -1` output as a list of names. */\nasync function listNames(\n handle: SandboxHandle,\n dir: string,\n): Promise<Array<string>> {\n const listing = await handle.process.exec(journalListCommand(dir))\n return listing.stdout\n .split('\\n')\n .map((line) => line.trim())\n .filter((line) => line !== '')\n}\n\n/** Decode the base64 frame a bounded journal read produces. */\nfunction decodeJournalRead(stdout: string): string {\n return Buffer.from(stdout.replace(/\\s+/g, ''), 'base64').toString('utf8')\n}\n\n/**\n * The `stat -c '%Y %n'` listing for `dir`, plus the raw stdout so a case can\n * assert the WITNESS LINE itself rather than only its parsed consequence.\n */\nasync function mtimeListing(\n handle: SandboxHandle,\n dir: string,\n): Promise<{ stdout: string; entries: Map<string, number> }> {\n const probe = await handle.process.exec(journalMtimeListCommand(dir))\n const parsed = parseJournalMtimeListing(probe.stdout, dir)\n if (parsed.kind !== 'listed') {\n throw new Error(\n `reaper conformance: the mtime listing for ${dir} came back unavailable — ` +\n `stat -c '%Y %n' produced no witness line. stdout: ${JSON.stringify(probe.stdout)}`,\n )\n }\n return {\n stdout: probe.stdout,\n entries: new Map(\n parsed.entries.map((entry) => [entry.name, entry.mtimeMs]),\n ),\n }\n}\n\n/**\n * Is there a `<seconds> <dir>` line — `stat`'s report on its own first operand?\n *\n * That line, not the exit status, is the evidence the mechanism ran: BusyBox\n * exits 1 both for an EMPTY directory (whose unexpanded glob it cannot stat) and\n * for an unrecognised flag, and only the witness distinguishes them.\n */\nfunction hasWitnessLine(stdout: string, dir: string): boolean {\n return stdout\n .split('\\n')\n .some((line) => /^\\d+ (?<path>.+)$/.exec(line.trim())?.[1] === dir)\n}\n\n/** Read one file's mtime out of a listing, loudly when it is missing. */\nfunction mtimeOf(entries: Map<string, number>, name: string): number {\n const mtimeMs = entries.get(name)\n if (mtimeMs === undefined) {\n throw new Error(\n `reaper conformance: ${name} has no mtime in the stat listing, so the age gate cannot be exercised`,\n )\n }\n return mtimeMs\n}\n\n/**\n * An in-process event log with real accumulated state, plus the two facts the\n * reaper assertions need: what was appended, and how many times `close()` ran.\n *\n * `close()` is the load-bearing counter. `pipeToRunLog` ALWAYS calls it, so a\n * reaper that entered the pipe to find out whether a run finished would show up\n * here as `closes() === 1` — which ends every attached client's stream — even if\n * it happened to append nothing.\n */\ninterface ConformanceLog {\n log: StreamDurability\n stored: () => Array<StreamChunk>\n closes: () => number\n}\n\nfunction conformanceLog(): ConformanceLog {\n const entries: Array<{ offset: string; chunk: StreamChunk }> = []\n let closes = 0\n return {\n log: {\n resumeFrom: () => null,\n append: (chunks) =>\n Promise.resolve(\n chunks.map((chunk) => {\n const offset = `reap:${entries.length}`\n entries.push({ offset, chunk })\n return offset\n }),\n ),\n // Nothing here tails the log — every assertion reads the appended\n // transcript, and a `read` would park until `close()` (see `align.ts`).\n read: () => (async function* empty() {})(),\n close: () => {\n closes += 1\n return Promise.resolve()\n },\n snapshot: () => Promise.resolve(entries.map((entry) => ({ ...entry }))),\n },\n stored: () => entries.map((entry) => entry.chunk),\n closes: () => closes,\n }\n}\n\n/**\n * A `RunStore` that counts its MUTATIONS, so the leave-alone case can assert\n * that a producing run's record was not written at all.\n *\n * \"Status still `'running'`\" is too weak on its own: `driverEpoch` is bumped by\n * `withRunClaim` before any status is written, so a reaper that claimed a live\n * run and then bailed would still read as `'running'`. Counting `update` sees\n * that; reading the status does not.\n */\ninterface CountingRunStore {\n runs: RunStore\n updates: () => number\n}\n\nfunction countingRunStore(inner: InMemoryRunStore): CountingRunStore {\n let updates = 0\n return {\n runs: {\n createOrResume: (...args) => inner.createOrResume(...args),\n update: (...args) => {\n updates += 1\n return inner.update(...args)\n },\n get: (...args) => inner.get(...args),\n listByThread: (...args) => inner.listByThread(...args),\n listReclaimable: (...args) => inner.listReclaimable(...args),\n findActiveRun: (...args) => inner.findActiveRun(...args),\n },\n updates: () => updates,\n }\n}\n\n/** The event a journal line translates into. `timestamp` is excluded from `chunkFingerprint`. */\nfunction contentChunk(messageId: string, delta: string): StreamChunk {\n return {\n type: EventType.TEXT_MESSAGE_CONTENT,\n messageId,\n delta,\n timestamp: Date.now(),\n }\n}\n\n/**\n * Narrow one parsed journal line into its chunk.\n *\n * Fields are validated and the chunk REBUILT from them rather than asserted into\n * shape: a cast would let a provider that mangles the bytes reach\n * `chunkFingerprint` as a structurally invalid chunk and fail somewhere\n * unrelated.\n */\nfunction toChunk(\n runId: string,\n messageId: string,\n value: unknown,\n): StreamChunk {\n if (typeof value !== 'object' || value === null || !('delta' in value)) {\n throw new Error(\n `reaper conformance: run ${runId} journal line is not an agent event: ${JSON.stringify(value)}`,\n )\n }\n const delta = value.delta\n if (typeof delta !== 'string') {\n throw new Error(\n `reaper conformance: run ${runId} journal line has a non-string delta: ${JSON.stringify(value)}`,\n )\n }\n return contentChunk(messageId, delta)\n}\n\n/** Deterministic translator: re-reading the journal reproduces the same chunks. */\nasync function* translate(\n runId: string,\n lines: AsyncIterable<unknown>,\n): AsyncIterable<StreamChunk> {\n const messageId = createRunScopedIdGen(runId)()\n for await (const line of lines) yield toChunk(runId, messageId, line)\n}\n\n/** A comparable transcript: each chunk reduced to its fingerprint. */\nfunction transcript(chunks: Array<StreamChunk>): Array<string> {\n return chunks.map(chunkFingerprint)\n}\n\n/** The chunks a run over `deltas` must deliver, exactly once and in order. */\nfunction expectedTranscript(\n runId: string,\n deltas: Array<string>,\n): Array<StreamChunk> {\n const messageId = createRunScopedIdGen(runId)()\n return deltas.map((delta) => contentChunk(messageId, delta))\n}\n\n/**\n * The reaper's `drive`: read the run's journal from byte 0 and translate it.\n *\n * The read is bounded independently of `signal` so a journal that stops growing\n * fails the case instead of hanging CI.\n *\n * Returns the drive alongside `backstopped()`, the causal witness for\n * {@link READ_BACKSTOP_MS}: the case must assert it is `false` before its\n * transcript assertions, so a read the CLOCK ended fails naming the backstop\n * instead of as a truncated-transcript diff.\n */\nfunction driveFromJournal(\n handle: SandboxHandle,\n dir: string,\n): {\n drive: (input: {\n runId: string\n threadId: string\n signal: AbortSignal\n }) => AsyncIterable<StreamChunk>\n /** True if any read this drive started was ended by the backstop clock. */\n backstopped: () => boolean\n} {\n // One entry per `drive` invocation, so a sweep that drives more than one run\n // cannot hide a backstopped read behind a healthy one.\n const backstops: Array<AbortSignal> = []\n return {\n drive: ({ runId, signal }) => {\n // Not the assertion — see {@link READ_BACKSTOP_MS}. `backstopped()` is what\n // proves the clock was not what ended the read.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n backstops.push(backstop)\n return translate(\n runId,\n readJournalNdjson(handle, {\n signal: AbortSignal.any([signal, backstop]),\n journal: { runId, dir, pollIntervalMs: POLL_INTERVAL_MS },\n }),\n )\n },\n backstopped: () => backstops.some((s) => s.aborted),\n }\n}\n\n/** A `'running'`, DETACHED record — the shape `listReclaimable` selects on. */\nasync function detachedRun(\n store: RunStore,\n runId: string,\n threadId: string,\n detachedSince: number,\n): Promise<void> {\n await store.createOrResume({ runId, threadId, startedAt: Date.now() })\n await store.update(runId, { detachedSince })\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n/**\n * Assert `createHandle` satisfies the sweep conformance contract. Each `it` gets\n * a fresh sandbox via `createHandle`/`dispose`, a fresh journal directory, and\n * unique runIds, so no case can observe another's files.\n */\nexport function runReaperConformance(config: ReaperConformanceConfig): void {\n describe(`reaper conformance — ${config.name}`, () => {\n if (config.unsupported) {\n it.skip(`unsupported: ${config.unsupported.reason}`, () => {\n expect(true).toBe(true)\n })\n return\n }\n\n // -----------------------------------------------------------------------\n // 1. `pruneJournals` against a real filesystem.\n // -----------------------------------------------------------------------\n it(\n \"deletes a terminal run's journal AND its .err sidecar, while a running run's journal survives the same sweep\",\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const terminalId = uniqueRunId('terminal')\n const liveId = uniqueRunId('live')\n const terminal = journalPaths(terminalId, dir)\n const live = journalPaths(liveId, dir)\n try {\n await runAgent(handle, terminal, ['1'])\n await runAgent(handle, live, ['1'])\n // Premise: all four files really exist before the sweep, otherwise\n // \"deleted\" below would be indistinguishable from \"never written\".\n expect({\n terminalJournal: await fileExists(handle, terminal.journal),\n terminalSidecar: await fileExists(handle, terminal.stderr),\n liveJournal: await fileExists(handle, live.journal),\n }).toEqual({\n terminalJournal: true,\n terminalSidecar: true,\n liveJournal: true,\n })\n\n const runs = new InMemoryRunStore()\n await runs.createOrResume({\n runId: terminalId,\n threadId: `${terminalId}-t`,\n startedAt: Date.now(),\n })\n await runs.update(terminalId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n await runs.createOrResume({\n runId: liveId,\n threadId: `${liveId}-t`,\n startedAt: Date.now(),\n })\n\n const result = await pruneJournals({ handle, runs, dir })\n expect(result.deleted).toEqual([terminalId])\n expect(result.failures).toEqual([])\n expect(result.kept).toEqual([\n { runId: liveId, names: expect.any(Array), reason: 'non-terminal' },\n ])\n\n // The files, through the shell. A sweep that reported a deletion it did\n // not perform passes every assertion above and fails here.\n expect({\n terminalJournal: await fileExists(handle, terminal.journal),\n terminalSidecar: await fileExists(handle, terminal.stderr),\n liveJournal: await fileExists(handle, live.journal),\n liveSidecar: await fileExists(handle, live.stderr),\n }).toEqual({\n terminalJournal: false,\n terminalSidecar: false,\n liveJournal: true,\n liveSidecar: true,\n })\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'sweeps the same terminal run twice without a failure, and rm -f of an already-absent journal exits 0',\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('twice')\n const paths = journalPaths(runId, dir)\n try {\n await runAgent(handle, paths, ['1'])\n const runs = new InMemoryRunStore()\n await runs.createOrResume({\n runId,\n threadId: `${runId}-t`,\n startedAt: Date.now(),\n })\n await runs.update(runId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n\n const first = await pruneJournals({ handle, runs, dir })\n expect(first.deleted).toEqual([runId])\n\n // The second sweep sees an empty directory. It must report nothing to\n // do rather than a failure — a cron runs this every tick forever.\n const second = await pruneJournals({ handle, runs, dir })\n expect({\n listed: second.listed,\n runIds: second.runIds,\n deleted: second.deleted,\n kept: second.kept,\n failures: second.failures,\n }).toEqual({\n listed: 0,\n runIds: 0,\n deleted: [],\n kept: [],\n failures: [],\n })\n\n // And the `rm -f` the sweep issues is itself idempotent on this shell.\n // Asserted directly because the sweep folds a non-zero `rm` into\n // `kept: 'delete-failed'` and would therefore hide it as a keep.\n const rerun = await handle.process.exec(journalCleanupCommand(paths))\n expect(rerun.exitCode).toBe(0)\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'leaves a filename it cannot decode alone, while still sweeping the terminal run beside it',\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('undecodable')\n const paths = journalPaths(runId, dir)\n // `_1.` is not a two-hex-digit escape, so this name is `malformed` — the\n // shape a truncated or foreign file has. `decodeJournalRunId` must refuse\n // it, and the sweep must keep it WITHOUT asking the store, because a\n // plausible-but-wrong runId could answer `terminal` for someone else.\n const strayName = 'reaper-conformance-stray_1.ndjson'\n const strayPath = `${dir}/${strayName}`\n try {\n await runAgent(handle, paths, ['1'])\n await handle.process.exec(\n `printf 'not a journal\\\\n' >> ${quote(strayPath)}`,\n )\n expect(await fileExists(handle, strayPath)).toBe(true)\n expect(decodeJournalRunId(strayName).kind).toBe('malformed')\n\n const runs = new InMemoryRunStore()\n await runs.createOrResume({\n runId,\n threadId: `${runId}-t`,\n startedAt: Date.now(),\n })\n await runs.update(runId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n\n const result = await pruneJournals({ handle, runs, dir })\n expect(result.deleted).toEqual([runId])\n expect(result.kept).toEqual([\n { names: [strayName], reason: 'undecodable-name' },\n ])\n expect(result.failures).toEqual([])\n expect({\n strayKept: await fileExists(handle, strayPath),\n journalDeleted: !(await fileExists(handle, paths.journal)),\n }).toEqual({ strayKept: true, journalDeleted: true })\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n // -----------------------------------------------------------------------\n // 2. The age gate on a real shell.\n // -----------------------------------------------------------------------\n const mtimeSkip = config.mtimeListUnsupported\n const itMtime = (title: string, timeout: number, fn: () => Promise<void>) =>\n it(\n mtimeSkip ? `${title} (unsupported: ${mtimeSkip.reason})` : title,\n { timeout, skip: Boolean(mtimeSkip) },\n fn,\n )\n\n itMtime(\n \"emits stat's self-witness line for a populated directory, so the age gate is usable\",\n 60_000,\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('witness')\n const paths = journalPaths(runId, dir)\n try {\n await runAgent(handle, paths, ['1'])\n const listing = await mtimeListing(handle, dir)\n // The witness is what makes \"no files\" distinguishable from \"the\n // mechanism is unavailable\". On BusyBox 1.37 — the docker provider's\n // `alpine:3` — `find -newermt`/`-printf` are unrecognised and exit 1\n // with empty stdout, which is exactly why the design is a witness line\n // rather than a `find` and an exit code.\n expect(hasWitnessLine(listing.stdout, dir)).toBe(true)\n expect([...listing.entries.keys()].sort()).toEqual(\n [basename(dir, paths.journal), basename(dir, paths.stderr)].sort(),\n )\n // Real epoch times, not the parser's zeroes: a `%Y` the shell did not\n // expand would parse as no entry at all, and a `stat` that printed\n // something else would land far from now.\n for (const mtimeMs of listing.entries.values()) {\n expect(Math.abs(Date.now() - mtimeMs)).toBeLessThan(120_000)\n }\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n itMtime(\n 'reports an EMPTY journal directory as witness-only rather than unavailable',\n 60_000,\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n try {\n await handle.process.exec(`mkdir -p ${quote(dir)}`)\n const listing = await mtimeListing(handle, dir)\n expect(hasWitnessLine(listing.stdout, dir)).toBe(true)\n expect([...listing.entries.keys()]).toEqual([])\n\n // And the sweep agrees: an empty directory is a LISTED age gate, not an\n // unavailable one. `'unavailable'` here would silently disable orphan\n // expiry forever on this provider.\n const result = await pruneJournals({\n handle,\n runs: new InMemoryRunStore(),\n dir,\n })\n expect({\n listed: result.listed,\n ageGate: result.ageGate,\n deleted: result.deleted,\n failures: result.failures,\n }).toEqual({\n listed: 0,\n ageGate: 'listed',\n deleted: [],\n failures: [],\n })\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n itMtime(\n 'keeps an orphan younger than orphanTtlMs and sweeps the older one, in the same pass',\n 120_000,\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const olderId = uniqueRunId('older')\n const newerId = uniqueRunId('newer')\n const older = journalPaths(olderId, dir)\n const newer = journalPaths(newerId, dir)\n try {\n await runAgent(handle, older, ['1'])\n // `stat -c '%Y'` is second-granular, so the two runs must be more than\n // one second apart for their ages to be distinguishable at all.\n await sleep(2_500)\n await runAgent(handle, newer, ['1'])\n\n // The cutoff is computed from the REAL mtimes the real shell reported,\n // not from a fabricated timestamp: that is the whole point of running\n // this against a provider. `pruneJournals` keeps when the NEWEST of a\n // run's files is strictly newer than the cutoff, so placing the cutoff\n // between the two runs must expire exactly one of them.\n const listing = await mtimeListing(handle, dir)\n const newestOf = (paths: JournalPaths): number =>\n Math.max(\n mtimeOf(listing.entries, basename(dir, paths.journal)),\n mtimeOf(listing.entries, basename(dir, paths.stderr)),\n )\n const olderMtime = newestOf(older)\n const newerMtime = newestOf(newer)\n expect(newerMtime - olderMtime).toBeGreaterThanOrEqual(1_000)\n\n const now = Date.now()\n const cutoff = olderMtime + Math.floor((newerMtime - olderMtime) / 2)\n // NEITHER run is in the store, so both take the orphan arm and only the\n // age gate decides between them.\n const result = await pruneJournals({\n handle,\n runs: new InMemoryRunStore(),\n dir,\n now,\n orphanTtlMs: now - cutoff,\n })\n expect(result.ageGate).toBe('listed')\n expect(result.deleted).toEqual([olderId])\n expect(result.kept).toEqual([\n {\n runId: newerId,\n names: expect.any(Array),\n reason: 'orphan-too-recent',\n },\n ])\n expect(result.failures).toEqual([])\n expect({\n olderJournal: await fileExists(handle, older.journal),\n olderSidecar: await fileExists(handle, older.stderr),\n newerJournal: await fileExists(handle, newer.journal),\n newerSidecar: await fileExists(handle, newer.stderr),\n }).toEqual({\n olderJournal: false,\n olderSidecar: false,\n newerJournal: true,\n newerSidecar: true,\n })\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n // -----------------------------------------------------------------------\n // 3. `reapDetachedRuns` end to end.\n // -----------------------------------------------------------------------\n it(\n 'finalizes a detached run whose agent reached its sentinel, and its transcript lands',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('finalize')\n const threadId = `${runId}-t`\n const deltas = ['1', '2', '3']\n const paths = journalPaths(runId, dir)\n try {\n const runs = new InMemoryRunStore()\n const detachedSince = Date.now()\n await detachedRun(runs, runId, threadId, detachedSince)\n await runAgent(handle, paths, deltas)\n\n // The probe, on its own, before any sweep: this read is what makes the\n // reaper safe, and it must answer from the JOURNAL rather than from the\n // delivery log (which a detached run's dead host stopped appending to).\n expect(await probeRunExit({ handle, runId, dir })).toEqual({\n state: 'finished',\n exitCode: 0,\n })\n\n const log = conformanceLog()\n const journalDrive = driveFromJournal(handle, dir)\n const result = await reapDetachedRuns({\n runs,\n locks: new InMemoryLockStore(),\n durability: () => log.log,\n hasFinished: (record) =>\n probeRunExit({ handle, runId: record.runId, dir }),\n drive: journalDrive.drive,\n now: Date.now(),\n detachedRunTtlMs: NEVER_EXPIRES_MS,\n fenceQuietMs: FENCE_QUIET_MS,\n })\n\n // The causal witness, before anything downstream — see\n // {@link READ_BACKSTOP_MS}. The reaper's read ends at the sentinel; if\n // the clock ended it instead, the transcript below is short and the\n // failure must name the backstop rather than a missing chunk.\n expect({ backstopped: journalDrive.backstopped() }).toEqual({\n backstopped: false,\n })\n expect({\n considered: result.considered,\n probed: result.probed,\n finalized: result.outcomes.finalized,\n }).toEqual({ considered: 1, probed: 1, finalized: 1 })\n expect(result.runs).toEqual([\n { runId, outcome: 'finalized', status: 'completed', exitCode: 0 },\n ])\n // The transcript, element for element — the reaper's whole purpose is\n // that the run a nobody watched still ends up saved.\n expect(transcript(log.stored())).toEqual(\n transcript(expectedTranscript(runId, deltas)),\n )\n const record = await runs.get(runId)\n expect(record?.status).toBe('completed')\n // NEVER CLEARED: `detachedSince` is what the next sweep selects on, and\n // clearing it would reset the TTL on every pass.\n expect(record?.detachedSince).toBe(detachedSince)\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'reports a still-producing detached run as producing and leaves it completely untouched',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('producing')\n const threadId = `${runId}-t`\n const paths = journalPaths(runId, dir)\n const store = countingRunStore(new InMemoryRunStore())\n // A REAL agent that has written a line and is genuinely still alive: no\n // sentinel can be in the journal, and driving it would truncate a healthy\n // run's transcript at line one.\n const agent = await handle.process.spawn(\n journaledCommand(`${emitLines(['1'])}; sleep 30`, paths),\n )\n try {\n const detachedSince = Date.now()\n await detachedRun(store.runs, runId, threadId, detachedSince)\n await waitForJournal(handle, paths)\n const read = await handle.process.exec(journalReadCommand(paths, 0))\n const text = decodeJournalRead(read.stdout)\n // Producing, provably: the first line is there and the sentinel is not.\n expect(text).toContain('{\"delta\":\"1\"}')\n expect(text).not.toContain('__exit')\n expect(await probeRunExit({ handle, runId, dir })).toEqual({\n state: 'producing',\n })\n\n const log = conformanceLog()\n let driveCalled = false\n let durabilityCalls = 0\n const result = await reapDetachedRuns({\n runs: store.runs,\n locks: new InMemoryLockStore(),\n durability: () => {\n durabilityCalls += 1\n return log.log\n },\n hasFinished: (record) =>\n probeRunExit({ handle, runId: record.runId, dir }),\n drive: () => {\n driveCalled = true\n return (async function* never() {})()\n },\n now: Date.now(),\n detachedRunTtlMs: NEVER_EXPIRES_MS,\n fenceQuietMs: FENCE_QUIET_MS,\n })\n\n expect(result.runs).toEqual([{ runId, outcome: 'producing' }])\n expect({\n considered: result.considered,\n probed: result.probed,\n producing: result.outcomes.producing,\n finalized: result.outcomes.finalized,\n expired: result.outcomes.expired,\n failed: result.outcomes.failed,\n }).toEqual({\n considered: 1,\n probed: 1,\n producing: 1,\n finalized: 0,\n expired: 0,\n failed: 0,\n })\n\n // ABSENCE, asserted in one object so a regression names which\n // guarantee broke instead of failing on whichever line came first.\n // Every one of these is a way the pre-`probeRunExit` design destroyed a\n // live run: an append duplicates its prefix, a `close()` ends every\n // attached client's stream, an `update` writes `'completed'` and drops\n // the run out of `listReclaimable` forever, and a moved\n // `detachedSince` restarts its TTL.\n const record = await store.runs.get(runId)\n expect({\n driveCalled,\n durabilityCalls,\n appended: log.stored().length,\n closes: log.closes(),\n updatesAfterSetup: store.updates() - 1,\n status: record?.status,\n detachedSince: record?.detachedSince,\n driverEpoch: record?.driverEpoch,\n }).toEqual({\n driveCalled: false,\n durabilityCalls: 0,\n appended: 0,\n closes: 0,\n updatesAfterSetup: 0,\n status: 'running',\n detachedSince,\n driverEpoch: undefined,\n })\n } finally {\n // The agent outlives the sweep on purpose; reap it here so no `sleep`\n // survives the case.\n try {\n await agent.kill()\n } catch {\n // Already gone, or a provider whose sandbox teardown covers it.\n }\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n // -----------------------------------------------------------------------\n // 4. A shell-hostile runId, end to end. SECURITY-RELEVANT.\n // -----------------------------------------------------------------------\n it(\n 'round-trips a shell-hostile runId through encode, journal, follow, sidecar read, list, decode and delete without executing any of it' +\n (config.followUnsupported === undefined\n ? ''\n : ` (follow read omitted: ${config.followUnsupported.reason})`),\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const nonce = randomUUID().slice(0, 8)\n // The canary lives OUTSIDE `dir` so the teardown `rm -rf` cannot be what\n // makes the final assertion pass.\n const canary = `/tmp/rp-pwn-${nonce}`\n // `/` would escape the directory, the space would split the word, `;` and\n // `$( )` would start new commands, and the `touch` is a real payload with\n // an observable effect. Every one of these must survive as DATA.\n //\n // THE ORDER OF THE PAYLOAD IS DELIBERATE and was measured: the `;touch`\n // comes BEFORE the space and the `/`. With raw interpolation, the\n // journaled command's redirect target is one word, so a payload whose\n // space precedes the `;` (`rp-a b;touch …`) makes the mangled command a\n // SYNTAX ERROR — the injected `touch` never runs and the canary below\n // would be decoration that can never fire. With the `;` first, the\n // vulnerable form parses as a command LIST and the payload really\n // executes (verified against a hand-composed unquoted, unencoded command\n // on this provider: canary present). So the canary is a live detector.\n const runId = `rp-a;touch ${canary};b c/d$(x)-${nonce}`\n const threadId = `${runId}-t`\n const paths = journalPaths(runId, dir)\n const journalName = basename(dir, paths.journal)\n try {\n // This case's agent writes to STDERR as well, so the sidecar read\n // below has real bytes to compare against: an empty sidecar is also\n // what a `journalStderrReadCommand` that read the wrong path (or\n // nothing at all) would return, and that read is the only coverage\n // that command has anywhere.\n await handle.process.exec(\n journaledCommand(\n `${emitLines(['1'])}; printf 'boom\\\\n' 1>&2`,\n paths,\n ),\n )\n // THE SECURITY ASSERTION, and deliberately the FIRST one: nothing the\n // runId contains was executed. It is stated before the cheaper\n // structural checks below on purpose — a defect that reintroduces raw\n // interpolation would also fail the filename shape, and a case that\n // short-circuited there would never prove this probe is live rather\n // than decorative. Re-asserted after the delete, because the sweep\n // composes a DIFFERENT command (`rm -f`) from the same id.\n expect(await fileExists(handle, canary)).toBe(false)\n expect(await probeRunExit({ handle, runId, dir })).toEqual({\n state: 'finished',\n exitCode: 0,\n })\n // The encoding is what bought that: the filename carries no character\n // a shell can act on, and it stays inside the journal directory.\n expect(journalName).toMatch(/^[A-Za-z0-9._-]+\\.ndjson$/)\n expect(paths.journal.startsWith(`${dir}/`)).toBe(true)\n\n // THE FOLLOW PATH, against this same hostile id.\n //\n // `journalFollowCommand` is the WORST command in the set under a\n // dropped `encodeRunId`: it interpolates the journal path THREE times\n // (`mkdir -p`, `: >> path`, `tail -c +N -f path`) and joins its prep\n // steps with `;` rather than `&&`, so `: >> /tmp/dir/rp-a;touch\n // <canary>;…` is a complete redirect followed by a command LIST — the\n // payload runs on EVERY attach, and a failing prep step does not stop\n // it. Nothing else reaches this command with a hostile runId: the\n // reaper's own probes are all bounded reads, and the takeover suite,\n // the only other real-provider consumer of the follow path, builds\n // alnum-only ids. So it is exercised here, where the hostile id and a\n // live canary already exist, for the cost of one read.\n //\n // The strategy is FORCED rather than capability-derived so this is the\n // follow command and not the bounded one, and the declaration is\n // checked against the live handle in both directions — a config that\n // does not describe the provider must fail rather than silently drop\n // this read.\n expect(journalReadStrategy(handle)).toBe(\n config.followUnsupported === undefined ? 'follow' : 'poll',\n )\n if (config.followUnsupported === undefined) {\n const followed: Array<string> = []\n // A backstop, so a reader that delivers nothing fails instead of\n // parking CI — a `tail -f` never ends on its own, so this read has no\n // other floor. Not the assertion — `backstopped` below proves it was\n // not what ended the loop.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n for await (const line of readJournal(handle, {\n paths,\n fromByte: 0,\n strategy: 'follow',\n signal: backstop,\n })) {\n followed.push(line.line)\n // The agent has already reached its sentinel, so this arrives; a\n // `tail -f` never ends on its own.\n if (line.line.includes(EXIT_SENTINEL_KEY)) break\n }\n // The causal witness, first: the loop must end on the sentinel\n // `break`, not on the clock. A backstopped follow read otherwise\n // reports as a one-element-vs-two array diff that says nothing about\n // why.\n expect({ backstopped: backstop.aborted }).toEqual({\n backstopped: false,\n })\n expect(followed).toEqual([\n '{\"delta\":\"1\"}',\n exitSentinelLine(paths, 0),\n ])\n expect(await fileExists(handle, canary)).toBe(false)\n }\n\n // The stderr SIDECAR read, which no other conformance case reaches at\n // all. Same hostile id, same canary, one `exec`.\n const sidecar = await handle.process.exec(\n journalStderrReadCommand(paths),\n )\n expect(decodeJournalRead(sidecar.stdout)).toBe('boom\\n')\n expect(await fileExists(handle, canary)).toBe(false)\n\n // encode → journal → list → decode: the sweep's actual path back to a\n // runId, over a real `ls -1`.\n const names = await listNames(handle, dir)\n expect(names.sort()).toEqual(\n [journalName, basename(dir, paths.stderr)].sort(),\n )\n expect(decodeJournalRunId(journalName)).toEqual({\n kind: 'runId',\n runId,\n })\n\n const runs = new InMemoryRunStore()\n await runs.createOrResume({ runId, threadId, startedAt: Date.now() })\n await runs.update(runId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n const result = await pruneJournals({ handle, runs, dir })\n expect(result.deleted).toEqual([runId])\n expect(result.failures).toEqual([])\n expect({\n journalDeleted: !(await fileExists(handle, paths.journal)),\n sidecarDeleted: !(await fileExists(handle, paths.stderr)),\n canaryAbsent: !(await fileExists(handle, canary)),\n }).toEqual({\n journalDeleted: true,\n sidecarDeleted: true,\n canaryAbsent: true,\n })\n } finally {\n await handle.process\n .exec(`rm -f ${quote(canary)}`)\n .catch(() => undefined)\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n })\n}\n"],"mappings":"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"}
|
package/dist/esm/tool-history.js
CHANGED
|
@@ -96,7 +96,7 @@ function createToolHistoryRecorder() {
|
|
|
96
96
|
return {
|
|
97
97
|
observe(chunk, target) {
|
|
98
98
|
if (chunk.type === EventType.TOOL_CALL_START) {
|
|
99
|
-
const name = chunk.toolCallName
|
|
99
|
+
const name = chunk.toolCallName;
|
|
100
100
|
if (!name) return;
|
|
101
101
|
open.set(chunk.toolCallId, {
|
|
102
102
|
name,
|
|
@@ -107,20 +107,19 @@ function createToolHistoryRecorder() {
|
|
|
107
107
|
if (chunk.type === EventType.TOOL_CALL_ARGS) {
|
|
108
108
|
const call = open.get(chunk.toolCallId);
|
|
109
109
|
if (!call) return;
|
|
110
|
-
call.args
|
|
110
|
+
call.args += chunk.delta;
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
113
|
if (chunk.type === EventType.TOOL_CALL_END) {
|
|
114
114
|
const call = open.get(chunk.toolCallId);
|
|
115
115
|
if (!call) return;
|
|
116
116
|
open.delete(chunk.toolCallId);
|
|
117
|
-
const args = chunk.input !== void 0 ? JSON.stringify(chunk.input) : call.args;
|
|
118
117
|
recorded.push({
|
|
119
118
|
id: chunk.toolCallId,
|
|
120
119
|
name: call.name,
|
|
121
|
-
args
|
|
120
|
+
args: call.args
|
|
122
121
|
});
|
|
123
|
-
appendCall(target, chunk.toolCallId, call.name, args);
|
|
122
|
+
appendCall(target, chunk.toolCallId, call.name, call.args);
|
|
124
123
|
return;
|
|
125
124
|
}
|
|
126
125
|
if (chunk.type === EventType.TOOL_CALL_RESULT) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-history.js","names":[],"sources":["../../src/tool-history.ts"],"sourcesContent":["/**\n * Turn a harness's PASSTHROUGH tool-call chunks into transcript messages, so a\n * finished run's tool cards survive a reload.\n *\n * Why this is needed at all: a harness executes its tools INSIDE the sandbox, so\n * `chat()` only relays its `TOOL_CALL_*` chunks — it never writes an assistant\n * message for them (`addAssistantToolCallMessage` is gated on the engine having\n * executed the tool itself). Chat persistence stores `ctx.messages`, so the whole\n * tool history existed only in the delivery log. Replaying that log is what makes\n * \"switch away and come back\" show everything; a FINISHED thread has no run to\n * rejoin, hydrates from the message store instead, and so came back as nothing but\n * the prompt and the final answer.\n *\n * Recording the calls as ordinary `toolCalls` + `role: 'tool'` messages needs no new\n * wire format and no client change: `modelMessagesToUIMessages` already merges a tool\n * result into the call it belongs to and marks the part complete, and\n * `reconstructChat` already runs that converter.\n */\nimport { EventType } from '@tanstack/ai'\nimport type { ModelMessage, StreamChunk } from '@tanstack/ai'\n\n/**\n * Metadata key set on every tool call recorded here.\n *\n * INTERNAL, and deliberately not exported: an app asks {@link isSandboxToolCall}\n * instead of knowing the key. Renaming it is a storage-visible change, because it ends\n * up inside stored `toolCalls[].metadata`, so the recorder test pins the literal.\n */\nconst SANDBOX_OBSERVED = 'sandboxObserved'\n\n/**\n * What the recorder writes into. `ChatMiddlewareContext.messages` is a\n * `ReadonlyArray`, so the transcript grows by REPLACING the array — the same way the\n * engine itself syncs `middlewareCtx.messages`.\n */\ninterface TranscriptTarget {\n messages: ReadonlyArray<ModelMessage>\n}\n\ninterface OpenCall {\n name: string\n /** Accumulated `TOOL_CALL_ARGS` deltas; superseded by `input` when the adapter sends it. */\n args: string\n}\n\nexport interface ToolHistoryRecorder {\n /** Feed every chunk. Observes only — never transforms or drops. */\n observe: (chunk: StreamChunk, target: TranscriptTarget) => void\n /**\n * Re-append anything missing from the transcript.\n *\n * The engine reassigns `middlewareCtx.messages` from its own array whenever it\n * syncs config (once per agent iteration), which discards writes made during the\n * previous iteration's stream. Reconciling at each iteration boundary and again at\n * finish makes the result independent of that, and independent of where this\n * middleware sits relative to persistence in the middleware array.\n */\n reconcile: (target: TranscriptTarget) => void\n}\n\n/**\n * True when this tool call was executed by the HARNESS inside the sandbox, and\n * recorded into the transcript for display, rather than executed by the agent loop.\n *\n * Use it to decide what your own `MessageStore` keeps — these calls are display\n * history, so dropping or capping them is safe (they are already stripped from the\n * request to the model on the next turn). Also works on a `tool-call` UI part, whose\n * `metadata` is copied straight from the model message.\n *\n * `metadata` is `unknown` on both, so the key can only be read behind a typeof/`in`\n * check; this mirrors the core `isProviderExecutedToolCall` convention.\n *\n * ```ts\n * import { isSandboxToolCall } from '@tanstack/ai-sandbox'\n *\n * const kept = messages.filter(\n * (message) => !message.toolCalls?.every(isSandboxToolCall),\n * )\n * ```\n */\nexport function isSandboxToolCall(\n toolCall: { metadata?: unknown } | null | undefined,\n): boolean {\n const metadata = toolCall?.metadata\n return (\n typeof metadata === 'object' &&\n metadata !== null &&\n SANDBOX_OBSERVED in metadata &&\n metadata[SANDBOX_OBSERVED] === true\n )\n}\n\n/** Does the transcript already carry this tool call, from any source? */\nfunction hasCall(messages: ReadonlyArray<ModelMessage>, id: string): boolean {\n return messages.some((message) =>\n message.toolCalls?.some((call) => call.id === id),\n )\n}\n\n/** Does the transcript already carry this tool result? */\nfunction hasResult(messages: ReadonlyArray<ModelMessage>, id: string): boolean {\n return messages.some(\n (message) => message.role === 'tool' && message.toolCallId === id,\n )\n}\n\nfunction callMessage(id: string, name: string, args: string): ModelMessage {\n return {\n role: 'assistant',\n content: null,\n toolCalls: [\n {\n id,\n type: 'function',\n function: { name, arguments: args },\n metadata: { [SANDBOX_OBSERVED]: true },\n },\n ],\n }\n}\n\nfunction resultMessage(id: string, content: string): ModelMessage {\n return { role: 'tool', toolCallId: id, content }\n}\n\nexport function createToolHistoryRecorder(): ToolHistoryRecorder {\n const open = new Map<string, OpenCall>()\n /** Completed calls in the order they ran — the order `reconcile` restores. */\n const recorded: Array<{ id: string; name: string; args: string }> = []\n const results = new Map<string, string>()\n\n function appendCall(\n target: TranscriptTarget,\n id: string,\n name: string,\n args: string,\n ): void {\n // An id already present is either the engine's own (it executed the tool itself)\n // or a chunk seen before — a journal replay on takeover re-emits the whole\n // stream. Either way a second write would duplicate the card.\n if (hasCall(target.messages, id)) return\n target.messages = [...target.messages, callMessage(id, name, args)]\n }\n\n function appendResult(\n target: TranscriptTarget,\n id: string,\n content: string,\n ): void {\n if (hasResult(target.messages, id)) return\n target.messages = [...target.messages, resultMessage(id, content)]\n }\n\n const recorder: ToolHistoryRecorder = {\n // An if/else chain rather than a `switch`: only four of the ~20 chunk types are\n // interesting here, and a `switch` on `chunk.type` has to enumerate all of them\n // to satisfy the exhaustiveness lint.\n observe(chunk, target) {\n if (chunk.type === EventType.TOOL_CALL_START) {\n // `toolCallName` is the AG-UI field; `toolName` is its deprecated alias, and\n // that alias is what several harness adapters still emit.\n const name = chunk.toolCallName ?? chunk.toolName\n if (!name) return\n open.set(chunk.toolCallId, { name, args: '' })\n return\n }\n if (chunk.type === EventType.TOOL_CALL_ARGS) {\n const call = open.get(chunk.toolCallId)\n if (!call) return\n // `args` is the accumulated-so-far field. Prefer it over stitching deltas:\n // an adapter that sends both would otherwise double the arguments.\n call.args = chunk.args ?? call.args + chunk.delta\n return\n }\n if (chunk.type === EventType.TOOL_CALL_END) {\n const call = open.get(chunk.toolCallId)\n if (!call) return\n open.delete(chunk.toolCallId)\n // `input` is the final PARSED input, so it beats the streamed string, which\n // can be a truncated fragment if the arguments stream was cut short.\n const args =\n chunk.input !== undefined ? JSON.stringify(chunk.input) : call.args\n recorded.push({ id: chunk.toolCallId, name: call.name, args })\n appendCall(target, chunk.toolCallId, call.name, args)\n return\n }\n if (chunk.type === EventType.TOOL_CALL_RESULT) {\n // AG-UI types `content` as a string; anything else is not a result we can\n // store as a `role: 'tool'` message.\n if (typeof chunk.content !== 'string') return\n results.set(chunk.toolCallId, chunk.content)\n appendResult(target, chunk.toolCallId, chunk.content)\n }\n },\n\n reconcile(target) {\n for (const { id, name, args } of recorded) {\n appendCall(target, id, name, args)\n const result = results.get(id)\n // The result goes straight after its own call, so a restored transcript reads\n // in the order the tools actually ran.\n if (result !== undefined) appendResult(target, id, result)\n }\n },\n }\n return recorder\n}\n\n/**\n * Drop recorded harness tool calls from a list of messages bound for the model.\n *\n * A stored transcript becomes the history for the NEXT turn. These calls name tools\n * the provider was never given, and one triage-sized run is hundreds of kilobytes of\n * tool output — so replaying them is wasteful at best and rejected at worst. They stay\n * in `ctx.messages` (which is what gets stored and rendered); only the request to the\n * model loses them.\n *\n * An assistant message is dropped only when EVERY call on it is observed, so a mixed\n * message — one engine tool call plus one harness tool call — is left alone rather than\n * silently losing the engine's half.\n */\nexport function stripObservedToolCalls(\n messages: ReadonlyArray<ModelMessage>,\n): Array<ModelMessage> {\n const dropped = new Set<string>()\n const kept: Array<ModelMessage> = []\n for (const message of messages) {\n const calls = message.toolCalls\n if (calls && calls.length > 0 && calls.every(isSandboxToolCall)) {\n for (const call of calls) dropped.add(call.id)\n continue\n }\n // Orphaning a result is worse than keeping it: a provider rejects a tool result\n // whose call is not in the history.\n if (\n message.role === 'tool' &&\n message.toolCallId !== undefined &&\n dropped.has(message.toolCallId)\n ) {\n continue\n }\n kept.push(message)\n }\n return kept\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tool-history.js","names":[],"sources":["../../src/tool-history.ts"],"sourcesContent":["/**\n * Turn a harness's PASSTHROUGH tool-call chunks into transcript messages, so a\n * finished run's tool cards survive a reload.\n *\n * Why this is needed at all: a harness executes its tools INSIDE the sandbox, so\n * `chat()` only relays its `TOOL_CALL_*` chunks — it never writes an assistant\n * message for them (`addAssistantToolCallMessage` is gated on the engine having\n * executed the tool itself). Chat persistence stores `ctx.messages`, so the whole\n * tool history existed only in the delivery log. Replaying that log is what makes\n * \"switch away and come back\" show everything; a FINISHED thread has no run to\n * rejoin, hydrates from the message store instead, and so came back as nothing but\n * the prompt and the final answer.\n *\n * Recording the calls as ordinary `toolCalls` + `role: 'tool'` messages needs no new\n * wire format and no client change: `modelMessagesToUIMessages` already merges a tool\n * result into the call it belongs to and marks the part complete, and\n * `reconstructChat` already runs that converter.\n */\nimport { EventType } from '@tanstack/ai'\nimport type { ModelMessage, StreamChunk } from '@tanstack/ai'\n\n/**\n * Metadata key set on every tool call recorded here.\n *\n * INTERNAL, and deliberately not exported: an app asks {@link isSandboxToolCall}\n * instead of knowing the key. Renaming it is a storage-visible change, because it ends\n * up inside stored `toolCalls[].metadata`, so the recorder test pins the literal.\n */\nconst SANDBOX_OBSERVED = 'sandboxObserved'\n\n/**\n * What the recorder writes into. `ChatMiddlewareContext.messages` is a\n * `ReadonlyArray`, so the transcript grows by REPLACING the array — the same way the\n * engine itself syncs `middlewareCtx.messages`.\n */\ninterface TranscriptTarget {\n messages: ReadonlyArray<ModelMessage>\n}\n\ninterface OpenCall {\n name: string\n /** Accumulated `TOOL_CALL_ARGS` deltas. */\n args: string\n}\n\nexport interface ToolHistoryRecorder {\n /** Feed every chunk. Observes only — never transforms or drops. */\n observe: (chunk: StreamChunk, target: TranscriptTarget) => void\n /**\n * Re-append anything missing from the transcript.\n *\n * The engine reassigns `middlewareCtx.messages` from its own array whenever it\n * syncs config (once per agent iteration), which discards writes made during the\n * previous iteration's stream. Reconciling at each iteration boundary and again at\n * finish makes the result independent of that, and independent of where this\n * middleware sits relative to persistence in the middleware array.\n */\n reconcile: (target: TranscriptTarget) => void\n}\n\n/**\n * True when this tool call was executed by the HARNESS inside the sandbox, and\n * recorded into the transcript for display, rather than executed by the agent loop.\n *\n * Use it to decide what your own `MessageStore` keeps — these calls are display\n * history, so dropping or capping them is safe (they are already stripped from the\n * request to the model on the next turn). Also works on a `tool-call` UI part, whose\n * `metadata` is copied straight from the model message.\n *\n * `metadata` is `unknown` on both, so the key can only be read behind a typeof/`in`\n * check; this mirrors the core `isProviderExecutedToolCall` convention.\n *\n * ```ts\n * import { isSandboxToolCall } from '@tanstack/ai-sandbox'\n *\n * const kept = messages.filter(\n * (message) => !message.toolCalls?.every(isSandboxToolCall),\n * )\n * ```\n */\nexport function isSandboxToolCall(\n toolCall: { metadata?: unknown } | null | undefined,\n): boolean {\n const metadata = toolCall?.metadata\n return (\n typeof metadata === 'object' &&\n metadata !== null &&\n SANDBOX_OBSERVED in metadata &&\n metadata[SANDBOX_OBSERVED] === true\n )\n}\n\n/** Does the transcript already carry this tool call, from any source? */\nfunction hasCall(messages: ReadonlyArray<ModelMessage>, id: string): boolean {\n return messages.some((message) =>\n message.toolCalls?.some((call) => call.id === id),\n )\n}\n\n/** Does the transcript already carry this tool result? */\nfunction hasResult(messages: ReadonlyArray<ModelMessage>, id: string): boolean {\n return messages.some(\n (message) => message.role === 'tool' && message.toolCallId === id,\n )\n}\n\nfunction callMessage(id: string, name: string, args: string): ModelMessage {\n return {\n role: 'assistant',\n content: null,\n toolCalls: [\n {\n id,\n type: 'function',\n function: { name, arguments: args },\n metadata: { [SANDBOX_OBSERVED]: true },\n },\n ],\n }\n}\n\nfunction resultMessage(id: string, content: string): ModelMessage {\n return { role: 'tool', toolCallId: id, content }\n}\n\nexport function createToolHistoryRecorder(): ToolHistoryRecorder {\n const open = new Map<string, OpenCall>()\n /** Completed calls in the order they ran — the order `reconcile` restores. */\n const recorded: Array<{ id: string; name: string; args: string }> = []\n const results = new Map<string, string>()\n\n function appendCall(\n target: TranscriptTarget,\n id: string,\n name: string,\n args: string,\n ): void {\n // An id already present is either the engine's own (it executed the tool itself)\n // or a chunk seen before — a journal replay on takeover re-emits the whole\n // stream. Either way a second write would duplicate the card.\n if (hasCall(target.messages, id)) return\n target.messages = [...target.messages, callMessage(id, name, args)]\n }\n\n function appendResult(\n target: TranscriptTarget,\n id: string,\n content: string,\n ): void {\n if (hasResult(target.messages, id)) return\n target.messages = [...target.messages, resultMessage(id, content)]\n }\n\n const recorder: ToolHistoryRecorder = {\n // An if/else chain rather than a `switch`: only four of the ~20 chunk types are\n // interesting here, and a `switch` on `chunk.type` has to enumerate all of them\n // to satisfy the exhaustiveness lint.\n observe(chunk, target) {\n if (chunk.type === EventType.TOOL_CALL_START) {\n const name = chunk.toolCallName\n if (!name) return\n open.set(chunk.toolCallId, { name, args: '' })\n return\n }\n if (chunk.type === EventType.TOOL_CALL_ARGS) {\n const call = open.get(chunk.toolCallId)\n if (!call) return\n call.args += chunk.delta\n return\n }\n if (chunk.type === EventType.TOOL_CALL_END) {\n const call = open.get(chunk.toolCallId)\n if (!call) return\n open.delete(chunk.toolCallId)\n recorded.push({\n id: chunk.toolCallId,\n name: call.name,\n args: call.args,\n })\n appendCall(target, chunk.toolCallId, call.name, call.args)\n return\n }\n if (chunk.type === EventType.TOOL_CALL_RESULT) {\n // AG-UI types `content` as a string; anything else is not a result we can\n // store as a `role: 'tool'` message.\n if (typeof chunk.content !== 'string') return\n results.set(chunk.toolCallId, chunk.content)\n appendResult(target, chunk.toolCallId, chunk.content)\n }\n },\n\n reconcile(target) {\n for (const { id, name, args } of recorded) {\n appendCall(target, id, name, args)\n const result = results.get(id)\n // The result goes straight after its own call, so a restored transcript reads\n // in the order the tools actually ran.\n if (result !== undefined) appendResult(target, id, result)\n }\n },\n }\n return recorder\n}\n\n/**\n * Drop recorded harness tool calls from a list of messages bound for the model.\n *\n * A stored transcript becomes the history for the NEXT turn. These calls name tools\n * the provider was never given, and one triage-sized run is hundreds of kilobytes of\n * tool output — so replaying them is wasteful at best and rejected at worst. They stay\n * in `ctx.messages` (which is what gets stored and rendered); only the request to the\n * model loses them.\n *\n * An assistant message is dropped only when EVERY call on it is observed, so a mixed\n * message — one engine tool call plus one harness tool call — is left alone rather than\n * silently losing the engine's half.\n */\nexport function stripObservedToolCalls(\n messages: ReadonlyArray<ModelMessage>,\n): Array<ModelMessage> {\n const dropped = new Set<string>()\n const kept: Array<ModelMessage> = []\n for (const message of messages) {\n const calls = message.toolCalls\n if (calls && calls.length > 0 && calls.every(isSandboxToolCall)) {\n for (const call of calls) dropped.add(call.id)\n continue\n }\n // Orphaning a result is worse than keeping it: a provider rejects a tool result\n // whose call is not in the history.\n if (\n message.role === 'tool' &&\n message.toolCallId !== undefined &&\n dropped.has(message.toolCallId)\n ) {\n continue\n }\n kept.push(message)\n }\n return kept\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-sandbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "MIT",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@ngrok/ngrok": "^1.0.0",
|
|
62
62
|
"vitest": "^4.1.10",
|
|
63
|
-
"@tanstack/ai": "^0.
|
|
64
|
-
"@tanstack/ai-persistence": "^0.
|
|
63
|
+
"@tanstack/ai": "^0.49.1",
|
|
64
|
+
"@tanstack/ai-persistence": "^0.5.1"
|
|
65
65
|
},
|
|
66
66
|
"peerDependenciesMeta": {
|
|
67
67
|
"@ngrok/ngrok": {
|
|
@@ -78,8 +78,8 @@
|
|
|
78
78
|
"@ngrok/ngrok": "^1.7.0",
|
|
79
79
|
"@vitest/coverage-v8": "4.1.10",
|
|
80
80
|
"vitest": "^4.1.10",
|
|
81
|
-
"@tanstack/ai": "0.
|
|
82
|
-
"@tanstack/ai-persistence": "0.
|
|
81
|
+
"@tanstack/ai": "0.49.1",
|
|
82
|
+
"@tanstack/ai-persistence": "0.5.1"
|
|
83
83
|
},
|
|
84
84
|
"scripts": {
|
|
85
85
|
"build": "vite build",
|
package/src/approvals.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* `approvalId` is stable for a given (provider, kind, target) so a client grant
|
|
16
16
|
* matches the same action on the resumed run.
|
|
17
17
|
*/
|
|
18
|
-
import { EventType } from '@tanstack/ai'
|
|
18
|
+
import { EventType, withTanstackMetadata } from '@tanstack/ai'
|
|
19
19
|
import { evaluateCommand } from './policy'
|
|
20
20
|
import type { SandboxPolicy } from './policy'
|
|
21
21
|
import type { StreamChunk } from '@tanstack/ai'
|
|
@@ -81,16 +81,17 @@ export function buildApprovalRequestedEvent(input: {
|
|
|
81
81
|
runId: string
|
|
82
82
|
detail?: Record<string, unknown>
|
|
83
83
|
}): StreamChunk {
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
return withTanstackMetadata(
|
|
85
|
+
{
|
|
86
|
+
type: EventType.CUSTOM,
|
|
87
|
+
name: APPROVAL_REQUESTED_EVENT,
|
|
88
|
+
value: {
|
|
89
|
+
approvalId: input.approvalId,
|
|
90
|
+
title: input.title,
|
|
91
|
+
...(input.detail ?? {}),
|
|
92
|
+
},
|
|
93
|
+
timestamp: Date.now(),
|
|
91
94
|
},
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
runId: input.runId,
|
|
95
|
-
}
|
|
95
|
+
{ threadId: input.threadId, runId: input.runId },
|
|
96
|
+
) as StreamChunk
|
|
96
97
|
}
|
package/src/bridge-events.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* stream into its translated output — so events interleave live while the agent
|
|
11
11
|
* runs (e.g. code mode's `code_mode:console` logs during a long execution).
|
|
12
12
|
*/
|
|
13
|
-
import { EventType } from '@tanstack/ai'
|
|
13
|
+
import { EventType, withTanstackMetadata } from '@tanstack/ai'
|
|
14
14
|
import type { StreamChunk } from '@tanstack/ai'
|
|
15
15
|
|
|
16
16
|
export interface BridgeEventChannel {
|
|
@@ -50,15 +50,21 @@ export function createBridgeEventChannel(meta: {
|
|
|
50
50
|
return {
|
|
51
51
|
emitCustomEvent(eventName, value) {
|
|
52
52
|
if (closed) return
|
|
53
|
-
buffer.push(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
buffer.push(
|
|
54
|
+
withTanstackMetadata(
|
|
55
|
+
{
|
|
56
|
+
type: EventType.CUSTOM,
|
|
57
|
+
name: eventName,
|
|
58
|
+
value,
|
|
59
|
+
timestamp: Date.now(),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
model: meta.model,
|
|
63
|
+
...(meta.threadId !== undefined ? { threadId: meta.threadId } : {}),
|
|
64
|
+
...(meta.runId !== undefined ? { runId: meta.runId } : {}),
|
|
65
|
+
},
|
|
66
|
+
) as StreamChunk,
|
|
67
|
+
)
|
|
62
68
|
notify?.()
|
|
63
69
|
},
|
|
64
70
|
close() {
|
package/src/chunk-identity.ts
CHANGED
|
@@ -29,8 +29,16 @@
|
|
|
29
29
|
* exactly that field — nothing downstream keys on a chunk's timestamp, so
|
|
30
30
|
* leaving it wall-clock is safe, but every other field must participate in
|
|
31
31
|
* the comparison or a real divergence would go undetected.
|
|
32
|
+
* 3. Adapter yields still carry leftover TanStack extras (`content`, `args`,
|
|
33
|
+
* `finishReason`). The durability log stores spec chunks. Fingerprints keep
|
|
34
|
+
* only AG-UI spec keys and drop `metadata.tanstack`, so a live adapter yield
|
|
35
|
+
* matches the stored spec chunk.
|
|
32
36
|
*/
|
|
33
37
|
import type { StreamChunk } from '@tanstack/ai'
|
|
38
|
+
import {
|
|
39
|
+
isSpecTopLevelKey,
|
|
40
|
+
tanstackMetadata,
|
|
41
|
+
} from '@tanstack/ai/adapter-internals'
|
|
34
42
|
|
|
35
43
|
/**
|
|
36
44
|
* A deterministic id generator scoped to one run.
|
|
@@ -112,16 +120,37 @@ function stableStringify(
|
|
|
112
120
|
* cannot spuriously diverge.
|
|
113
121
|
* - **Recurses into nested arrays and objects**: tool-call arguments are
|
|
114
122
|
* nested, and a shallow fingerprint would miss a changed argument.
|
|
115
|
-
* - **Excludes
|
|
116
|
-
*
|
|
123
|
+
* - **Excludes `timestamp` and leftover adapter extras.** Spec keys
|
|
124
|
+
* participate, including `undefined` values. `metadata.tanstack` is dropped
|
|
125
|
+
* so stored spec chunks match live adapter yields.
|
|
117
126
|
* - **Distinguishes present-but-`undefined` from absent**: `undefined` is
|
|
118
127
|
* encoded as the sentinel string `"__undefined__"` rather than dropped, so
|
|
119
128
|
* `{a: undefined}` and `{}` do not collide. A translator emitting an
|
|
120
129
|
* explicit `undefined` is a different chunk shape and must fingerprint
|
|
121
130
|
* differently.
|
|
122
131
|
*/
|
|
132
|
+
function fingerprintableChunk(chunk: StreamChunk): Record<string, unknown> {
|
|
133
|
+
const out: Record<string, unknown> = {}
|
|
134
|
+
for (const [key, value] of Object.entries(chunk)) {
|
|
135
|
+
if (key === 'timestamp') continue
|
|
136
|
+
if (!isSpecTopLevelKey(chunk.type, key)) continue
|
|
137
|
+
if (key === 'metadata' && value != null && typeof value === 'object') {
|
|
138
|
+
const rest: Record<string, unknown> = {}
|
|
139
|
+
for (const [metaKey, metaValue] of Object.entries(value)) {
|
|
140
|
+
if (metaKey === 'tanstack') continue
|
|
141
|
+
rest[metaKey] = metaValue
|
|
142
|
+
}
|
|
143
|
+
if (Object.keys(rest).length === 0) continue
|
|
144
|
+
out.metadata = rest
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
out[key] = value
|
|
148
|
+
}
|
|
149
|
+
return out
|
|
150
|
+
}
|
|
151
|
+
|
|
123
152
|
export function chunkFingerprint(chunk: StreamChunk): string {
|
|
124
|
-
return stableStringify(chunk, VOLATILE_FIELDS)
|
|
153
|
+
return stableStringify(fingerprintableChunk(chunk), VOLATILE_FIELDS)
|
|
125
154
|
}
|
|
126
155
|
|
|
127
156
|
/**
|
|
@@ -136,7 +165,7 @@ export function chunkFingerprint(chunk: StreamChunk): string {
|
|
|
136
165
|
* `JournalReplayThreadIdMismatchError` in `align.ts`).
|
|
137
166
|
*/
|
|
138
167
|
export function chunkFingerprintIgnoringThreadId(chunk: StreamChunk): string {
|
|
139
|
-
return stableStringify(chunk, VOLATILE_AND_THREAD_ID)
|
|
168
|
+
return stableStringify(fingerprintableChunk(chunk), VOLATILE_AND_THREAD_ID)
|
|
140
169
|
}
|
|
141
170
|
|
|
142
171
|
/**
|
|
@@ -150,5 +179,7 @@ export function chunkFingerprintIgnoringThreadId(chunk: StreamChunk): string {
|
|
|
150
179
|
export function chunkThreadId(chunk: StreamChunk): string | undefined {
|
|
151
180
|
const record: Record<string, unknown> = chunk as Record<string, unknown>
|
|
152
181
|
const value = record[THREAD_ID_FIELD]
|
|
153
|
-
|
|
182
|
+
if (typeof value === 'string') return value
|
|
183
|
+
const nested = tanstackMetadata(chunk)?.threadId
|
|
184
|
+
return typeof nested === 'string' ? nested : undefined
|
|
154
185
|
}
|
|
@@ -132,6 +132,12 @@ export interface ReaperConformanceConfig {
|
|
|
132
132
|
* can in fact follow.
|
|
133
133
|
*/
|
|
134
134
|
followUnsupported?: { reason: string }
|
|
135
|
+
/**
|
|
136
|
+
* Declare that this provider cannot run GNU `stat -c '%Y %n'`. The three
|
|
137
|
+
* age-gate cases skip with this reason. Docker alpine is the authority on
|
|
138
|
+
* the witness line; local-process on Darwin is BSD `stat`.
|
|
139
|
+
*/
|
|
140
|
+
mtimeListUnsupported?: { reason: string }
|
|
135
141
|
}
|
|
136
142
|
|
|
137
143
|
/** Poll interval handed to providers that cannot follow a growing file. */
|
|
@@ -712,9 +718,17 @@ export function runReaperConformance(config: ReaperConformanceConfig): void {
|
|
|
712
718
|
// -----------------------------------------------------------------------
|
|
713
719
|
// 2. The age gate on a real shell.
|
|
714
720
|
// -----------------------------------------------------------------------
|
|
715
|
-
|
|
721
|
+
const mtimeSkip = config.mtimeListUnsupported
|
|
722
|
+
const itMtime = (title: string, timeout: number, fn: () => Promise<void>) =>
|
|
723
|
+
it(
|
|
724
|
+
mtimeSkip ? `${title} (unsupported: ${mtimeSkip.reason})` : title,
|
|
725
|
+
{ timeout, skip: Boolean(mtimeSkip) },
|
|
726
|
+
fn,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
itMtime(
|
|
716
730
|
"emits stat's self-witness line for a populated directory, so the age gate is usable",
|
|
717
|
-
|
|
731
|
+
60_000,
|
|
718
732
|
async () => {
|
|
719
733
|
const { handle, dispose } = await config.createHandle()
|
|
720
734
|
const dir = caseDir()
|
|
@@ -745,9 +759,9 @@ export function runReaperConformance(config: ReaperConformanceConfig): void {
|
|
|
745
759
|
},
|
|
746
760
|
)
|
|
747
761
|
|
|
748
|
-
|
|
762
|
+
itMtime(
|
|
749
763
|
'reports an EMPTY journal directory as witness-only rather than unavailable',
|
|
750
|
-
|
|
764
|
+
60_000,
|
|
751
765
|
async () => {
|
|
752
766
|
const { handle, dispose } = await config.createHandle()
|
|
753
767
|
const dir = caseDir()
|
|
@@ -783,9 +797,9 @@ export function runReaperConformance(config: ReaperConformanceConfig): void {
|
|
|
783
797
|
},
|
|
784
798
|
)
|
|
785
799
|
|
|
786
|
-
|
|
800
|
+
itMtime(
|
|
787
801
|
'keeps an orphan younger than orphanTtlMs and sweeps the older one, in the same pass',
|
|
788
|
-
|
|
802
|
+
120_000,
|
|
789
803
|
async () => {
|
|
790
804
|
const { handle, dispose } = await config.createHandle()
|
|
791
805
|
const dir = caseDir()
|
package/src/tool-history.ts
CHANGED
|
@@ -39,7 +39,7 @@ interface TranscriptTarget {
|
|
|
39
39
|
|
|
40
40
|
interface OpenCall {
|
|
41
41
|
name: string
|
|
42
|
-
/** Accumulated `TOOL_CALL_ARGS` deltas
|
|
42
|
+
/** Accumulated `TOOL_CALL_ARGS` deltas. */
|
|
43
43
|
args: string
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -157,9 +157,7 @@ export function createToolHistoryRecorder(): ToolHistoryRecorder {
|
|
|
157
157
|
// to satisfy the exhaustiveness lint.
|
|
158
158
|
observe(chunk, target) {
|
|
159
159
|
if (chunk.type === EventType.TOOL_CALL_START) {
|
|
160
|
-
|
|
161
|
-
// that alias is what several harness adapters still emit.
|
|
162
|
-
const name = chunk.toolCallName ?? chunk.toolName
|
|
160
|
+
const name = chunk.toolCallName
|
|
163
161
|
if (!name) return
|
|
164
162
|
open.set(chunk.toolCallId, { name, args: '' })
|
|
165
163
|
return
|
|
@@ -167,21 +165,19 @@ export function createToolHistoryRecorder(): ToolHistoryRecorder {
|
|
|
167
165
|
if (chunk.type === EventType.TOOL_CALL_ARGS) {
|
|
168
166
|
const call = open.get(chunk.toolCallId)
|
|
169
167
|
if (!call) return
|
|
170
|
-
|
|
171
|
-
// an adapter that sends both would otherwise double the arguments.
|
|
172
|
-
call.args = chunk.args ?? call.args + chunk.delta
|
|
168
|
+
call.args += chunk.delta
|
|
173
169
|
return
|
|
174
170
|
}
|
|
175
171
|
if (chunk.type === EventType.TOOL_CALL_END) {
|
|
176
172
|
const call = open.get(chunk.toolCallId)
|
|
177
173
|
if (!call) return
|
|
178
174
|
open.delete(chunk.toolCallId)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
appendCall(target, chunk.toolCallId, call.name, args)
|
|
175
|
+
recorded.push({
|
|
176
|
+
id: chunk.toolCallId,
|
|
177
|
+
name: call.name,
|
|
178
|
+
args: call.args,
|
|
179
|
+
})
|
|
180
|
+
appendCall(target, chunk.toolCallId, call.name, call.args)
|
|
185
181
|
return
|
|
186
182
|
}
|
|
187
183
|
if (chunk.type === EventType.TOOL_CALL_RESULT) {
|