@tanstack/ai-sandbox 0.2.4 → 0.3.0
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/agents-file.js +53 -34
- package/dist/esm/agents-file.js.map +1 -1
- package/dist/esm/align.d.ts +121 -0
- package/dist/esm/align.js +197 -0
- package/dist/esm/align.js.map +1 -0
- package/dist/esm/approvals.js +63 -29
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/attach-preflight.d.ts +85 -0
- package/dist/esm/attach-preflight.js +189 -0
- package/dist/esm/attach-preflight.js.map +1 -0
- package/dist/esm/bootstrap.js +103 -117
- package/dist/esm/bootstrap.js.map +1 -1
- package/dist/esm/bridge-events.js +96 -71
- package/dist/esm/bridge-events.js.map +1 -1
- package/dist/esm/capabilities.d.ts +0 -5
- package/dist/esm/capabilities.js +32 -28
- package/dist/esm/capabilities.js.map +1 -1
- package/dist/esm/chunk-identity.d.ts +52 -0
- package/dist/esm/chunk-identity.js +102 -0
- package/dist/esm/chunk-identity.js.map +1 -0
- package/dist/esm/claim.d.ts +187 -0
- package/dist/esm/claim.js +349 -0
- package/dist/esm/claim.js.map +1 -0
- package/dist/esm/contracts.d.ts +13 -0
- package/dist/esm/driver.d.ts +83 -0
- package/dist/esm/driver.js +138 -0
- package/dist/esm/driver.js.map +1 -0
- package/dist/esm/durability.d.ts +263 -0
- package/dist/esm/durability.js +230 -0
- package/dist/esm/durability.js.map +1 -0
- package/dist/esm/errors.js +28 -24
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/file-diff.js +151 -135
- package/dist/esm/file-diff.js.map +1 -1
- package/dist/esm/git-exec.js +51 -62
- package/dist/esm/git-exec.js.map +1 -1
- package/dist/esm/harness-cwd.js +24 -19
- package/dist/esm/harness-cwd.js.map +1 -1
- package/dist/esm/index.d.ts +30 -8
- package/dist/esm/index.js +23 -91
- package/dist/esm/instance-store.d.ts +88 -0
- package/dist/esm/instance-store.js +67 -0
- package/dist/esm/instance-store.js.map +1 -0
- package/dist/esm/journal-bytes.d.ts +67 -0
- package/dist/esm/journal-bytes.js +110 -0
- package/dist/esm/journal-bytes.js.map +1 -0
- package/dist/esm/journal-reader.d.ts +66 -0
- package/dist/esm/journal-reader.js +228 -0
- package/dist/esm/journal-reader.js.map +1 -0
- package/dist/esm/journal-sweep.d.ts +113 -0
- package/dist/esm/journal-sweep.js +309 -0
- package/dist/esm/journal-sweep.js.map +1 -0
- package/dist/esm/journal.d.ts +542 -0
- package/dist/esm/journal.js +679 -0
- package/dist/esm/journal.js.map +1 -0
- package/dist/esm/key.js +36 -33
- package/dist/esm/key.js.map +1 -1
- package/dist/esm/middleware.d.ts +50 -2
- package/dist/esm/middleware.js +335 -208
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.js +75 -49
- package/dist/esm/ngrok.js.map +1 -1
- package/dist/esm/policy.js +43 -34
- package/dist/esm/policy.js.map +1 -1
- package/dist/esm/projection.js +16 -8
- package/dist/esm/projection.js.map +1 -1
- package/dist/esm/reap.d.ts +238 -0
- package/dist/esm/reap.js +355 -0
- package/dist/esm/reap.js.map +1 -0
- package/dist/esm/reclaim.d.ts +84 -0
- package/dist/esm/reclaim.js +106 -0
- package/dist/esm/reclaim.js.map +1 -0
- package/dist/esm/remote-tools.js +73 -62
- package/dist/esm/remote-tools.js.map +1 -1
- package/dist/esm/run.d.ts +93 -25
- package/dist/esm/run.js +274 -79
- package/dist/esm/run.js.map +1 -1
- package/dist/esm/runner.d.ts +119 -2
- package/dist/esm/runner.js +270 -51
- package/dist/esm/runner.js.map +1 -1
- package/dist/esm/sandbox.d.ts +3 -2
- package/dist/esm/sandbox.js +139 -123
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/secrets.js +39 -47
- package/dist/esm/secrets.js.map +1 -1
- package/dist/esm/setup-plan.js +22 -14
- package/dist/esm/setup-plan.js.map +1 -1
- package/dist/esm/shell.d.ts +8 -0
- package/dist/esm/shell.js +197 -158
- package/dist/esm/shell.js.map +1 -1
- package/dist/esm/testkit/conformance.d.ts +16 -0
- package/dist/esm/testkit/conformance.js +97 -0
- package/dist/esm/testkit/conformance.js.map +1 -0
- package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
- package/dist/esm/testkit/journal-conformance.d.ts +51 -0
- package/dist/esm/testkit/journal-conformance.js +378 -0
- package/dist/esm/testkit/journal-conformance.js.map +1 -0
- package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
- package/dist/esm/testkit/reaper-conformance.js +847 -0
- package/dist/esm/testkit/reaper-conformance.js.map +1 -0
- package/dist/esm/testkit/shell-spawn.d.ts +2 -0
- package/dist/esm/testkit/shell-spawn.js +60 -0
- package/dist/esm/testkit/shell-spawn.js.map +1 -0
- package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
- package/dist/esm/testkit/takeover-conformance.js +685 -0
- package/dist/esm/testkit/takeover-conformance.js.map +1 -0
- package/dist/esm/tool-bridge.js +227 -180
- package/dist/esm/tool-bridge.js.map +1 -1
- package/dist/esm/tool-history.d.ts +62 -0
- package/dist/esm/tool-history.js +171 -0
- package/dist/esm/tool-history.js.map +1 -0
- package/dist/esm/watch.js +310 -236
- package/dist/esm/watch.js.map +1 -1
- package/dist/esm/workspace.d.ts +1 -1
- package/dist/esm/workspace.js +49 -28
- package/dist/esm/workspace.js.map +1 -1
- package/package.json +16 -6
- package/skills/ai-sandbox/SKILL.md +658 -20
- package/src/align.ts +297 -0
- package/src/attach-preflight.ts +292 -0
- package/src/capabilities.ts +4 -13
- package/src/chunk-identity.ts +154 -0
- package/src/claim.ts +479 -0
- package/src/contracts.ts +13 -0
- package/src/driver.ts +205 -0
- package/src/durability.ts +380 -0
- package/src/index.ts +212 -27
- package/src/instance-store.ts +122 -0
- package/src/journal-bytes.ts +136 -0
- package/src/journal-reader.ts +359 -0
- package/src/journal-sweep.ts +406 -0
- package/src/journal.ts +875 -0
- package/src/middleware.ts +470 -30
- package/src/reap.ts +723 -0
- package/src/reclaim.ts +191 -0
- package/src/run.ts +365 -75
- package/src/runner.ts +347 -3
- package/src/sandbox.ts +38 -8
- package/src/shell.ts +106 -38
- package/src/testkit/conformance.ts +117 -0
- package/src/testkit/durable-run-fields-conformance.ts +147 -0
- package/src/testkit/journal-conformance.ts +676 -0
- package/src/testkit/reaper-conformance.ts +1201 -0
- package/src/testkit/shell-spawn.ts +67 -0
- package/src/testkit/takeover-conformance.ts +1040 -0
- package/src/tool-history.ts +245 -0
- package/src/workspace.ts +1 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/run-log.d.ts +0 -81
- package/dist/esm/run-log.js +0 -107
- package/dist/esm/run-log.js.map +0 -1
- package/dist/esm/store.d.ts +0 -53
- package/dist/esm/store.js +0 -34
- package/dist/esm/store.js.map +0 -1
- package/src/run-log.ts +0 -224
- package/src/store.ts +0 -83
package/dist/esm/agents-file.js
CHANGED
|
@@ -1,44 +1,63 @@
|
|
|
1
|
-
|
|
1
|
+
//#region src/agents-file.ts
|
|
2
|
+
/** CLI instruction-file names that should resolve to AGENTS.md. */
|
|
3
|
+
var SYMLINK_NAMES = ["CLAUDE.md", "GEMINI.md"];
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the directory a `gitSkill` repo is cloned into when no explicit
|
|
6
|
+
* `into` override is provided. The convention is:
|
|
7
|
+
*
|
|
8
|
+
* `<root>/.tanstack-skills/<basename>`
|
|
9
|
+
*
|
|
10
|
+
* where `basename` is derived from the `repo` field by taking the last
|
|
11
|
+
* path segment and stripping a trailing `.git` suffix.
|
|
12
|
+
*
|
|
13
|
+
* Per-harness projectors (e.g. the Claude Code adapter) import this helper
|
|
14
|
+
* so they can locate cloned skill repos consistently.
|
|
15
|
+
*
|
|
16
|
+
* @param root - Workspace root inside the sandbox (e.g. `/workspace`).
|
|
17
|
+
* @param skill - A `WorkspaceSkill` of `kind === 'git'`.
|
|
18
|
+
*/
|
|
2
19
|
function resolveGitSkillDir(root, skill) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
return `${root}/.tanstack-skills/${basename}`;
|
|
20
|
+
const rawBasename = skill.repo.split("/").pop() ?? skill.repo;
|
|
21
|
+
return `${root}/.tanstack-skills/${rawBasename.endsWith(".git") ? rawBasename.slice(0, -4) : rawBasename}`;
|
|
6
22
|
}
|
|
23
|
+
/** Format workspace scripts as a `## Workspace scripts` markdown section. */
|
|
7
24
|
function formatWorkspaceScriptsSection(scripts) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return `## Workspace scripts
|
|
12
|
-
|
|
13
|
-
${lines.join("\n")}`;
|
|
25
|
+
const names = Object.keys(scripts).sort();
|
|
26
|
+
if (names.length === 0) return "";
|
|
27
|
+
return `## Workspace scripts\n\n${names.map((name) => `- ${name} → ${scripts[name]}`).join("\n")}`;
|
|
14
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Merge base AGENTS.md content with an optional workspace scripts section.
|
|
31
|
+
* Returns `undefined` when there is nothing to write.
|
|
32
|
+
*/
|
|
15
33
|
function mergeAgentsContent(base, scripts) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
${scriptsSection}`;
|
|
34
|
+
const scriptsSection = scripts !== void 0 ? formatWorkspaceScriptsSection(scripts) : "";
|
|
35
|
+
if (base === void 0 && scriptsSection.length === 0) return void 0;
|
|
36
|
+
if (base === void 0) return scriptsSection;
|
|
37
|
+
if (scriptsSection.length === 0) return base;
|
|
38
|
+
return `${base.trimEnd()}\n\n${scriptsSection}`;
|
|
23
39
|
}
|
|
40
|
+
/** Escape a string for safe use as a single-quoted shell argument. */
|
|
24
41
|
function sqEscape(value) {
|
|
25
|
-
|
|
42
|
+
return value.replace(/'/g, `'\\''`);
|
|
26
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Write `AGENTS.md` under `root` and create per-CLI symlinks (or copies as a
|
|
46
|
+
* fallback when `ln -s` is unavailable).
|
|
47
|
+
*
|
|
48
|
+
* @param handle - The sandbox handle providing `fs` and `process`.
|
|
49
|
+
* @param root - Absolute path inside the sandbox under which to write.
|
|
50
|
+
* @param content - Markdown content for the instruction file.
|
|
51
|
+
*/
|
|
27
52
|
async function writeAgentsFile(handle, root, content) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
await handle.fs.write(`${root}/${name}`, content);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
53
|
+
const agentsPath = `${root}/AGENTS.md`;
|
|
54
|
+
await handle.fs.write(agentsPath, content);
|
|
55
|
+
for (const name of SYMLINK_NAMES) {
|
|
56
|
+
const lnCmd = `ln -s '${sqEscape("AGENTS.md")}' '${sqEscape(name)}'`;
|
|
57
|
+
if ((await handle.process.exec(lnCmd, { cwd: root })).exitCode !== 0) await handle.fs.write(`${root}/${name}`, content);
|
|
58
|
+
}
|
|
37
59
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
writeAgentsFile
|
|
43
|
-
};
|
|
44
|
-
//# sourceMappingURL=agents-file.js.map
|
|
60
|
+
//#endregion
|
|
61
|
+
export { formatWorkspaceScriptsSection, mergeAgentsContent, resolveGitSkillDir, writeAgentsFile };
|
|
62
|
+
|
|
63
|
+
//# sourceMappingURL=agents-file.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agents-file.js","sources":["../../src/agents-file.ts"],"sourcesContent":["/**\n * Universal AGENTS.md writer with per-CLI symlink projection, plus the\n * canonical helper for locating cloned gitSkill repositories inside a sandbox.\n *\n * The known-names set below lists the canonical instruction-file names for\n * each AI coding assistant CLI. Keep the list in one place so it is easy to\n * extend. The copy fallback ensures correctness on platforms without symlink\n * support (e.g. Windows).\n *\n * External per-CLI convention: each assistant looks for its own instruction\n * file by name (CLAUDE.md for Claude Code, GEMINI.md for Gemini CLI, …).\n * We write a single authoritative AGENTS.md and point each name at it.\n */\nimport type { SandboxHandle } from './contracts'\nimport type { WorkspaceSkill } from './workspace'\n\n/** CLI instruction-file names that should resolve to AGENTS.md. */\nconst SYMLINK_NAMES: ReadonlyArray<string> = ['CLAUDE.md', 'GEMINI.md']\n\n/**\n * Resolve the directory a `gitSkill` repo is cloned into when no explicit\n * `into` override is provided. The convention is:\n *\n * `<root>/.tanstack-skills/<basename>`\n *\n * where `basename` is derived from the `repo` field by taking the last\n * path segment and stripping a trailing `.git` suffix.\n *\n * Per-harness projectors (e.g. the Claude Code adapter) import this helper\n * so they can locate cloned skill repos consistently.\n *\n * @param root - Workspace root inside the sandbox (e.g. `/workspace`).\n * @param skill - A `WorkspaceSkill` of `kind === 'git'`.\n */\nexport function resolveGitSkillDir(\n root: string,\n skill: Extract<WorkspaceSkill, { kind: 'git' }>,\n): string {\n const rawBasename = skill.repo.split('/').pop() ?? skill.repo\n const basename = rawBasename.endsWith('.git')\n ? rawBasename.slice(0, -4)\n : rawBasename\n return `${root}/.tanstack-skills/${basename}`\n}\n\n/** Format workspace scripts as a `## Workspace scripts` markdown section. */\nexport function formatWorkspaceScriptsSection(\n scripts: Record<string, string>,\n): string {\n const names = Object.keys(scripts).sort()\n if (names.length === 0) return ''\n const lines = names.map((name) => `- ${name} → ${scripts[name]}`)\n return `## Workspace scripts\\n\\n${lines.join('\\n')}`\n}\n\n/**\n * Merge base AGENTS.md content with an optional workspace scripts section.\n * Returns `undefined` when there is nothing to write.\n */\nexport function mergeAgentsContent(\n base: string | undefined,\n scripts: Record<string, string> | undefined,\n): string | undefined {\n const scriptsSection =\n scripts !== undefined ? formatWorkspaceScriptsSection(scripts) : ''\n if (base === undefined && scriptsSection.length === 0) return undefined\n if (base === undefined) return scriptsSection\n if (scriptsSection.length === 0) return base\n return `${base.trimEnd()}\\n\\n${scriptsSection}`\n}\n\n/** Escape a string for safe use as a single-quoted shell argument. */\nfunction sqEscape(value: string): string {\n return value.replace(/'/g, `'\\\\''`)\n}\n\n/**\n * Write `AGENTS.md` under `root` and create per-CLI symlinks (or copies as a\n * fallback when `ln -s` is unavailable).\n *\n * @param handle - The sandbox handle providing `fs` and `process`.\n * @param root - Absolute path inside the sandbox under which to write.\n * @param content - Markdown content for the instruction file.\n */\nexport async function writeAgentsFile(\n handle: SandboxHandle,\n root: string,\n content: string,\n): Promise<void> {\n const agentsPath = `${root}/AGENTS.md`\n await handle.fs.write(agentsPath, content)\n\n for (const name of SYMLINK_NAMES) {\n const lnCmd = `ln -s '${sqEscape('AGENTS.md')}' '${sqEscape(name)}'`\n const result = await handle.process.exec(lnCmd, { cwd: root })\n if (result.exitCode !== 0) {\n // Symlinks are not supported on this platform — fall back to a copy.\n await handle.fs.write(`${root}/${name}`, content)\n }\n }\n}\n"],"
|
|
1
|
+
{"version":3,"file":"agents-file.js","names":[],"sources":["../../src/agents-file.ts"],"sourcesContent":["/**\n * Universal AGENTS.md writer with per-CLI symlink projection, plus the\n * canonical helper for locating cloned gitSkill repositories inside a sandbox.\n *\n * The known-names set below lists the canonical instruction-file names for\n * each AI coding assistant CLI. Keep the list in one place so it is easy to\n * extend. The copy fallback ensures correctness on platforms without symlink\n * support (e.g. Windows).\n *\n * External per-CLI convention: each assistant looks for its own instruction\n * file by name (CLAUDE.md for Claude Code, GEMINI.md for Gemini CLI, …).\n * We write a single authoritative AGENTS.md and point each name at it.\n */\nimport type { SandboxHandle } from './contracts'\nimport type { WorkspaceSkill } from './workspace'\n\n/** CLI instruction-file names that should resolve to AGENTS.md. */\nconst SYMLINK_NAMES: ReadonlyArray<string> = ['CLAUDE.md', 'GEMINI.md']\n\n/**\n * Resolve the directory a `gitSkill` repo is cloned into when no explicit\n * `into` override is provided. The convention is:\n *\n * `<root>/.tanstack-skills/<basename>`\n *\n * where `basename` is derived from the `repo` field by taking the last\n * path segment and stripping a trailing `.git` suffix.\n *\n * Per-harness projectors (e.g. the Claude Code adapter) import this helper\n * so they can locate cloned skill repos consistently.\n *\n * @param root - Workspace root inside the sandbox (e.g. `/workspace`).\n * @param skill - A `WorkspaceSkill` of `kind === 'git'`.\n */\nexport function resolveGitSkillDir(\n root: string,\n skill: Extract<WorkspaceSkill, { kind: 'git' }>,\n): string {\n const rawBasename = skill.repo.split('/').pop() ?? skill.repo\n const basename = rawBasename.endsWith('.git')\n ? rawBasename.slice(0, -4)\n : rawBasename\n return `${root}/.tanstack-skills/${basename}`\n}\n\n/** Format workspace scripts as a `## Workspace scripts` markdown section. */\nexport function formatWorkspaceScriptsSection(\n scripts: Record<string, string>,\n): string {\n const names = Object.keys(scripts).sort()\n if (names.length === 0) return ''\n const lines = names.map((name) => `- ${name} → ${scripts[name]}`)\n return `## Workspace scripts\\n\\n${lines.join('\\n')}`\n}\n\n/**\n * Merge base AGENTS.md content with an optional workspace scripts section.\n * Returns `undefined` when there is nothing to write.\n */\nexport function mergeAgentsContent(\n base: string | undefined,\n scripts: Record<string, string> | undefined,\n): string | undefined {\n const scriptsSection =\n scripts !== undefined ? formatWorkspaceScriptsSection(scripts) : ''\n if (base === undefined && scriptsSection.length === 0) return undefined\n if (base === undefined) return scriptsSection\n if (scriptsSection.length === 0) return base\n return `${base.trimEnd()}\\n\\n${scriptsSection}`\n}\n\n/** Escape a string for safe use as a single-quoted shell argument. */\nfunction sqEscape(value: string): string {\n return value.replace(/'/g, `'\\\\''`)\n}\n\n/**\n * Write `AGENTS.md` under `root` and create per-CLI symlinks (or copies as a\n * fallback when `ln -s` is unavailable).\n *\n * @param handle - The sandbox handle providing `fs` and `process`.\n * @param root - Absolute path inside the sandbox under which to write.\n * @param content - Markdown content for the instruction file.\n */\nexport async function writeAgentsFile(\n handle: SandboxHandle,\n root: string,\n content: string,\n): Promise<void> {\n const agentsPath = `${root}/AGENTS.md`\n await handle.fs.write(agentsPath, content)\n\n for (const name of SYMLINK_NAMES) {\n const lnCmd = `ln -s '${sqEscape('AGENTS.md')}' '${sqEscape(name)}'`\n const result = await handle.process.exec(lnCmd, { cwd: root })\n if (result.exitCode !== 0) {\n // Symlinks are not supported on this platform — fall back to a copy.\n await handle.fs.write(`${root}/${name}`, content)\n }\n }\n}\n"],"mappings":";;AAiBA,IAAM,gBAAuC,CAAC,aAAa,WAAW;;;;;;;;;;;;;;;;AAiBtE,SAAgB,mBACd,MACA,OACQ;CACR,MAAM,cAAc,MAAM,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,MAAM;CAIzD,OAAO,GAAG,KAAK,oBAHE,YAAY,SAAS,MAAM,IACxC,YAAY,MAAM,GAAG,EAAE,IACvB;AAEN;;AAGA,SAAgB,8BACd,SACQ;CACR,MAAM,QAAQ,OAAO,KAAK,OAAO,CAAC,CAAC,KAAK;CACxC,IAAI,MAAM,WAAW,GAAG,OAAO;CAE/B,OAAO,2BADO,MAAM,KAAK,SAAS,KAAK,KAAK,KAAK,QAAQ,OACvB,CAAA,CAAM,KAAK,IAAI;AACnD;;;;;AAMA,SAAgB,mBACd,MACA,SACoB;CACpB,MAAM,iBACJ,YAAY,KAAA,IAAY,8BAA8B,OAAO,IAAI;CACnE,IAAI,SAAS,KAAA,KAAa,eAAe,WAAW,GAAG,OAAO,KAAA;CAC9D,IAAI,SAAS,KAAA,GAAW,OAAO;CAC/B,IAAI,eAAe,WAAW,GAAG,OAAO;CACxC,OAAO,GAAG,KAAK,QAAQ,EAAE,MAAM;AACjC;;AAGA,SAAS,SAAS,OAAuB;CACvC,OAAO,MAAM,QAAQ,MAAM,OAAO;AACpC;;;;;;;;;AAUA,eAAsB,gBACpB,QACA,MACA,SACe;CACf,MAAM,aAAa,GAAG,KAAK;CAC3B,MAAM,OAAO,GAAG,MAAM,YAAY,OAAO;CAEzC,KAAK,MAAM,QAAQ,eAAe;EAChC,MAAM,QAAQ,UAAU,SAAS,WAAW,EAAE,KAAK,SAAS,IAAI,EAAE;EAElE,KAAI,MADiB,OAAO,QAAQ,KAAK,OAAO,EAAE,KAAK,KAAK,CAAC,EAAA,CAClD,aAAa,GAEtB,MAAM,OAAO,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,OAAO;CAEpD;AACF"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { InternalLogger } from '@tanstack/ai/adapter-internals';
|
|
2
|
+
import { StreamChunk, StreamDurability } from '@tanstack/ai';
|
|
3
|
+
/**
|
|
4
|
+
* Default bound on consecutive stored chunks alignment will skip as out-of-band.
|
|
5
|
+
*
|
|
6
|
+
* A bound is what keeps this a tolerance rather than a search. Unbounded, a
|
|
7
|
+
* genuine determinism regression would make alignment scan forward through the
|
|
8
|
+
* whole log looking for a fingerprint that happens to match, suppress
|
|
9
|
+
* everything it passed, and deliver a stream whose prefix and suffix disagree —
|
|
10
|
+
* the exact failure {@link JournalReplayDivergedError} exists to prevent. 64 is
|
|
11
|
+
* well above any realistic burst of bridged console events between two
|
|
12
|
+
* translated chunks and well below a log length where a false match becomes
|
|
13
|
+
* plausible.
|
|
14
|
+
*/
|
|
15
|
+
export declare const DEFAULT_MAX_OUT_OF_BAND_SKIP = 64;
|
|
16
|
+
/**
|
|
17
|
+
* The out-of-band predicate for the harness adapters.
|
|
18
|
+
*
|
|
19
|
+
* `ai-codex` and `ai-claude-code` splice `createBridgeEventChannel`'s stream
|
|
20
|
+
* into their translated output with `mergeChunkStreams`. That channel is the
|
|
21
|
+
* only producer on the path and it emits exclusively `EventType.CUSTOM` chunks
|
|
22
|
+
* (`bridge-events.ts:53-63`), fired by LIVE bridged-tool execution. A replay
|
|
23
|
+
* runs no tools, so those chunks exist in the log and not in the replay.
|
|
24
|
+
*
|
|
25
|
+
* Structural rather than a list of event names on purpose: a new bridged tool
|
|
26
|
+
* inventing a new `name` must not silently reintroduce the divergence.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isBridgeCustomChunk(chunk: StreamChunk): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The replay produced a different chunk than the log already holds at that
|
|
31
|
+
* index. Means translation stopped being deterministic — a `genId` that is not
|
|
32
|
+
* run-scoped, a translator that consults the clock, or a journal that was
|
|
33
|
+
* rewritten. Fail loud: suppressing the mismatch would deliver a stream whose
|
|
34
|
+
* prefix and suffix disagree about message identity.
|
|
35
|
+
*/
|
|
36
|
+
export declare class JournalReplayDivergedError extends Error {
|
|
37
|
+
readonly index: number;
|
|
38
|
+
readonly stored: string;
|
|
39
|
+
readonly replayed: string;
|
|
40
|
+
constructor(index: number, stored: string, replayed: string);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The replay reproduced the stored chunk EXACTLY except for its `threadId`.
|
|
44
|
+
*
|
|
45
|
+
* A distinct diagnosis because the cause and the fix are entirely different from
|
|
46
|
+
* a real divergence. The adapters resolve `threadId` as
|
|
47
|
+
* `options.threadId ?? this.generateId()`, and that id lands in every emitted
|
|
48
|
+
* chunk — so an attach route that drives a run without passing the run record's
|
|
49
|
+
* `threadId` mints a fresh one, and the very first chunk (`RUN_STARTED`) fails
|
|
50
|
+
* alignment. The agent behaved identically; only the id moved. Reported as a
|
|
51
|
+
* generic divergence, that sends the reader hunting for non-determinism in the
|
|
52
|
+
* translator, which is the wrong place entirely.
|
|
53
|
+
*
|
|
54
|
+
* A SUBCLASS of {@link JournalReplayDivergedError}, deliberately: this is still a
|
|
55
|
+
* divergence and still fatal, so a consumer already branching on the general
|
|
56
|
+
* class keeps working. The two are not collapsed — a genuine content divergence
|
|
57
|
+
* throws the base class, so `instanceof JournalReplayThreadIdMismatchError`
|
|
58
|
+
* separates a config mistake from a determinism bug in exactly one check.
|
|
59
|
+
*/
|
|
60
|
+
export declare class JournalReplayThreadIdMismatchError extends JournalReplayDivergedError {
|
|
61
|
+
readonly storedThreadId: string | undefined;
|
|
62
|
+
readonly replayedThreadId: string | undefined;
|
|
63
|
+
constructor(index: number, stored: string, replayed: string, storedThreadId: string | undefined, replayedThreadId: string | undefined);
|
|
64
|
+
}
|
|
65
|
+
export interface AlignToStoredLogOptions<TOffset extends string = string> {
|
|
66
|
+
/**
|
|
67
|
+
* The run's event log. Read from the beginning; never written here.
|
|
68
|
+
*
|
|
69
|
+
* Generic in the offset type, defaulted to `string`, for the same reason
|
|
70
|
+
* {@link RunDeps} is: a branded-cursor backend's `StreamDurability<TOffset>`
|
|
71
|
+
* is not assignable to `StreamDurability<string>`.
|
|
72
|
+
*
|
|
73
|
+
* Narrowed to `snapshot` — the only member this transform touches, as the
|
|
74
|
+
* function docs below spell out — so the capability-bus view of a log
|
|
75
|
+
* (`SandboxDurabilityLog`, which omits the offset-invariant `read`) can be
|
|
76
|
+
* passed straight through by `alignedIfAttaching`. A full `StreamDurability`
|
|
77
|
+
* still satisfies it, so no existing caller changes.
|
|
78
|
+
*/
|
|
79
|
+
durability: Pick<StreamDurability<TOffset>, 'snapshot'>;
|
|
80
|
+
/** Optional sink for the alignment summary. */
|
|
81
|
+
logger?: InternalLogger;
|
|
82
|
+
/**
|
|
83
|
+
* Recognizes a stored chunk that the replay CANNOT reproduce, so alignment
|
|
84
|
+
* skips it instead of throwing.
|
|
85
|
+
*
|
|
86
|
+
* Absent by default, which keeps strict positional comparison: any stored
|
|
87
|
+
* chunk the replay does not produce is a determinism bug and fails loudly.
|
|
88
|
+
* Pass {@link isBridgeCustomChunk} on the harness attach path, where the
|
|
89
|
+
* previous host spliced live bridged-tool events into the log.
|
|
90
|
+
*
|
|
91
|
+
* The predicate is applied to the STORED chunk, never to the replayed one. A
|
|
92
|
+
* skipped entry is suppressed, not re-appended, so the client's view is
|
|
93
|
+
* unchanged: it already received that chunk under its own offset.
|
|
94
|
+
*/
|
|
95
|
+
isOutOfBand?: (chunk: StreamChunk) => boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Maximum CONSECUTIVE stored chunks that may be skipped as out-of-band before
|
|
98
|
+
* alignment gives up. Reset by every match. Defaults to
|
|
99
|
+
* {@link DEFAULT_MAX_OUT_OF_BAND_SKIP}. Ignored when `isOutOfBand` is absent.
|
|
100
|
+
*/
|
|
101
|
+
maxOutOfBandSkip?: number;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Suppress the chunks already present in the event log and yield the rest.
|
|
105
|
+
*
|
|
106
|
+
* The stored prefix is read exactly once, eagerly, before the first replay
|
|
107
|
+
* chunk is pulled. Both halves of that matter:
|
|
108
|
+
*
|
|
109
|
+
* - **Exactly once**, because a second read mid-stream would race the appends
|
|
110
|
+
* the caller is making downstream of this transform and could classify a
|
|
111
|
+
* chunk this very run just appended as an already-stored one, dropping it.
|
|
112
|
+
* - **Via `snapshot()`, never `read()`**. `read` *tails*: it returns only when
|
|
113
|
+
* the log is terminalized with `close()` or the caller aborts. A takeover's
|
|
114
|
+
* log is open by definition — the host that would have closed it is the host
|
|
115
|
+
* that died — so `for await (… of read('-1'))` would never finish, and on an
|
|
116
|
+
* empty log `memoryStream` rejects a from-start join outright once its
|
|
117
|
+
* first-chunk deadline elapses. `snapshot()` is the bounded read: it resolves
|
|
118
|
+
* with what is stored right now, including while the log is still open, and
|
|
119
|
+
* resolves to `[]` for a run with nothing stored.
|
|
120
|
+
*/
|
|
121
|
+
export declare function alignToStoredLog<TOffset extends string = string>(chunks: AsyncIterable<StreamChunk>, options: AlignToStoredLogOptions<TOffset>): AsyncIterable<StreamChunk>;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { chunkFingerprint, chunkFingerprintIgnoringThreadId, chunkThreadId } from "./chunk-identity.js";
|
|
2
|
+
import { EventType } from "@tanstack/ai";
|
|
3
|
+
//#region src/align.ts
|
|
4
|
+
/**
|
|
5
|
+
* Replay-from-zero with log alignment: the mechanism that makes a resumed
|
|
6
|
+
* journal read idempotent.
|
|
7
|
+
*
|
|
8
|
+
* A host translates journal bytes 0..1000 and appends the resulting chunks, then
|
|
9
|
+
* dies. A successor re-reads the journal **from byte 0** and re-translates it,
|
|
10
|
+
* producing the same chunks again. This transform reads what is already in the
|
|
11
|
+
* event log, verifies that the replay reproduces it, suppresses that prefix, and
|
|
12
|
+
* passes only the remainder downstream to be appended.
|
|
13
|
+
*
|
|
14
|
+
* Why this shape rather than the offset-upsert the design sketched:
|
|
15
|
+
*
|
|
16
|
+
* - `StreamDurability.append` does not accept caller-supplied offsets, and
|
|
17
|
+
* `UpsertableStreamDurability.upsert` is deliberately **not** used here:
|
|
18
|
+
* `memoryStream.upsert` rejects any offset it did not mint itself, and
|
|
19
|
+
* `durableStream` has no `upsert` at all (its offsets embed a
|
|
20
|
+
* backend-assigned cursor). The journal path therefore only ever *appends*,
|
|
21
|
+
* and this function's whole job is deciding where that append starts. Do not
|
|
22
|
+
* "simplify" it into an `upsert` — the recommended production adapter cannot
|
|
23
|
+
* accept one.
|
|
24
|
+
* - Even if it could, re-translation is only reproducible because
|
|
25
|
+
* `createRunScopedIdGen` makes it so. The dedupe boundary therefore has to be
|
|
26
|
+
* *derived from the log*, not tracked beside it — which also means there is no
|
|
27
|
+
* window in which a checkpoint and the log can disagree, because the log is
|
|
28
|
+
* the checkpoint.
|
|
29
|
+
* - The log stays append-only with strictly increasing offsets. That is what
|
|
30
|
+
* `durableStream`'s backend enforces and what the client's offset de-dup
|
|
31
|
+
* (`ai-client`'s `seen` set) relies on — the client is NOT tolerant of a
|
|
32
|
+
* duplicated text or tool-argument delta.
|
|
33
|
+
*
|
|
34
|
+
* Divergence is a bug, not a condition to recover from, so it throws.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* Default bound on consecutive stored chunks alignment will skip as out-of-band.
|
|
38
|
+
*
|
|
39
|
+
* A bound is what keeps this a tolerance rather than a search. Unbounded, a
|
|
40
|
+
* genuine determinism regression would make alignment scan forward through the
|
|
41
|
+
* whole log looking for a fingerprint that happens to match, suppress
|
|
42
|
+
* everything it passed, and deliver a stream whose prefix and suffix disagree —
|
|
43
|
+
* the exact failure {@link JournalReplayDivergedError} exists to prevent. 64 is
|
|
44
|
+
* well above any realistic burst of bridged console events between two
|
|
45
|
+
* translated chunks and well below a log length where a false match becomes
|
|
46
|
+
* plausible.
|
|
47
|
+
*/
|
|
48
|
+
var DEFAULT_MAX_OUT_OF_BAND_SKIP = 64;
|
|
49
|
+
/**
|
|
50
|
+
* The out-of-band predicate for the harness adapters.
|
|
51
|
+
*
|
|
52
|
+
* `ai-codex` and `ai-claude-code` splice `createBridgeEventChannel`'s stream
|
|
53
|
+
* into their translated output with `mergeChunkStreams`. That channel is the
|
|
54
|
+
* only producer on the path and it emits exclusively `EventType.CUSTOM` chunks
|
|
55
|
+
* (`bridge-events.ts:53-63`), fired by LIVE bridged-tool execution. A replay
|
|
56
|
+
* runs no tools, so those chunks exist in the log and not in the replay.
|
|
57
|
+
*
|
|
58
|
+
* Structural rather than a list of event names on purpose: a new bridged tool
|
|
59
|
+
* inventing a new `name` must not silently reintroduce the divergence.
|
|
60
|
+
*/
|
|
61
|
+
function isBridgeCustomChunk(chunk) {
|
|
62
|
+
return chunk.type === EventType.CUSTOM;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The replay produced a different chunk than the log already holds at that
|
|
66
|
+
* index. Means translation stopped being deterministic — a `genId` that is not
|
|
67
|
+
* run-scoped, a translator that consults the clock, or a journal that was
|
|
68
|
+
* rewritten. Fail loud: suppressing the mismatch would deliver a stream whose
|
|
69
|
+
* prefix and suffix disagree about message identity.
|
|
70
|
+
*/
|
|
71
|
+
var JournalReplayDivergedError = class extends Error {
|
|
72
|
+
index;
|
|
73
|
+
stored;
|
|
74
|
+
replayed;
|
|
75
|
+
constructor(index, stored, replayed) {
|
|
76
|
+
super(`journal replay diverged at index ${index}: stored ${stored} but replayed ${replayed}`);
|
|
77
|
+
this.index = index;
|
|
78
|
+
this.stored = stored;
|
|
79
|
+
this.replayed = replayed;
|
|
80
|
+
this.name = "JournalReplayDivergedError";
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* The replay reproduced the stored chunk EXACTLY except for its `threadId`.
|
|
85
|
+
*
|
|
86
|
+
* A distinct diagnosis because the cause and the fix are entirely different from
|
|
87
|
+
* a real divergence. The adapters resolve `threadId` as
|
|
88
|
+
* `options.threadId ?? this.generateId()`, and that id lands in every emitted
|
|
89
|
+
* chunk — so an attach route that drives a run without passing the run record's
|
|
90
|
+
* `threadId` mints a fresh one, and the very first chunk (`RUN_STARTED`) fails
|
|
91
|
+
* alignment. The agent behaved identically; only the id moved. Reported as a
|
|
92
|
+
* generic divergence, that sends the reader hunting for non-determinism in the
|
|
93
|
+
* translator, which is the wrong place entirely.
|
|
94
|
+
*
|
|
95
|
+
* A SUBCLASS of {@link JournalReplayDivergedError}, deliberately: this is still a
|
|
96
|
+
* divergence and still fatal, so a consumer already branching on the general
|
|
97
|
+
* class keeps working. The two are not collapsed — a genuine content divergence
|
|
98
|
+
* throws the base class, so `instanceof JournalReplayThreadIdMismatchError`
|
|
99
|
+
* separates a config mistake from a determinism bug in exactly one check.
|
|
100
|
+
*/
|
|
101
|
+
var JournalReplayThreadIdMismatchError = class extends JournalReplayDivergedError {
|
|
102
|
+
storedThreadId;
|
|
103
|
+
replayedThreadId;
|
|
104
|
+
constructor(index, stored, replayed, storedThreadId, replayedThreadId) {
|
|
105
|
+
super(index, stored, replayed);
|
|
106
|
+
this.storedThreadId = storedThreadId;
|
|
107
|
+
this.replayedThreadId = replayedThreadId;
|
|
108
|
+
this.name = "JournalReplayThreadIdMismatchError";
|
|
109
|
+
this.message = `journal replay diverged at index ${index} ONLY by threadId: stored ${JSON.stringify(storedThreadId)} but replayed ${JSON.stringify(replayedThreadId)}. Every other field of the chunk is identical, so the agent did NOT behave differently — the attaching run generated a new threadId instead of reusing the run record's. Pass the run record's threadId (RunRecord.threadId, which sandboxRunDriver hands to drive({ runId, threadId, signal })) into chat() on the attach route; without it the adapter falls back to generateId() and every chunk carries an id the stored log cannot match.`;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Classify a mismatch before throwing.
|
|
114
|
+
*
|
|
115
|
+
* The `threadId`-only case is recognized by comparing the two chunks a SECOND
|
|
116
|
+
* time with `threadId` excluded: equal there and unequal under the real
|
|
117
|
+
* fingerprint means `threadId` is the only field that moved. Cheap, because it
|
|
118
|
+
* runs only on the failure path, and precise, because it is derived from the same
|
|
119
|
+
* fingerprint function rather than a hand-written field diff.
|
|
120
|
+
*/
|
|
121
|
+
function divergenceError(index, storedChunk, replayedChunk, stored, replayed) {
|
|
122
|
+
const storedThreadId = chunkThreadId(storedChunk);
|
|
123
|
+
const replayedThreadId = chunkThreadId(replayedChunk);
|
|
124
|
+
if (storedThreadId !== replayedThreadId && chunkFingerprintIgnoringThreadId(storedChunk) === chunkFingerprintIgnoringThreadId(replayedChunk)) return new JournalReplayThreadIdMismatchError(index, stored, replayed, storedThreadId, replayedThreadId);
|
|
125
|
+
return new JournalReplayDivergedError(index, stored, replayed);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Suppress the chunks already present in the event log and yield the rest.
|
|
129
|
+
*
|
|
130
|
+
* The stored prefix is read exactly once, eagerly, before the first replay
|
|
131
|
+
* chunk is pulled. Both halves of that matter:
|
|
132
|
+
*
|
|
133
|
+
* - **Exactly once**, because a second read mid-stream would race the appends
|
|
134
|
+
* the caller is making downstream of this transform and could classify a
|
|
135
|
+
* chunk this very run just appended as an already-stored one, dropping it.
|
|
136
|
+
* - **Via `snapshot()`, never `read()`**. `read` *tails*: it returns only when
|
|
137
|
+
* the log is terminalized with `close()` or the caller aborts. A takeover's
|
|
138
|
+
* log is open by definition — the host that would have closed it is the host
|
|
139
|
+
* that died — so `for await (… of read('-1'))` would never finish, and on an
|
|
140
|
+
* empty log `memoryStream` rejects a from-start join outright once its
|
|
141
|
+
* first-chunk deadline elapses. `snapshot()` is the bounded read: it resolves
|
|
142
|
+
* with what is stored right now, including while the log is still open, and
|
|
143
|
+
* resolves to `[]` for a run with nothing stored.
|
|
144
|
+
*/
|
|
145
|
+
async function* alignToStoredLog(chunks, options) {
|
|
146
|
+
const entries = await options.durability.snapshot();
|
|
147
|
+
const stored = entries.map((entry) => chunkFingerprint(entry.chunk));
|
|
148
|
+
const isOutOfBand = options.isOutOfBand;
|
|
149
|
+
const maxSkip = options.maxOutOfBandSkip ?? 64;
|
|
150
|
+
let cursor = 0;
|
|
151
|
+
let suppressed = 0;
|
|
152
|
+
let skipped = 0;
|
|
153
|
+
let forwarded = 0;
|
|
154
|
+
for await (const chunk of chunks) {
|
|
155
|
+
if (cursor >= stored.length) {
|
|
156
|
+
forwarded += 1;
|
|
157
|
+
yield chunk;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const actual = chunkFingerprint(chunk);
|
|
161
|
+
let consecutiveSkips = 0;
|
|
162
|
+
for (;;) {
|
|
163
|
+
const entry = entries[cursor];
|
|
164
|
+
const expected = stored[cursor];
|
|
165
|
+
if (entry === void 0 || expected === void 0) {
|
|
166
|
+
forwarded += 1;
|
|
167
|
+
yield chunk;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
if (expected === actual) {
|
|
171
|
+
cursor += 1;
|
|
172
|
+
suppressed += 1;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
if (isOutOfBand === void 0 || !isOutOfBand(entry.chunk)) throw divergenceError(cursor, entry.chunk, chunk, expected, actual);
|
|
176
|
+
if (consecutiveSkips >= maxSkip) throw divergenceError(cursor, entry.chunk, chunk, expected, actual);
|
|
177
|
+
cursor += 1;
|
|
178
|
+
consecutiveSkips += 1;
|
|
179
|
+
skipped += 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
while (cursor < stored.length) {
|
|
183
|
+
const entry = entries[cursor];
|
|
184
|
+
if (entry === void 0 || isOutOfBand === void 0 || !isOutOfBand(entry.chunk)) throw new Error(`journal replay is shorter than the stored log: ${stored.length - cursor} stored chunk(s) from index ${cursor} were not reproduced`);
|
|
185
|
+
cursor += 1;
|
|
186
|
+
skipped += 1;
|
|
187
|
+
}
|
|
188
|
+
options.logger?.provider(`journal alignment: suppressed ${suppressed} stored chunk(s), skipped ${skipped} out-of-band, forwarded ${forwarded}`, {
|
|
189
|
+
suppressed,
|
|
190
|
+
skipped,
|
|
191
|
+
forwarded
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
export { DEFAULT_MAX_OUT_OF_BAND_SKIP, JournalReplayDivergedError, JournalReplayThreadIdMismatchError, alignToStoredLog, isBridgeCustomChunk };
|
|
196
|
+
|
|
197
|
+
//# sourceMappingURL=align.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"align.js","names":[],"sources":["../../src/align.ts"],"sourcesContent":["/**\n * Replay-from-zero with log alignment: the mechanism that makes a resumed\n * journal read idempotent.\n *\n * A host translates journal bytes 0..1000 and appends the resulting chunks, then\n * dies. A successor re-reads the journal **from byte 0** and re-translates it,\n * producing the same chunks again. This transform reads what is already in the\n * event log, verifies that the replay reproduces it, suppresses that prefix, and\n * passes only the remainder downstream to be appended.\n *\n * Why this shape rather than the offset-upsert the design sketched:\n *\n * - `StreamDurability.append` does not accept caller-supplied offsets, and\n * `UpsertableStreamDurability.upsert` is deliberately **not** used here:\n * `memoryStream.upsert` rejects any offset it did not mint itself, and\n * `durableStream` has no `upsert` at all (its offsets embed a\n * backend-assigned cursor). The journal path therefore only ever *appends*,\n * and this function's whole job is deciding where that append starts. Do not\n * \"simplify\" it into an `upsert` — the recommended production adapter cannot\n * accept one.\n * - Even if it could, re-translation is only reproducible because\n * `createRunScopedIdGen` makes it so. The dedupe boundary therefore has to be\n * *derived from the log*, not tracked beside it — which also means there is no\n * window in which a checkpoint and the log can disagree, because the log is\n * the checkpoint.\n * - The log stays append-only with strictly increasing offsets. That is what\n * `durableStream`'s backend enforces and what the client's offset de-dup\n * (`ai-client`'s `seen` set) relies on — the client is NOT tolerant of a\n * duplicated text or tool-argument delta.\n *\n * Divergence is a bug, not a condition to recover from, so it throws.\n */\nimport { EventType } from '@tanstack/ai'\nimport {\n chunkFingerprint,\n chunkFingerprintIgnoringThreadId,\n chunkThreadId,\n} from './chunk-identity'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\nimport type { StreamChunk, StreamDurability } from '@tanstack/ai'\n\n/**\n * Default bound on consecutive stored chunks alignment will skip as out-of-band.\n *\n * A bound is what keeps this a tolerance rather than a search. Unbounded, a\n * genuine determinism regression would make alignment scan forward through the\n * whole log looking for a fingerprint that happens to match, suppress\n * everything it passed, and deliver a stream whose prefix and suffix disagree —\n * the exact failure {@link JournalReplayDivergedError} exists to prevent. 64 is\n * well above any realistic burst of bridged console events between two\n * translated chunks and well below a log length where a false match becomes\n * plausible.\n */\nexport const DEFAULT_MAX_OUT_OF_BAND_SKIP = 64\n\n/**\n * The out-of-band predicate for the harness adapters.\n *\n * `ai-codex` and `ai-claude-code` splice `createBridgeEventChannel`'s stream\n * into their translated output with `mergeChunkStreams`. That channel is the\n * only producer on the path and it emits exclusively `EventType.CUSTOM` chunks\n * (`bridge-events.ts:53-63`), fired by LIVE bridged-tool execution. A replay\n * runs no tools, so those chunks exist in the log and not in the replay.\n *\n * Structural rather than a list of event names on purpose: a new bridged tool\n * inventing a new `name` must not silently reintroduce the divergence.\n */\nexport function isBridgeCustomChunk(chunk: StreamChunk): boolean {\n return chunk.type === EventType.CUSTOM\n}\n\n/**\n * The replay produced a different chunk than the log already holds at that\n * index. Means translation stopped being deterministic — a `genId` that is not\n * run-scoped, a translator that consults the clock, or a journal that was\n * rewritten. Fail loud: suppressing the mismatch would deliver a stream whose\n * prefix and suffix disagree about message identity.\n */\nexport class JournalReplayDivergedError extends Error {\n constructor(\n readonly index: number,\n readonly stored: string,\n readonly replayed: string,\n ) {\n super(\n `journal replay diverged at index ${index}: stored ${stored} but replayed ${replayed}`,\n )\n this.name = 'JournalReplayDivergedError'\n }\n}\n\n/**\n * The replay reproduced the stored chunk EXACTLY except for its `threadId`.\n *\n * A distinct diagnosis because the cause and the fix are entirely different from\n * a real divergence. The adapters resolve `threadId` as\n * `options.threadId ?? this.generateId()`, and that id lands in every emitted\n * chunk — so an attach route that drives a run without passing the run record's\n * `threadId` mints a fresh one, and the very first chunk (`RUN_STARTED`) fails\n * alignment. The agent behaved identically; only the id moved. Reported as a\n * generic divergence, that sends the reader hunting for non-determinism in the\n * translator, which is the wrong place entirely.\n *\n * A SUBCLASS of {@link JournalReplayDivergedError}, deliberately: this is still a\n * divergence and still fatal, so a consumer already branching on the general\n * class keeps working. The two are not collapsed — a genuine content divergence\n * throws the base class, so `instanceof JournalReplayThreadIdMismatchError`\n * separates a config mistake from a determinism bug in exactly one check.\n */\nexport class JournalReplayThreadIdMismatchError extends JournalReplayDivergedError {\n constructor(\n index: number,\n stored: string,\n replayed: string,\n readonly storedThreadId: string | undefined,\n readonly replayedThreadId: string | undefined,\n ) {\n super(index, stored, replayed)\n this.name = 'JournalReplayThreadIdMismatchError'\n this.message =\n `journal replay diverged at index ${index} ONLY by threadId: stored ${JSON.stringify(storedThreadId)} but replayed ${JSON.stringify(replayedThreadId)}. ` +\n `Every other field of the chunk is identical, so the agent did NOT behave differently — the attaching run generated a new threadId instead of reusing the run record's. ` +\n `Pass the run record's threadId (RunRecord.threadId, which sandboxRunDriver hands to drive({ runId, threadId, signal })) into chat() on the attach route; ` +\n `without it the adapter falls back to generateId() and every chunk carries an id the stored log cannot match.`\n }\n}\n\n/**\n * Classify a mismatch before throwing.\n *\n * The `threadId`-only case is recognized by comparing the two chunks a SECOND\n * time with `threadId` excluded: equal there and unequal under the real\n * fingerprint means `threadId` is the only field that moved. Cheap, because it\n * runs only on the failure path, and precise, because it is derived from the same\n * fingerprint function rather than a hand-written field diff.\n */\nfunction divergenceError(\n index: number,\n storedChunk: StreamChunk,\n replayedChunk: StreamChunk,\n stored: string,\n replayed: string,\n): JournalReplayDivergedError {\n const storedThreadId = chunkThreadId(storedChunk)\n const replayedThreadId = chunkThreadId(replayedChunk)\n if (\n storedThreadId !== replayedThreadId &&\n chunkFingerprintIgnoringThreadId(storedChunk) ===\n chunkFingerprintIgnoringThreadId(replayedChunk)\n ) {\n return new JournalReplayThreadIdMismatchError(\n index,\n stored,\n replayed,\n storedThreadId,\n replayedThreadId,\n )\n }\n return new JournalReplayDivergedError(index, stored, replayed)\n}\n\nexport interface AlignToStoredLogOptions<TOffset extends string = string> {\n /**\n * The run's event log. Read from the beginning; never written here.\n *\n * Generic in the offset type, defaulted to `string`, for the same reason\n * {@link RunDeps} is: a branded-cursor backend's `StreamDurability<TOffset>`\n * is not assignable to `StreamDurability<string>`.\n *\n * Narrowed to `snapshot` — the only member this transform touches, as the\n * function docs below spell out — so the capability-bus view of a log\n * (`SandboxDurabilityLog`, which omits the offset-invariant `read`) can be\n * passed straight through by `alignedIfAttaching`. A full `StreamDurability`\n * still satisfies it, so no existing caller changes.\n */\n durability: Pick<StreamDurability<TOffset>, 'snapshot'>\n /** Optional sink for the alignment summary. */\n logger?: InternalLogger\n /**\n * Recognizes a stored chunk that the replay CANNOT reproduce, so alignment\n * skips it instead of throwing.\n *\n * Absent by default, which keeps strict positional comparison: any stored\n * chunk the replay does not produce is a determinism bug and fails loudly.\n * Pass {@link isBridgeCustomChunk} on the harness attach path, where the\n * previous host spliced live bridged-tool events into the log.\n *\n * The predicate is applied to the STORED chunk, never to the replayed one. A\n * skipped entry is suppressed, not re-appended, so the client's view is\n * unchanged: it already received that chunk under its own offset.\n */\n isOutOfBand?: (chunk: StreamChunk) => boolean\n /**\n * Maximum CONSECUTIVE stored chunks that may be skipped as out-of-band before\n * alignment gives up. Reset by every match. Defaults to\n * {@link DEFAULT_MAX_OUT_OF_BAND_SKIP}. Ignored when `isOutOfBand` is absent.\n */\n maxOutOfBandSkip?: number\n}\n\n/**\n * Suppress the chunks already present in the event log and yield the rest.\n *\n * The stored prefix is read exactly once, eagerly, before the first replay\n * chunk is pulled. Both halves of that matter:\n *\n * - **Exactly once**, because a second read mid-stream would race the appends\n * the caller is making downstream of this transform and could classify a\n * chunk this very run just appended as an already-stored one, dropping it.\n * - **Via `snapshot()`, never `read()`**. `read` *tails*: it returns only when\n * the log is terminalized with `close()` or the caller aborts. A takeover's\n * log is open by definition — the host that would have closed it is the host\n * that died — so `for await (… of read('-1'))` would never finish, and on an\n * empty log `memoryStream` rejects a from-start join outright once its\n * first-chunk deadline elapses. `snapshot()` is the bounded read: it resolves\n * with what is stored right now, including while the log is still open, and\n * resolves to `[]` for a run with nothing stored.\n */\nexport async function* alignToStoredLog<TOffset extends string = string>(\n chunks: AsyncIterable<StreamChunk>,\n options: AlignToStoredLogOptions<TOffset>,\n): AsyncIterable<StreamChunk> {\n const entries = await options.durability.snapshot()\n const stored = entries.map((entry) => chunkFingerprint(entry.chunk))\n\n const isOutOfBand = options.isOutOfBand\n const maxSkip = options.maxOutOfBandSkip ?? DEFAULT_MAX_OUT_OF_BAND_SKIP\n\n let cursor = 0\n let suppressed = 0\n let skipped = 0\n let forwarded = 0\n\n for await (const chunk of chunks) {\n // Past the end of the stored log: everything from here is new.\n if (cursor >= stored.length) {\n forwarded += 1\n yield chunk\n continue\n }\n\n const actual = chunkFingerprint(chunk)\n let consecutiveSkips = 0\n for (;;) {\n // `entries` and `stored` are the same length by construction; both are\n // bound because the predicate needs the CHUNK while the comparison needs\n // its fingerprint.\n const entry = entries[cursor]\n const expected = stored[cursor]\n if (entry === undefined || expected === undefined) {\n forwarded += 1\n yield chunk\n break\n }\n if (expected === actual) {\n cursor += 1\n suppressed += 1\n break\n }\n // Mismatch. Only a stored chunk the replay provably cannot reproduce may\n // be skipped, and only `maxSkip` of them in a row.\n if (isOutOfBand === undefined || !isOutOfBand(entry.chunk)) {\n throw divergenceError(cursor, entry.chunk, chunk, expected, actual)\n }\n if (consecutiveSkips >= maxSkip) {\n throw divergenceError(cursor, entry.chunk, chunk, expected, actual)\n }\n cursor += 1\n consecutiveSkips += 1\n skipped += 1\n }\n }\n\n // Trailing stored entries. Out-of-band ones are expected (a bridged tool's\n // last event lands after the final translated chunk); anything else means the\n // journal no longer accounts for chunks the log already delivered, which\n // nothing downstream can repair.\n while (cursor < stored.length) {\n const entry = entries[cursor]\n if (\n entry === undefined ||\n isOutOfBand === undefined ||\n !isOutOfBand(entry.chunk)\n ) {\n throw new Error(\n `journal replay is shorter than the stored log: ${stored.length - cursor} stored chunk(s) from index ${cursor} were not reproduced`,\n )\n }\n cursor += 1\n skipped += 1\n }\n\n options.logger?.provider(\n `journal alignment: suppressed ${suppressed} stored chunk(s), skipped ${skipped} out-of-band, forwarded ${forwarded}`,\n { suppressed, skipped, forwarded },\n )\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqDA,IAAa,+BAA+B;;;;;;;;;;;;;AAc5C,SAAgB,oBAAoB,OAA6B;CAC/D,OAAO,MAAM,SAAS,UAAU;AAClC;;;;;;;;AASA,IAAa,6BAAb,cAAgD,MAAM;CAEzC;CACA;CACA;CAHX,YACE,OACA,QACA,UACA;EACA,MACE,oCAAoC,MAAM,WAAW,OAAO,gBAAgB,UAC9E;EANS,KAAA,QAAA;EACA,KAAA,SAAA;EACA,KAAA,WAAA;EAKT,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;;;;;AAoBA,IAAa,qCAAb,cAAwD,2BAA2B;CAKtE;CACA;CALX,YACE,OACA,QACA,UACA,gBACA,kBACA;EACA,MAAM,OAAO,QAAQ,QAAQ;EAHpB,KAAA,iBAAA;EACA,KAAA,mBAAA;EAGT,KAAK,OAAO;EACZ,KAAK,UACH,oCAAoC,MAAM,4BAA4B,KAAK,UAAU,cAAc,EAAE,gBAAgB,KAAK,UAAU,gBAAgB,EAAE;CAI1J;AACF;;;;;;;;;;AAWA,SAAS,gBACP,OACA,aACA,eACA,QACA,UAC4B;CAC5B,MAAM,iBAAiB,cAAc,WAAW;CAChD,MAAM,mBAAmB,cAAc,aAAa;CACpD,IACE,mBAAmB,oBACnB,iCAAiC,WAAW,MAC1C,iCAAiC,aAAa,GAEhD,OAAO,IAAI,mCACT,OACA,QACA,UACA,gBACA,gBACF;CAEF,OAAO,IAAI,2BAA2B,OAAO,QAAQ,QAAQ;AAC/D;;;;;;;;;;;;;;;;;;;AA2DA,gBAAuB,iBACrB,QACA,SAC4B;CAC5B,MAAM,UAAU,MAAM,QAAQ,WAAW,SAAS;CAClD,MAAM,SAAS,QAAQ,KAAK,UAAU,iBAAiB,MAAM,KAAK,CAAC;CAEnE,MAAM,cAAc,QAAQ;CAC5B,MAAM,UAAU,QAAQ,oBAAA;CAExB,IAAI,SAAS;CACb,IAAI,aAAa;CACjB,IAAI,UAAU;CACd,IAAI,YAAY;CAEhB,WAAW,MAAM,SAAS,QAAQ;EAEhC,IAAI,UAAU,OAAO,QAAQ;GAC3B,aAAa;GACb,MAAM;GACN;EACF;EAEA,MAAM,SAAS,iBAAiB,KAAK;EACrC,IAAI,mBAAmB;EACvB,SAAS;GAIP,MAAM,QAAQ,QAAQ;GACtB,MAAM,WAAW,OAAO;GACxB,IAAI,UAAU,KAAA,KAAa,aAAa,KAAA,GAAW;IACjD,aAAa;IACb,MAAM;IACN;GACF;GACA,IAAI,aAAa,QAAQ;IACvB,UAAU;IACV,cAAc;IACd;GACF;GAGA,IAAI,gBAAgB,KAAA,KAAa,CAAC,YAAY,MAAM,KAAK,GACvD,MAAM,gBAAgB,QAAQ,MAAM,OAAO,OAAO,UAAU,MAAM;GAEpE,IAAI,oBAAoB,SACtB,MAAM,gBAAgB,QAAQ,MAAM,OAAO,OAAO,UAAU,MAAM;GAEpE,UAAU;GACV,oBAAoB;GACpB,WAAW;EACb;CACF;CAMA,OAAO,SAAS,OAAO,QAAQ;EAC7B,MAAM,QAAQ,QAAQ;EACtB,IACE,UAAU,KAAA,KACV,gBAAgB,KAAA,KAChB,CAAC,YAAY,MAAM,KAAK,GAExB,MAAM,IAAI,MACR,kDAAkD,OAAO,SAAS,OAAO,8BAA8B,OAAO,qBAChH;EAEF,UAAU;EACV,WAAW;CACb;CAEA,QAAQ,QAAQ,SACd,iCAAiC,WAAW,4BAA4B,QAAQ,0BAA0B,aAC1G;EAAE;EAAY;EAAS;CAAU,CACnC;AACF"}
|
package/dist/esm/approvals.js
CHANGED
|
@@ -1,36 +1,70 @@
|
|
|
1
|
-
import { EventType } from "@tanstack/ai";
|
|
2
1
|
import { evaluateCommand } from "./policy.js";
|
|
3
|
-
|
|
2
|
+
import { EventType } from "@tanstack/ai";
|
|
3
|
+
//#region src/approvals.ts
|
|
4
|
+
/**
|
|
5
|
+
* Shared interactive-approval logic for harness adapters.
|
|
6
|
+
*
|
|
7
|
+
* Flow (rides chat()'s existing resume-based approval mechanism):
|
|
8
|
+
* 1. The agent (inside the sandbox) asks to run a risky action; the harness's
|
|
9
|
+
* host-side permission callback fires.
|
|
10
|
+
* 2. `resolveApproval` evaluates the sandbox policy: `allow`/`deny` are final;
|
|
11
|
+
* `ask` consults the client's approval decisions (threaded via
|
|
12
|
+
* `TextOptions.approvals`, keyed by a stable `approvalId`).
|
|
13
|
+
* 3. On `ask` with no decision yet, the adapter emits an `approval-requested`
|
|
14
|
+
* CUSTOM event (carrying the `approvalId`) and denies the action this turn.
|
|
15
|
+
* The client shows UI, then re-runs chat() with the decision in the message;
|
|
16
|
+
* the engine surfaces it as `approvals`, and the next run allows it.
|
|
17
|
+
*
|
|
18
|
+
* `approvalId` is stable for a given (provider, kind, target) so a client grant
|
|
19
|
+
* matches the same action on the resumed run.
|
|
20
|
+
*/
|
|
21
|
+
/** CUSTOM event name emitted when a harness action needs client approval. */
|
|
22
|
+
var APPROVAL_REQUESTED_EVENT = "approval-requested";
|
|
23
|
+
/** A stable, opaque approval id for a harness action. */
|
|
4
24
|
function approvalId(input) {
|
|
5
|
-
|
|
25
|
+
return `${input.provider}:${input.kind}:${input.target}`;
|
|
6
26
|
}
|
|
27
|
+
/** Resolve a harness permission request against policy + client approvals. */
|
|
7
28
|
function resolveApproval(input) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
29
|
+
const base = input.command !== void 0 ? evaluateCommand(input.command, input.policy, input.scripts) : input.capability !== void 0 ? input.policy?.capabilities?.[input.capability] ?? input.policy?.default ?? "ask" : input.policy?.default ?? "ask";
|
|
30
|
+
if (base === "allow") return {
|
|
31
|
+
decision: "allow",
|
|
32
|
+
needsApproval: false
|
|
33
|
+
};
|
|
34
|
+
if (base === "deny") return {
|
|
35
|
+
decision: "deny",
|
|
36
|
+
needsApproval: false
|
|
37
|
+
};
|
|
38
|
+
const granted = input.approvals?.get(input.id);
|
|
39
|
+
if (granted === true) return {
|
|
40
|
+
decision: "allow",
|
|
41
|
+
needsApproval: false
|
|
42
|
+
};
|
|
43
|
+
if (granted === false) return {
|
|
44
|
+
decision: "deny",
|
|
45
|
+
needsApproval: false
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
decision: "deny",
|
|
49
|
+
needsApproval: true
|
|
50
|
+
};
|
|
15
51
|
}
|
|
52
|
+
/** Build the AG-UI `approval-requested` CUSTOM event for a harness action. */
|
|
16
53
|
function buildApprovalRequestedEvent(input) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
54
|
+
return {
|
|
55
|
+
type: EventType.CUSTOM,
|
|
56
|
+
name: APPROVAL_REQUESTED_EVENT,
|
|
57
|
+
value: {
|
|
58
|
+
approvalId: input.approvalId,
|
|
59
|
+
title: input.title,
|
|
60
|
+
...input.detail ?? {}
|
|
61
|
+
},
|
|
62
|
+
timestamp: Date.now(),
|
|
63
|
+
threadId: input.threadId,
|
|
64
|
+
runId: input.runId
|
|
65
|
+
};
|
|
29
66
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
resolveApproval
|
|
35
|
-
};
|
|
36
|
-
//# sourceMappingURL=approvals.js.map
|
|
67
|
+
//#endregion
|
|
68
|
+
export { APPROVAL_REQUESTED_EVENT, approvalId, buildApprovalRequestedEvent, resolveApproval };
|
|
69
|
+
|
|
70
|
+
//# sourceMappingURL=approvals.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"approvals.js","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 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 threadId: input.threadId,\n runId: input.runId,\n }\n}\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 } 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 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 threadId: input.threadId,\n runId: input.runId,\n }\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;EACL,MAAM,UAAU;EAChB,MAAM;EACN,OAAO;GACL,YAAY,MAAM;GAClB,OAAO,MAAM;GACb,GAAI,MAAM,UAAU,CAAC;EACvB;EACA,WAAW,KAAK,IAAI;EACpB,UAAU,MAAM;EAChB,OAAO,MAAM;CACf;AACF"}
|