@tanstack/ai-sandbox 0.2.1 → 0.2.2
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/file-diff.d.ts +16 -0
- package/dist/esm/file-diff.js +67 -0
- package/dist/esm/file-diff.js.map +1 -0
- package/dist/esm/middleware.js +37 -4
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/sandbox.d.ts +15 -9
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/watch.js +8 -6
- package/dist/esm/watch.js.map +1 -1
- package/package.json +3 -3
- package/src/file-diff.ts +94 -0
- package/src/middleware.ts +48 -5
- package/src/sandbox.ts +11 -9
- package/src/watch.ts +21 -8
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai';
|
|
2
|
+
import { SandboxHandle } from './contracts.js';
|
|
3
|
+
/**
|
|
4
|
+
* Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to
|
|
5
|
+
* the live handle. `baseSha` is the session baseline (`''` when the workspace
|
|
6
|
+
* isn't a git repo). Never throws.
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildFileHookEvent(handle: SandboxHandle, root: string, baseSha: string, event: SandboxFileEvent): SandboxFileHookEvent;
|
|
9
|
+
export interface ResolvedFileEvents {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
diff: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */
|
|
14
|
+
export declare function resolveFileEvents(opt: boolean | {
|
|
15
|
+
diff?: boolean;
|
|
16
|
+
} | undefined): ResolvedFileEvents;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
function relTo(root, path) {
|
|
2
|
+
const prefix = root.endsWith("/") ? root : `${root}/`;
|
|
3
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
4
|
+
}
|
|
5
|
+
function q(value) {
|
|
6
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7
|
+
}
|
|
8
|
+
function synthesizeAddPatch(path, content) {
|
|
9
|
+
const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
|
|
10
|
+
const body = lines.map((l) => `+${l}`).join("\n");
|
|
11
|
+
return `--- /dev/null
|
|
12
|
+
+++ ${path}
|
|
13
|
+
@@ -0,0 +1,${lines.length} @@
|
|
14
|
+
${body}${body ? "\n" : ""}`;
|
|
15
|
+
}
|
|
16
|
+
function buildFileHookEvent(handle, root, baseSha, event) {
|
|
17
|
+
const after = async () => {
|
|
18
|
+
if (event.type === "delete") return "";
|
|
19
|
+
try {
|
|
20
|
+
return await handle.fs.read(event.path);
|
|
21
|
+
} catch {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const before = async () => {
|
|
26
|
+
if (baseSha === "") return "";
|
|
27
|
+
const rel = relTo(root, event.path);
|
|
28
|
+
try {
|
|
29
|
+
const res = await handle.process.exec(
|
|
30
|
+
`git show ${q(baseSha)}:${q(rel)}`,
|
|
31
|
+
{ cwd: root }
|
|
32
|
+
);
|
|
33
|
+
return res.exitCode === 0 ? res.stdout : "";
|
|
34
|
+
} catch {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const diff = async () => {
|
|
39
|
+
if (baseSha === "") {
|
|
40
|
+
if (event.type === "delete") return "";
|
|
41
|
+
return synthesizeAddPatch(event.path, await after());
|
|
42
|
+
}
|
|
43
|
+
const rel = relTo(root, event.path);
|
|
44
|
+
try {
|
|
45
|
+
const res = await handle.process.exec(
|
|
46
|
+
`git diff ${q(baseSha)} -- ${q(rel)}`,
|
|
47
|
+
{
|
|
48
|
+
cwd: root
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
return res.exitCode === 0 ? res.stdout : "";
|
|
52
|
+
} catch {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
return { ...event, before, after, diff };
|
|
57
|
+
}
|
|
58
|
+
function resolveFileEvents(opt) {
|
|
59
|
+
if (opt === false) return { enabled: false, diff: false };
|
|
60
|
+
if (opt === void 0 || opt === true) return { enabled: true, diff: false };
|
|
61
|
+
return { enabled: true, diff: opt.diff === true };
|
|
62
|
+
}
|
|
63
|
+
export {
|
|
64
|
+
buildFileHookEvent,
|
|
65
|
+
resolveFileEvents
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=file-diff.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-diff.js","sources":["../../src/file-diff.ts"],"sourcesContent":["import type { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai'\nimport type { SandboxHandle } from './contracts'\n\n/** Path relative to the repo/workspace root, POSIX form. */\nfunction relTo(root: string, path: string): string {\n const prefix = root.endsWith('/') ? root : `${root}/`\n return path.startsWith(prefix) ? path.slice(prefix.length) : path\n}\n\n/**\n * POSIX single-quote escape for embedding a value in a shell command.\n */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/** Minimal unified add-patch for a brand-new file (non-git workspaces). */\nfunction synthesizeAddPatch(path: string, content: string): string {\n const lines = content === '' ? [] : content.replace(/\\n$/, '').split('\\n')\n const body = lines.map((l) => `+${l}`).join('\\n')\n return `--- /dev/null\\n+++ ${path}\\n@@ -0,0 +1,${lines.length} @@\\n${body}${body ? '\\n' : ''}`\n}\n\n/**\n * Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to\n * the live handle. `baseSha` is the session baseline (`''` when the workspace\n * isn't a git repo). Never throws.\n */\nexport function buildFileHookEvent(\n handle: SandboxHandle,\n root: string,\n baseSha: string,\n event: SandboxFileEvent,\n): SandboxFileHookEvent {\n const after = async (): Promise<string> => {\n if (event.type === 'delete') return ''\n try {\n return await handle.fs.read(event.path)\n } catch {\n return ''\n }\n }\n const before = async (): Promise<string> => {\n if (baseSha === '') return ''\n const rel = relTo(root, event.path)\n try {\n const res = await handle.process.exec(\n `git show ${q(baseSha)}:${q(rel)}`,\n { cwd: root },\n )\n return res.exitCode === 0 ? res.stdout : ''\n } catch {\n return ''\n }\n }\n const diff = async (): Promise<string> => {\n if (baseSha === '') {\n if (event.type === 'delete') return ''\n return synthesizeAddPatch(event.path, await after())\n }\n // Pathspec must be relative to `root` (like `before()` above) — a bare\n // leading `/` (e.g. the virtual `/workspace/x.ts`) is resolved by git\n // against the filesystem root, not the repo root, and fails with\n // \"fatal: Invalid path\" whenever the real repo root differs from\n // `/workspace` (e.g. every local-process sandbox).\n const rel = relTo(root, event.path)\n try {\n const res = await handle.process.exec(\n `git diff ${q(baseSha)} -- ${q(rel)}`,\n {\n cwd: root,\n },\n )\n return res.exitCode === 0 ? res.stdout : ''\n } catch {\n return ''\n }\n }\n return { ...event, before, after, diff }\n}\n\nexport interface ResolvedFileEvents {\n enabled: boolean\n diff: boolean\n}\n\n/** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */\nexport function resolveFileEvents(\n opt: boolean | { diff?: boolean } | undefined,\n): ResolvedFileEvents {\n if (opt === false) return { enabled: false, diff: false }\n if (opt === undefined || opt === true) return { enabled: true, diff: false }\n return { enabled: true, diff: opt.diff === true }\n}\n"],"names":[],"mappings":"AAIA,SAAS,MAAM,MAAc,MAAsB;AACjD,QAAM,SAAS,KAAK,SAAS,GAAG,IAAI,OAAO,GAAG,IAAI;AAClD,SAAO,KAAK,WAAW,MAAM,IAAI,KAAK,MAAM,OAAO,MAAM,IAAI;AAC/D;AAKA,SAAS,EAAE,OAAuB;AAChC,SAAO,IAAI,MAAM,QAAQ,MAAM,OAAO,CAAC;AACzC;AAGA,SAAS,mBAAmB,MAAc,SAAyB;AACjE,QAAM,QAAQ,YAAY,KAAK,KAAK,QAAQ,QAAQ,OAAO,EAAE,EAAE,MAAM,IAAI;AACzE,QAAM,OAAO,MAAM,IAAI,CAAC,MAAM,IAAI,CAAC,EAAE,EAAE,KAAK,IAAI;AAChD,SAAO;AAAA,MAAsB,IAAI;AAAA,aAAgB,MAAM,MAAM;AAAA,EAAQ,IAAI,GAAG,OAAO,OAAO,EAAE;AAC9F;AAOO,SAAS,mBACd,QACA,MACA,SACA,OACsB;AACtB,QAAM,QAAQ,YAA6B;AACzC,QAAI,MAAM,SAAS,SAAU,QAAO;AACpC,QAAI;AACF,aAAO,MAAM,OAAO,GAAG,KAAK,MAAM,IAAI;AAAA,IACxC,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,QAAM,SAAS,YAA6B;AAC1C,QAAI,YAAY,GAAI,QAAO;AAC3B,UAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAClC,QAAI;AACF,YAAM,MAAM,MAAM,OAAO,QAAQ;AAAA,QAC/B,YAAY,EAAE,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC;AAAA,QAChC,EAAE,KAAK,KAAA;AAAA,MAAK;AAEd,aAAO,IAAI,aAAa,IAAI,IAAI,SAAS;AAAA,IAC3C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,QAAM,OAAO,YAA6B;AACxC,QAAI,YAAY,IAAI;AAClB,UAAI,MAAM,SAAS,SAAU,QAAO;AACpC,aAAO,mBAAmB,MAAM,MAAM,MAAM,OAAO;AAAA,IACrD;AAMA,UAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAClC,QAAI;AACF,YAAM,MAAM,MAAM,OAAO,QAAQ;AAAA,QAC/B,YAAY,EAAE,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC;AAAA,QACnC;AAAA,UACE,KAAK;AAAA,QAAA;AAAA,MACP;AAEF,aAAO,IAAI,aAAa,IAAI,IAAI,SAAS;AAAA,IAC3C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO,EAAE,GAAG,OAAO,QAAQ,OAAO,KAAA;AACpC;AAQO,SAAS,kBACd,KACoB;AACpB,MAAI,QAAQ,MAAO,QAAO,EAAE,SAAS,OAAO,MAAM,MAAA;AAClD,MAAI,QAAQ,UAAa,QAAQ,aAAa,EAAE,SAAS,MAAM,MAAM,MAAA;AACrE,SAAO,EAAE,SAAS,MAAM,MAAM,IAAI,SAAS,KAAA;AAC7C;"}
|
package/dist/esm/middleware.js
CHANGED
|
@@ -2,6 +2,7 @@ import { defineChatMiddleware } from "@tanstack/ai";
|
|
|
2
2
|
import { getSandboxRuntime } from "@tanstack/ai/adapter-internals";
|
|
3
3
|
import { SandboxStoreCapability, LocksCapability, SandboxCapability, provideSandbox, provideSandboxPolicy } from "./capabilities.js";
|
|
4
4
|
import { computeWorkspaceHash } from "./key.js";
|
|
5
|
+
import { resolveFileEvents, buildFileHookEvent } from "./file-diff.js";
|
|
5
6
|
import { ProjectionCapability, provideWorkspaceProjection } from "./projection.js";
|
|
6
7
|
import { resolveSecret } from "./secrets.js";
|
|
7
8
|
import { watchWorkspace } from "./watch.js";
|
|
@@ -53,6 +54,15 @@ function withSandbox(definition) {
|
|
|
53
54
|
const handle = await definition.ensure(ensureCtx);
|
|
54
55
|
provideSandbox(ctx, handle);
|
|
55
56
|
if (definition.policy) provideSandboxPolicy(ctx, definition.policy);
|
|
57
|
+
const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT;
|
|
58
|
+
let baseSha = "";
|
|
59
|
+
try {
|
|
60
|
+
const shaRes = await handle.process.exec("git rev-parse HEAD", {
|
|
61
|
+
cwd: watchRoot
|
|
62
|
+
});
|
|
63
|
+
if (shaRes.exitCode === 0) baseSha = shaRes.stdout.trim();
|
|
64
|
+
} catch {
|
|
65
|
+
}
|
|
56
66
|
const workspace = definition.workspace;
|
|
57
67
|
if (workspace !== void 0) {
|
|
58
68
|
const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT;
|
|
@@ -76,24 +86,45 @@ function withSandbox(definition) {
|
|
|
76
86
|
}
|
|
77
87
|
const hooks = definition.hooks;
|
|
78
88
|
await hooks?.onReady?.(handle);
|
|
89
|
+
const fe = resolveFileEvents(definition.fileEvents);
|
|
90
|
+
const pendingDiffs = [];
|
|
79
91
|
let watcher;
|
|
80
|
-
if (
|
|
92
|
+
if (fe.enabled) {
|
|
81
93
|
const runtime = getSandboxRuntime(ctx, { optional: true });
|
|
82
94
|
watcher = await watchWorkspace(handle, {
|
|
83
95
|
onEvent: (event) => {
|
|
84
|
-
|
|
85
|
-
|
|
96
|
+
const enriched = buildFileHookEvent(
|
|
97
|
+
handle,
|
|
98
|
+
watchRoot,
|
|
99
|
+
baseSha,
|
|
100
|
+
event
|
|
101
|
+
);
|
|
102
|
+
void dispatchDefinitionHooks(hooks, enriched);
|
|
103
|
+
runtime?.emit(enriched);
|
|
104
|
+
if (fe.diff) {
|
|
105
|
+
pendingDiffs.push(
|
|
106
|
+
enriched.diff().then((diff) => {
|
|
107
|
+
runtime?.emitFileDiff({ path: event.path, diff });
|
|
108
|
+
}).catch(() => void 0)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
86
111
|
},
|
|
87
112
|
...ctx.signal !== void 0 ? { signal: ctx.signal } : {}
|
|
88
113
|
});
|
|
89
114
|
}
|
|
90
|
-
runState.set(ctx, {
|
|
115
|
+
runState.set(ctx, {
|
|
116
|
+
handle,
|
|
117
|
+
ensureCtx,
|
|
118
|
+
pendingDiffs,
|
|
119
|
+
...watcher ? { watcher } : {}
|
|
120
|
+
});
|
|
91
121
|
},
|
|
92
122
|
async onFinish(ctx) {
|
|
93
123
|
const state = runState.get(ctx);
|
|
94
124
|
if (!state) return;
|
|
95
125
|
const { handle, ensureCtx } = state;
|
|
96
126
|
await state.watcher?.stop();
|
|
127
|
+
await Promise.allSettled(state.pendingDiffs);
|
|
97
128
|
const lifecycle = definition.lifecycle;
|
|
98
129
|
if (lifecycle?.snapshot === "after-run" && handle.capabilities.snapshots && handle.snapshot) {
|
|
99
130
|
const snapshot = await handle.snapshot(`after-run-${ctx.runId}`);
|
|
@@ -119,6 +150,7 @@ function withSandbox(definition) {
|
|
|
119
150
|
const state = runState.get(ctx);
|
|
120
151
|
if (!state) return;
|
|
121
152
|
await state.watcher?.stop();
|
|
153
|
+
await Promise.allSettled(state.pendingDiffs);
|
|
122
154
|
await definition.destroy(state.ensureCtx);
|
|
123
155
|
await definition.hooks?.onDestroy?.();
|
|
124
156
|
},
|
|
@@ -126,6 +158,7 @@ function withSandbox(definition) {
|
|
|
126
158
|
const state = runState.get(ctx);
|
|
127
159
|
if (!state) return;
|
|
128
160
|
await state.watcher?.stop();
|
|
161
|
+
await Promise.allSettled(state.pendingDiffs);
|
|
129
162
|
await definition.hooks?.onError?.(info.error);
|
|
130
163
|
if (definition.lifecycle?.destroyOnComplete) {
|
|
131
164
|
await definition.destroy(state.ensureCtx);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.js","sources":["../../src/middleware.ts"],"sourcesContent":["/**\n * `withSandbox(definition)` — the middleware that PROVIDES the\n * {@link SandboxCapability} a harness adapter requires.\n *\n * - `setup`: resume-or-create the sandbox (via the definition's ensure\n * algorithm), provide the handle, using the optional SandboxStore/Locks\n * capabilities when a persistence middleware supplied them (in-memory\n * fallback otherwise). If `fileEvents` is not false, starts a watcher\n * that dispatches to sandbox-scoped hooks and forwards to the runtime sink.\n * - `onFinish`/`onAbort`/`onError`: stop the watcher, snapshot (`after-run`)\n * and/or destroy per lifecycle.\n *\n * NOTE: streamed sandbox lifecycle events (sandbox.created, workspace.setup.*)\n * are emitted by the harness adapter's chatStream (which can yield CUSTOM\n * chunks), not from here — middleware setup runs before streaming begins.\n */\nimport { defineChatMiddleware } from '@tanstack/ai'\nimport { getSandboxRuntime } from '@tanstack/ai/adapter-internals'\nimport {\n LocksCapability,\n SandboxCapability,\n SandboxStoreCapability,\n provideSandbox,\n provideSandboxPolicy,\n} from './capabilities'\nimport { computeWorkspaceHash } from './key'\nimport { ProjectionCapability, provideWorkspaceProjection } from './projection'\nimport { resolveSecret } from './secrets'\nimport { watchWorkspace } from './watch'\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type {\n AbortInfo,\n ChatMiddlewareContext,\n DefinedChatMiddleware,\n SandboxFileEvent,\n} from '@tanstack/ai'\nimport type { SandboxHandle } from './contracts'\nimport type {\n SandboxDefinition,\n SandboxEnsureContext,\n SandboxHooks,\n} from './sandbox'\nimport type { SandboxWatchHandle } from './watch'\n\n/** Per-request state we need to carry from `setup` to the terminal hooks. */\ninterface SandboxRunState {\n handle: SandboxHandle\n ensureCtx: SandboxEnsureContext\n watcher?: SandboxWatchHandle\n}\n\nconst runState = new WeakMap<object, SandboxRunState>()\n\n/** Defensively pull tenant scoping out of the runtime context, if present. */\nfunction tenantFrom(\n context: unknown,\n): { userId?: string; orgId?: string } | undefined {\n if (context === null || typeof context !== 'object') return undefined\n const c = context as Record<string, unknown>\n const userId = typeof c.userId === 'string' ? c.userId : undefined\n const orgId = typeof c.orgId === 'string' ? c.orgId : undefined\n if (userId === undefined && orgId === undefined) return undefined\n return { userId, orgId }\n}\n\nfunction buildEnsureCtx(ctx: ChatMiddlewareContext): SandboxEnsureContext {\n return {\n threadId: ctx.threadId,\n runId: ctx.runId,\n store: ctx.getOptional(SandboxStoreCapability),\n locks: ctx.getOptional(LocksCapability),\n tenant: tenantFrom(ctx.context),\n signal: ctx.signal,\n }\n}\n\n/**\n * Dispatch a sandbox file event to the per-type hooks declared on the\n * definition. Errors in individual hooks are swallowed so one bad hook\n * cannot break the run.\n */\nasync function dispatchDefinitionHooks(\n hooks: SandboxHooks | undefined,\n event: SandboxFileEvent,\n): Promise<void> {\n if (!hooks) return\n const typed = (\n {\n create: 'onFileCreate',\n change: 'onFileChange',\n delete: 'onFileDelete',\n } as const\n )[event.type]\n for (const fn of [hooks.onFile, hooks[typed]]) {\n if (!fn) continue\n try {\n await fn(event)\n } catch {\n // swallowed — one bad hook must not break the run\n }\n }\n}\n\nexport function withSandbox(\n definition: SandboxDefinition,\n): DefinedChatMiddleware<\n unknown,\n readonly [],\n readonly [typeof SandboxCapability, typeof ProjectionCapability]\n> {\n return defineChatMiddleware({\n name: 'sandbox',\n provides: [SandboxCapability, ProjectionCapability],\n // SandboxPolicyCapability is provided conditionally (only when the\n // definition has a policy), so it is intentionally NOT declared here —\n // consumers read it via `getOptional`.\n optionalRequires: [SandboxStoreCapability, LocksCapability],\n\n async setup(ctx) {\n const ensureCtx = buildEnsureCtx(ctx)\n const handle = await definition.ensure(ensureCtx)\n provideSandbox(ctx, handle)\n if (definition.policy) provideSandboxPolicy(ctx, definition.policy)\n\n const workspace = definition.workspace\n if (workspace !== undefined) {\n const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT\n const workspaceHash = computeWorkspaceHash(workspace)\n const secrets = workspace.secrets\n provideWorkspaceProjection(ctx, {\n skills: workspace.skills ?? [],\n plugins: workspace.plugins ?? [],\n resolveSecret: (ref) => {\n if (secrets === undefined) {\n throw new Error(\n `resolveSecret: no secrets defined on this workspace (ref: \"${ref.__secretName}\")`,\n )\n }\n return resolveSecret(secrets, ref)\n },\n markerPath: `${root}/.tanstack-projected-${workspaceHash}`,\n root,\n ...(workspace.scripts !== undefined\n ? { scripts: workspace.scripts }\n : {}),\n })\n }\n\n const hooks = definition.hooks\n await hooks?.onReady?.(handle)\n\n let watcher: SandboxWatchHandle | undefined\n if (definition.fileEvents !== false) {\n const runtime = getSandboxRuntime(ctx, { optional: true })\n watcher = await watchWorkspace(handle, {\n onEvent: (event: SandboxFileEvent) => {\n void dispatchDefinitionHooks(hooks, event)\n runtime?.emit(event)\n },\n ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),\n })\n }\n\n runState.set(ctx, { handle, ensureCtx, ...(watcher ? { watcher } : {}) })\n },\n\n async onFinish(ctx) {\n const state = runState.get(ctx)\n if (!state) return\n const { handle, ensureCtx } = state\n\n await state.watcher?.stop()\n\n const lifecycle = definition.lifecycle\n\n if (\n lifecycle?.snapshot === 'after-run' &&\n handle.capabilities.snapshots &&\n handle.snapshot\n ) {\n const snapshot = await handle.snapshot(`after-run-${ctx.runId}`)\n const store = ensureCtx.store\n if (store) {\n const key = definition.key(ensureCtx)\n const existing = await store.get(key)\n if (existing) {\n await store.upsert({\n ...existing,\n latestSnapshotId: snapshot.id,\n updatedAt: Date.now(),\n })\n }\n }\n }\n\n if (lifecycle?.destroyOnComplete) {\n await definition.destroy(ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n\n async onAbort(ctx, _info: AbortInfo) {\n const state = runState.get(ctx)\n if (!state) return\n\n await state.watcher?.stop()\n\n // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.\n // The in-sandbox agent process is not killed by closing its IO stream\n // (e.g. a Docker exec survives client disconnect), so the only reliable way\n // to stop it — and the token/cost drain of its ongoing API calls — is to\n // destroy the sandbox (stop the container/VM). `keepAlive` /\n // `destroyOnComplete:false` governs *successful completion*, never cancel.\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n },\n\n async onError(ctx, info) {\n const state = runState.get(ctx)\n if (!state) return\n\n await state.watcher?.stop()\n await definition.hooks?.onError?.(info.error)\n\n // On failure, only tear down when the lifecycle says so; otherwise leave\n // the sandbox for a resumed retry.\n if (definition.lifecycle?.destroyOnComplete) {\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n })\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"middleware.js","sources":["../../src/middleware.ts"],"sourcesContent":["/**\n * `withSandbox(definition)` — the middleware that PROVIDES the\n * {@link SandboxCapability} a harness adapter requires.\n *\n * - `setup`: resume-or-create the sandbox (via the definition's ensure\n * algorithm), provide the handle, using the optional SandboxStore/Locks\n * capabilities when a persistence middleware supplied them (in-memory\n * fallback otherwise). If `fileEvents` is not false, starts a watcher\n * that dispatches to sandbox-scoped hooks and forwards to the runtime sink.\n * - `onFinish`/`onAbort`/`onError`: stop the watcher, snapshot (`after-run`)\n * and/or destroy per lifecycle.\n *\n * NOTE: streamed sandbox lifecycle events (sandbox.created, workspace.setup.*)\n * are emitted by the harness adapter's chatStream (which can yield CUSTOM\n * chunks), not from here — middleware setup runs before streaming begins.\n */\nimport { defineChatMiddleware } from '@tanstack/ai'\nimport { getSandboxRuntime } from '@tanstack/ai/adapter-internals'\nimport {\n LocksCapability,\n SandboxCapability,\n SandboxStoreCapability,\n provideSandbox,\n provideSandboxPolicy,\n} from './capabilities'\nimport { computeWorkspaceHash } from './key'\nimport { buildFileHookEvent, resolveFileEvents } from './file-diff'\nimport { ProjectionCapability, provideWorkspaceProjection } from './projection'\nimport { resolveSecret } from './secrets'\nimport { watchWorkspace } from './watch'\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type {\n AbortInfo,\n ChatMiddlewareContext,\n DefinedChatMiddleware,\n SandboxFileEvent,\n SandboxFileHookEvent,\n} from '@tanstack/ai'\nimport type { SandboxHandle } from './contracts'\nimport type {\n SandboxDefinition,\n SandboxEnsureContext,\n SandboxHooks,\n} from './sandbox'\nimport type { SandboxWatchHandle } from './watch'\n\n/** Per-request state we need to carry from `setup` to the terminal hooks. */\ninterface SandboxRunState {\n handle: SandboxHandle\n ensureCtx: SandboxEnsureContext\n watcher?: SandboxWatchHandle\n /** In-flight `enriched.diff()` promises queued by the `fileEvents.diff`\n * watcher callback, awaited before teardown so a pending diff isn't\n * dropped when the run finishes/aborts/errors mid-computation. */\n pendingDiffs: Array<Promise<void>>\n}\n\nconst runState = new WeakMap<object, SandboxRunState>()\n\n/** Defensively pull tenant scoping out of the runtime context, if present. */\nfunction tenantFrom(\n context: unknown,\n): { userId?: string; orgId?: string } | undefined {\n if (context === null || typeof context !== 'object') return undefined\n const c = context as Record<string, unknown>\n const userId = typeof c.userId === 'string' ? c.userId : undefined\n const orgId = typeof c.orgId === 'string' ? c.orgId : undefined\n if (userId === undefined && orgId === undefined) return undefined\n return { userId, orgId }\n}\n\nfunction buildEnsureCtx(ctx: ChatMiddlewareContext): SandboxEnsureContext {\n return {\n threadId: ctx.threadId,\n runId: ctx.runId,\n store: ctx.getOptional(SandboxStoreCapability),\n locks: ctx.getOptional(LocksCapability),\n tenant: tenantFrom(ctx.context),\n signal: ctx.signal,\n }\n}\n\n/**\n * Dispatch a sandbox file event to the per-type hooks declared on the\n * definition. Errors in individual hooks are swallowed so one bad hook\n * cannot break the run.\n */\nasync function dispatchDefinitionHooks(\n hooks: SandboxHooks | undefined,\n event: SandboxFileHookEvent,\n): Promise<void> {\n if (!hooks) return\n const typed = (\n {\n create: 'onFileCreate',\n change: 'onFileChange',\n delete: 'onFileDelete',\n } as const\n )[event.type]\n for (const fn of [hooks.onFile, hooks[typed]]) {\n if (!fn) continue\n try {\n await fn(event)\n } catch {\n // swallowed — one bad hook must not break the run\n }\n }\n}\n\nexport function withSandbox(\n definition: SandboxDefinition,\n): DefinedChatMiddleware<\n unknown,\n readonly [],\n readonly [typeof SandboxCapability, typeof ProjectionCapability]\n> {\n return defineChatMiddleware({\n name: 'sandbox',\n provides: [SandboxCapability, ProjectionCapability],\n // SandboxPolicyCapability is provided conditionally (only when the\n // definition has a policy), so it is intentionally NOT declared here —\n // consumers read it via `getOptional`.\n optionalRequires: [SandboxStoreCapability, LocksCapability],\n\n async setup(ctx) {\n const ensureCtx = buildEnsureCtx(ctx)\n const handle = await definition.ensure(ensureCtx)\n provideSandbox(ctx, handle)\n if (definition.policy) provideSandboxPolicy(ctx, definition.policy)\n\n const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT\n let baseSha = ''\n try {\n const shaRes = await handle.process.exec('git rev-parse HEAD', {\n cwd: watchRoot,\n })\n if (shaRes.exitCode === 0) baseSha = shaRes.stdout.trim()\n } catch {\n // non-git workspace / exec rejects → baseSha stays '' (accessors fall back)\n }\n\n const workspace = definition.workspace\n if (workspace !== undefined) {\n const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT\n const workspaceHash = computeWorkspaceHash(workspace)\n const secrets = workspace.secrets\n provideWorkspaceProjection(ctx, {\n skills: workspace.skills ?? [],\n plugins: workspace.plugins ?? [],\n resolveSecret: (ref) => {\n if (secrets === undefined) {\n throw new Error(\n `resolveSecret: no secrets defined on this workspace (ref: \"${ref.__secretName}\")`,\n )\n }\n return resolveSecret(secrets, ref)\n },\n markerPath: `${root}/.tanstack-projected-${workspaceHash}`,\n root,\n ...(workspace.scripts !== undefined\n ? { scripts: workspace.scripts }\n : {}),\n })\n }\n\n const hooks = definition.hooks\n await hooks?.onReady?.(handle)\n\n const fe = resolveFileEvents(definition.fileEvents)\n const pendingDiffs: Array<Promise<void>> = []\n let watcher: SandboxWatchHandle | undefined\n if (fe.enabled) {\n const runtime = getSandboxRuntime(ctx, { optional: true })\n watcher = await watchWorkspace(handle, {\n onEvent: (event: SandboxFileEvent) => {\n const enriched = buildFileHookEvent(\n handle,\n watchRoot,\n baseSha,\n event,\n )\n void dispatchDefinitionHooks(hooks, enriched)\n runtime?.emit(enriched)\n if (fe.diff) {\n pendingDiffs.push(\n enriched\n .diff()\n .then((diff) => {\n runtime?.emitFileDiff({ path: event.path, diff })\n })\n .catch(() => undefined),\n )\n }\n },\n ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),\n })\n }\n\n runState.set(ctx, {\n handle,\n ensureCtx,\n pendingDiffs,\n ...(watcher ? { watcher } : {}),\n })\n },\n\n async onFinish(ctx) {\n const state = runState.get(ctx)\n if (!state) return\n const { handle, ensureCtx } = state\n\n await state.watcher?.stop()\n await Promise.allSettled(state.pendingDiffs)\n\n const lifecycle = definition.lifecycle\n\n if (\n lifecycle?.snapshot === 'after-run' &&\n handle.capabilities.snapshots &&\n handle.snapshot\n ) {\n const snapshot = await handle.snapshot(`after-run-${ctx.runId}`)\n const store = ensureCtx.store\n if (store) {\n const key = definition.key(ensureCtx)\n const existing = await store.get(key)\n if (existing) {\n await store.upsert({\n ...existing,\n latestSnapshotId: snapshot.id,\n updatedAt: Date.now(),\n })\n }\n }\n }\n\n if (lifecycle?.destroyOnComplete) {\n await definition.destroy(ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n\n async onAbort(ctx, _info: AbortInfo) {\n const state = runState.get(ctx)\n if (!state) return\n\n await state.watcher?.stop()\n await Promise.allSettled(state.pendingDiffs)\n\n // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.\n // The in-sandbox agent process is not killed by closing its IO stream\n // (e.g. a Docker exec survives client disconnect), so the only reliable way\n // to stop it — and the token/cost drain of its ongoing API calls — is to\n // destroy the sandbox (stop the container/VM). `keepAlive` /\n // `destroyOnComplete:false` governs *successful completion*, never cancel.\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n },\n\n async onError(ctx, info) {\n const state = runState.get(ctx)\n if (!state) return\n\n await state.watcher?.stop()\n await Promise.allSettled(state.pendingDiffs)\n await definition.hooks?.onError?.(info.error)\n\n // On failure, only tear down when the lifecycle says so; otherwise leave\n // the sandbox for a resumed retry.\n if (definition.lifecycle?.destroyOnComplete) {\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n })\n}\n"],"names":[],"mappings":";;;;;;;;;AAyDA,MAAM,+BAAe,QAAA;AAGrB,SAAS,WACP,SACiD;AACjD,MAAI,YAAY,QAAQ,OAAO,YAAY,SAAU,QAAO;AAC5D,QAAM,IAAI;AACV,QAAM,SAAS,OAAO,EAAE,WAAW,WAAW,EAAE,SAAS;AACzD,QAAM,QAAQ,OAAO,EAAE,UAAU,WAAW,EAAE,QAAQ;AACtD,MAAI,WAAW,UAAa,UAAU,OAAW,QAAO;AACxD,SAAO,EAAE,QAAQ,MAAA;AACnB;AAEA,SAAS,eAAe,KAAkD;AACxE,SAAO;AAAA,IACL,UAAU,IAAI;AAAA,IACd,OAAO,IAAI;AAAA,IACX,OAAO,IAAI,YAAY,sBAAsB;AAAA,IAC7C,OAAO,IAAI,YAAY,eAAe;AAAA,IACtC,QAAQ,WAAW,IAAI,OAAO;AAAA,IAC9B,QAAQ,IAAI;AAAA,EAAA;AAEhB;AAOA,eAAe,wBACb,OACA,OACe;AACf,MAAI,CAAC,MAAO;AACZ,QAAM,QACJ;AAAA,IACE,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,QAAQ;AAAA,EAAA,EAEV,MAAM,IAAI;AACZ,aAAW,MAAM,CAAC,MAAM,QAAQ,MAAM,KAAK,CAAC,GAAG;AAC7C,QAAI,CAAC,GAAI;AACT,QAAI;AACF,YAAM,GAAG,KAAK;AAAA,IAChB,QAAQ;AAAA,IAER;AAAA,EACF;AACF;AAEO,SAAS,YACd,YAKA;AACA,SAAO,qBAAqB;AAAA,IAC1B,MAAM;AAAA,IACN,UAAU,CAAC,mBAAmB,oBAAoB;AAAA;AAAA;AAAA;AAAA,IAIlD,kBAAkB,CAAC,wBAAwB,eAAe;AAAA,IAE1D,MAAM,MAAM,KAAK;AACf,YAAM,YAAY,eAAe,GAAG;AACpC,YAAM,SAAS,MAAM,WAAW,OAAO,SAAS;AAChD,qBAAe,KAAK,MAAM;AAC1B,UAAI,WAAW,OAAQ,sBAAqB,KAAK,WAAW,MAAM;AAElE,YAAM,YAAY,WAAW,WAAW,QAAQ;AAChD,UAAI,UAAU;AACd,UAAI;AACF,cAAM,SAAS,MAAM,OAAO,QAAQ,KAAK,sBAAsB;AAAA,UAC7D,KAAK;AAAA,QAAA,CACN;AACD,YAAI,OAAO,aAAa,EAAG,WAAU,OAAO,OAAO,KAAA;AAAA,MACrD,QAAQ;AAAA,MAER;AAEA,YAAM,YAAY,WAAW;AAC7B,UAAI,cAAc,QAAW;AAC3B,cAAM,OAAO,UAAU,QAAQ;AAC/B,cAAM,gBAAgB,qBAAqB,SAAS;AACpD,cAAM,UAAU,UAAU;AAC1B,mCAA2B,KAAK;AAAA,UAC9B,QAAQ,UAAU,UAAU,CAAA;AAAA,UAC5B,SAAS,UAAU,WAAW,CAAA;AAAA,UAC9B,eAAe,CAAC,QAAQ;AACtB,gBAAI,YAAY,QAAW;AACzB,oBAAM,IAAI;AAAA,gBACR,8DAA8D,IAAI,YAAY;AAAA,cAAA;AAAA,YAElF;AACA,mBAAO,cAAc,SAAS,GAAG;AAAA,UACnC;AAAA,UACA,YAAY,GAAG,IAAI,wBAAwB,aAAa;AAAA,UACxD;AAAA,UACA,GAAI,UAAU,YAAY,SACtB,EAAE,SAAS,UAAU,YACrB,CAAA;AAAA,QAAC,CACN;AAAA,MACH;AAEA,YAAM,QAAQ,WAAW;AACzB,YAAM,OAAO,UAAU,MAAM;AAE7B,YAAM,KAAK,kBAAkB,WAAW,UAAU;AAClD,YAAM,eAAqC,CAAA;AAC3C,UAAI;AACJ,UAAI,GAAG,SAAS;AACd,cAAM,UAAU,kBAAkB,KAAK,EAAE,UAAU,MAAM;AACzD,kBAAU,MAAM,eAAe,QAAQ;AAAA,UACrC,SAAS,CAAC,UAA4B;AACpC,kBAAM,WAAW;AAAA,cACf;AAAA,cACA;AAAA,cACA;AAAA,cACA;AAAA,YAAA;AAEF,iBAAK,wBAAwB,OAAO,QAAQ;AAC5C,qBAAS,KAAK,QAAQ;AACtB,gBAAI,GAAG,MAAM;AACX,2BAAa;AAAA,gBACX,SACG,KAAA,EACA,KAAK,CAAC,SAAS;AACd,2BAAS,aAAa,EAAE,MAAM,MAAM,MAAM,MAAM;AAAA,gBAClD,CAAC,EACA,MAAM,MAAM,MAAS;AAAA,cAAA;AAAA,YAE5B;AAAA,UACF;AAAA,UACA,GAAI,IAAI,WAAW,SAAY,EAAE,QAAQ,IAAI,WAAW,CAAA;AAAA,QAAC,CAC1D;AAAA,MACH;AAEA,eAAS,IAAI,KAAK;AAAA,QAChB;AAAA,QACA;AAAA,QACA;AAAA,QACA,GAAI,UAAU,EAAE,YAAY,CAAA;AAAA,MAAC,CAC9B;AAAA,IACH;AAAA,IAEA,MAAM,SAAS,KAAK;AAClB,YAAM,QAAQ,SAAS,IAAI,GAAG;AAC9B,UAAI,CAAC,MAAO;AACZ,YAAM,EAAE,QAAQ,UAAA,IAAc;AAE9B,YAAM,MAAM,SAAS,KAAA;AACrB,YAAM,QAAQ,WAAW,MAAM,YAAY;AAE3C,YAAM,YAAY,WAAW;AAE7B,UACE,WAAW,aAAa,eACxB,OAAO,aAAa,aACpB,OAAO,UACP;AACA,cAAM,WAAW,MAAM,OAAO,SAAS,aAAa,IAAI,KAAK,EAAE;AAC/D,cAAM,QAAQ,UAAU;AACxB,YAAI,OAAO;AACT,gBAAM,MAAM,WAAW,IAAI,SAAS;AACpC,gBAAM,WAAW,MAAM,MAAM,IAAI,GAAG;AACpC,cAAI,UAAU;AACZ,kBAAM,MAAM,OAAO;AAAA,cACjB,GAAG;AAAA,cACH,kBAAkB,SAAS;AAAA,cAC3B,WAAW,KAAK,IAAA;AAAA,YAAI,CACrB;AAAA,UACH;AAAA,QACF;AAAA,MACF;AAEA,UAAI,WAAW,mBAAmB;AAChC,cAAM,WAAW,QAAQ,SAAS;AAClC,cAAM,WAAW,OAAO,YAAA;AAAA,MAC1B;AAAA,IACF;AAAA,IAEA,MAAM,QAAQ,KAAK,OAAkB;AACnC,YAAM,QAAQ,SAAS,IAAI,GAAG;AAC9B,UAAI,CAAC,MAAO;AAEZ,YAAM,MAAM,SAAS,KAAA;AACrB,YAAM,QAAQ,WAAW,MAAM,YAAY;AAQ3C,YAAM,WAAW,QAAQ,MAAM,SAAS;AACxC,YAAM,WAAW,OAAO,YAAA;AAAA,IAC1B;AAAA,IAEA,MAAM,QAAQ,KAAK,MAAM;AACvB,YAAM,QAAQ,SAAS,IAAI,GAAG;AAC9B,UAAI,CAAC,MAAO;AAEZ,YAAM,MAAM,SAAS,KAAA;AACrB,YAAM,QAAQ,WAAW,MAAM,YAAY;AAC3C,YAAM,WAAW,OAAO,UAAU,KAAK,KAAK;AAI5C,UAAI,WAAW,WAAW,mBAAmB;AAC3C,cAAM,WAAW,QAAQ,MAAM,SAAS;AACxC,cAAM,WAAW,OAAO,YAAA;AAAA,MAC1B;AAAA,IACF;AAAA,EAAA,CACD;AACH;"}
|
package/dist/esm/sandbox.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SandboxFileHookEvent } from '@tanstack/ai';
|
|
2
2
|
import { SandboxHandle, SandboxProvider } from './contracts.js';
|
|
3
3
|
import { LockStore, SandboxStore } from './store.js';
|
|
4
4
|
import { SandboxPolicy } from './policy.js';
|
|
@@ -8,10 +8,10 @@ import { WorkspaceDefinition } from './workspace.js';
|
|
|
8
8
|
* create/change/delete during a chat run; lifecycle hooks fire server-side.
|
|
9
9
|
*/
|
|
10
10
|
export interface SandboxHooks {
|
|
11
|
-
onFile?: (e:
|
|
12
|
-
onFileCreate?: (e:
|
|
13
|
-
onFileChange?: (e:
|
|
14
|
-
onFileDelete?: (e:
|
|
11
|
+
onFile?: (e: SandboxFileHookEvent) => void | Promise<void>;
|
|
12
|
+
onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>;
|
|
13
|
+
onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>;
|
|
14
|
+
onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>;
|
|
15
15
|
onReady?: (handle: SandboxHandle) => void | Promise<void>;
|
|
16
16
|
onError?: (err: unknown) => void | Promise<void>;
|
|
17
17
|
onDestroy?: () => void | Promise<void>;
|
|
@@ -42,8 +42,11 @@ export interface SandboxConfig {
|
|
|
42
42
|
lifecycle?: SandboxLifecycle;
|
|
43
43
|
/** Sandbox-scoped file/lifecycle hooks. */
|
|
44
44
|
hooks?: SandboxHooks;
|
|
45
|
-
/** Watch the workspace for file events (default true).
|
|
46
|
-
|
|
45
|
+
/** Watch the workspace for file events (default true). `false` disables the
|
|
46
|
+
* watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */
|
|
47
|
+
fileEvents?: boolean | {
|
|
48
|
+
diff?: boolean;
|
|
49
|
+
};
|
|
47
50
|
}
|
|
48
51
|
/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */
|
|
49
52
|
export interface SandboxEnsureContext {
|
|
@@ -67,8 +70,11 @@ export interface SandboxDefinition {
|
|
|
67
70
|
readonly lifecycle?: SandboxLifecycle;
|
|
68
71
|
/** Sandbox-scoped file/lifecycle hooks. */
|
|
69
72
|
readonly hooks?: SandboxHooks;
|
|
70
|
-
/** Watch the workspace for file events (default true).
|
|
71
|
-
|
|
73
|
+
/** Watch the workspace for file events (default true). `false` disables the
|
|
74
|
+
* watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */
|
|
75
|
+
readonly fileEvents?: boolean | {
|
|
76
|
+
diff?: boolean;
|
|
77
|
+
};
|
|
72
78
|
/** Compound instance key for a given run context. */
|
|
73
79
|
key: (ctx: SandboxEnsureContext) => string;
|
|
74
80
|
/** Resume-or-create the sandbox for this thread/run. */
|
package/dist/esm/sandbox.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sandbox.js","sources":["../../src/sandbox.ts"],"sourcesContent":["/**\n * `defineSandbox()` returns a LAZY controller — it never creates a sandbox at\n * definition time. `withSandbox()` (and advanced users) call `ensure()` to\n * resume-or-create, following: provider.resume → provider.restoreSnapshot →\n * create + bootstrap. The controller folds provider/workspace/policy/lifecycle\n * into a stable instance key and coordinates through the (optional) lock +\n * sandbox stores.\n */\nimport { bootstrapWorkspace } from './bootstrap'\nimport { resolveAllSecrets } from './secrets'\nimport { computeSandboxKey } from './key'\nimport { InMemoryLockStore, InMemorySandboxStore } from './store'\nimport type { SandboxFileEvent } from '@tanstack/ai'\nimport type { SandboxHandle, SandboxProvider } from './contracts'\nimport type { SandboxKeyInput } from './key'\nimport type { LockStore, SandboxStore } from './store'\nimport type { SandboxPolicy } from './policy'\nimport type { WorkspaceDefinition } from './workspace'\n\n/**\n * Sandbox-scoped hooks declared on `defineSandbox`. File hooks fire for every\n * create/change/delete during a chat run; lifecycle hooks fire server-side.\n */\nexport interface SandboxHooks {\n onFile?: (e: SandboxFileEvent) => void | Promise<void>\n onFileCreate?: (e: SandboxFileEvent) => void | Promise<void>\n onFileChange?: (e: SandboxFileEvent) => void | Promise<void>\n onFileDelete?: (e: SandboxFileEvent) => void | Promise<void>\n onReady?: (handle: SandboxHandle) => void | Promise<void>\n onError?: (err: unknown) => void | Promise<void>\n onDestroy?: () => void | Promise<void>\n}\n\nexport type ReuseStrategy = 'thread' | 'none'\nexport type SnapshotStrategy = 'after-setup' | 'after-run' | 'none'\n\nexport interface SandboxLifecycle {\n /** `'thread'` resumes one sandbox per thread; `'none'` is fresh per run. */\n reuse?: ReuseStrategy\n /** When to snapshot (provider-permitting). */\n snapshot?: SnapshotStrategy\n /** Hint for how long a provider should keep the sandbox warm between runs. */\n keepAlive?: string\n /** Destroy the sandbox after the run completes. */\n destroyOnComplete?: boolean\n /**\n * Maximum age of a sandbox record before it is discarded and re-created\n * instead of resumed. Accepts `'<n>h'` (hours) or `'<n>m'` (minutes),\n * e.g. `'2h'` or `'30m'`.\n */\n snapshotMaxAge?: string\n}\n\nexport interface SandboxConfig {\n id: string\n provider: SandboxProvider\n workspace?: WorkspaceDefinition\n policy?: SandboxPolicy\n lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). Set false to disable. */\n fileEvents?: boolean\n}\n\n/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */\nexport interface SandboxEnsureContext {\n threadId: string\n runId: string\n /** Persistence seam; falls back to an in-memory store when absent. */\n store?: SandboxStore\n /** Lock seam; falls back to an in-memory lock when absent. */\n locks?: LockStore\n tenant?: { userId?: string; orgId?: string }\n signal?: AbortSignal\n}\n\nexport interface SandboxDefinition {\n readonly id: string\n readonly provider: SandboxProvider\n readonly workspace?: WorkspaceDefinition\n readonly policy?: SandboxPolicy\n readonly lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n readonly hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). Set false to disable. */\n readonly fileEvents?: boolean\n /** Compound instance key for a given run context. */\n key: (ctx: SandboxEnsureContext) => string\n /** Resume-or-create the sandbox for this thread/run. */\n ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>\n /** Tear down the sandbox recorded for this key. */\n destroy: (ctx: SandboxEnsureContext) => Promise<void>\n}\n\n/**\n * Parse a human-readable duration string into milliseconds.\n * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).\n * Returns `undefined` when the input is undefined or the format is unrecognised.\n */\nfunction parseMaxAgeMs(value: string | undefined): number | undefined {\n if (value === undefined) return undefined\n const hourMatch = /^(\\d+)h$/.exec(value)\n if (hourMatch) return Number(hourMatch[1]) * 60 * 60 * 1000\n const minuteMatch = /^(\\d+)m$/.exec(value)\n if (minuteMatch) return Number(minuteMatch[1]) * 60 * 1000\n return undefined\n}\n\n// Process-lifetime fallbacks shared across all definitions so concurrent\n// ensures for the same key serialize even without an injected store/lock.\nconst fallbackStore = new InMemorySandboxStore()\nconst fallbackLocks = new InMemoryLockStore()\n\nexport function defineSandbox(config: SandboxConfig): SandboxDefinition {\n const keyInputFor = (ctx: SandboxEnsureContext): SandboxKeyInput => ({\n threadId:\n config.lifecycle?.reuse === 'none'\n ? `${ctx.threadId}:${ctx.runId}`\n : ctx.threadId,\n sandboxId: config.id,\n providerName: config.provider.name,\n workspace: config.workspace,\n tenant: ctx.tenant,\n })\n\n const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> => {\n const store = ctx.store ?? fallbackStore\n const locks = ctx.locks ?? fallbackLocks\n const key = computeSandboxKey(keyInputFor(ctx))\n const caps = config.provider.capabilities()\n\n return locks.withLock(`sandbox:${key}`, async () => {\n const effectiveSnapshot: SnapshotStrategy =\n config.lifecycle?.snapshot ?? (caps.snapshots ? 'after-setup' : 'none')\n const maxAgeMs = parseMaxAgeMs(config.lifecycle?.snapshotMaxAge)\n\n const existing = await store.get(key)\n if (existing) {\n // Check whether the record has exceeded snapshotMaxAge; if so,\n // discard and fall through to a fresh create.\n const tooOld =\n maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs\n\n if (!tooOld) {\n // 1) Try to reconnect to the still-running sandbox.\n const resumed = await config.provider.resume({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n if (resumed) {\n await store.upsert({\n ...existing,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return resumed\n }\n // 2) Else restore from the latest snapshot, if supported.\n if (\n existing.latestSnapshotId &&\n caps.snapshots &&\n config.provider.restoreSnapshot\n ) {\n const restored = await config.provider.restoreSnapshot({\n snapshotId: existing.latestSnapshotId,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n await store.upsert({\n ...existing,\n providerSandboxId: restored.id,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return restored\n }\n }\n // 3) Else fall through and re-create under the same identity\n // (capability-aware degradation for ephemeral-disk providers, or\n // snapshotMaxAge TTL exceeded).\n }\n\n const created = await config.provider.create({\n // Deterministic id so consumers can reconstruct the provider sandbox\n // address from run context (not just from the store record).\n id: key,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n\n if (config.workspace) {\n try {\n await bootstrapWorkspace(created, config.workspace, {\n signal: ctx.signal,\n })\n } catch (error) {\n // Bootstrap failed after the sandbox was created but before it was\n // recorded — destroy the orphan so a failed/retried run doesn't leak\n // a (billed) sandbox, then surface the original error.\n await created.destroy().catch(() => {})\n throw error\n }\n }\n\n let latestSnapshotId: string | undefined\n if (\n effectiveSnapshot === 'after-setup' &&\n caps.snapshots &&\n created.snapshot\n ) {\n latestSnapshotId = (await created.snapshot('after-setup')).id\n }\n\n await store.upsert({\n key,\n provider: config.provider.name,\n providerSandboxId: created.id,\n latestSnapshotId,\n threadId: ctx.threadId,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return created\n })\n }\n\n const destroy = async (ctx: SandboxEnsureContext): Promise<void> => {\n const store = ctx.store ?? fallbackStore\n const key = computeSandboxKey(keyInputFor(ctx))\n const existing = await store.get(key)\n if (!existing) return\n await config.provider.destroy({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n await store.delete(key)\n }\n\n return {\n id: config.id,\n provider: config.provider,\n workspace: config.workspace,\n policy: config.policy,\n lifecycle: config.lifecycle,\n hooks: config.hooks,\n fileEvents: config.fileEvents,\n key: (ctx) => computeSandboxKey(keyInputFor(ctx)),\n ensure,\n destroy,\n }\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sandbox.js","sources":["../../src/sandbox.ts"],"sourcesContent":["/**\n * `defineSandbox()` returns a LAZY controller — it never creates a sandbox at\n * definition time. `withSandbox()` (and advanced users) call `ensure()` to\n * resume-or-create, following: provider.resume → provider.restoreSnapshot →\n * create + bootstrap. The controller folds provider/workspace/policy/lifecycle\n * into a stable instance key and coordinates through the (optional) lock +\n * sandbox stores.\n */\nimport { bootstrapWorkspace } from './bootstrap'\nimport { resolveAllSecrets } from './secrets'\nimport { computeSandboxKey } from './key'\nimport { InMemoryLockStore, InMemorySandboxStore } from './store'\nimport type { SandboxFileHookEvent } from '@tanstack/ai'\nimport type { SandboxHandle, SandboxProvider } from './contracts'\nimport type { SandboxKeyInput } from './key'\nimport type { LockStore, SandboxStore } from './store'\nimport type { SandboxPolicy } from './policy'\nimport type { WorkspaceDefinition } from './workspace'\n\n/**\n * Sandbox-scoped hooks declared on `defineSandbox`. File hooks fire for every\n * create/change/delete during a chat run; lifecycle hooks fire server-side.\n */\nexport interface SandboxHooks {\n onFile?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>\n onReady?: (handle: SandboxHandle) => void | Promise<void>\n onError?: (err: unknown) => void | Promise<void>\n onDestroy?: () => void | Promise<void>\n}\n\nexport type ReuseStrategy = 'thread' | 'none'\nexport type SnapshotStrategy = 'after-setup' | 'after-run' | 'none'\n\nexport interface SandboxLifecycle {\n /** `'thread'` resumes one sandbox per thread; `'none'` is fresh per run. */\n reuse?: ReuseStrategy\n /** When to snapshot (provider-permitting). */\n snapshot?: SnapshotStrategy\n /** Hint for how long a provider should keep the sandbox warm between runs. */\n keepAlive?: string\n /** Destroy the sandbox after the run completes. */\n destroyOnComplete?: boolean\n /**\n * Maximum age of a sandbox record before it is discarded and re-created\n * instead of resumed. Accepts `'<n>h'` (hours) or `'<n>m'` (minutes),\n * e.g. `'2h'` or `'30m'`.\n */\n snapshotMaxAge?: string\n}\n\nexport interface SandboxConfig {\n id: string\n provider: SandboxProvider\n workspace?: WorkspaceDefinition\n policy?: SandboxPolicy\n lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n fileEvents?: boolean | { diff?: boolean }\n}\n\n/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */\nexport interface SandboxEnsureContext {\n threadId: string\n runId: string\n /** Persistence seam; falls back to an in-memory store when absent. */\n store?: SandboxStore\n /** Lock seam; falls back to an in-memory lock when absent. */\n locks?: LockStore\n tenant?: { userId?: string; orgId?: string }\n signal?: AbortSignal\n}\n\nexport interface SandboxDefinition {\n readonly id: string\n readonly provider: SandboxProvider\n readonly workspace?: WorkspaceDefinition\n readonly policy?: SandboxPolicy\n readonly lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n readonly hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n readonly fileEvents?: boolean | { diff?: boolean }\n /** Compound instance key for a given run context. */\n key: (ctx: SandboxEnsureContext) => string\n /** Resume-or-create the sandbox for this thread/run. */\n ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>\n /** Tear down the sandbox recorded for this key. */\n destroy: (ctx: SandboxEnsureContext) => Promise<void>\n}\n\n/**\n * Parse a human-readable duration string into milliseconds.\n * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).\n * Returns `undefined` when the input is undefined or the format is unrecognised.\n */\nfunction parseMaxAgeMs(value: string | undefined): number | undefined {\n if (value === undefined) return undefined\n const hourMatch = /^(\\d+)h$/.exec(value)\n if (hourMatch) return Number(hourMatch[1]) * 60 * 60 * 1000\n const minuteMatch = /^(\\d+)m$/.exec(value)\n if (minuteMatch) return Number(minuteMatch[1]) * 60 * 1000\n return undefined\n}\n\n// Process-lifetime fallbacks shared across all definitions so concurrent\n// ensures for the same key serialize even without an injected store/lock.\nconst fallbackStore = new InMemorySandboxStore()\nconst fallbackLocks = new InMemoryLockStore()\n\nexport function defineSandbox(config: SandboxConfig): SandboxDefinition {\n const keyInputFor = (ctx: SandboxEnsureContext): SandboxKeyInput => ({\n threadId:\n config.lifecycle?.reuse === 'none'\n ? `${ctx.threadId}:${ctx.runId}`\n : ctx.threadId,\n sandboxId: config.id,\n providerName: config.provider.name,\n workspace: config.workspace,\n tenant: ctx.tenant,\n })\n\n const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> => {\n const store = ctx.store ?? fallbackStore\n const locks = ctx.locks ?? fallbackLocks\n const key = computeSandboxKey(keyInputFor(ctx))\n const caps = config.provider.capabilities()\n\n return locks.withLock(`sandbox:${key}`, async () => {\n const effectiveSnapshot: SnapshotStrategy =\n config.lifecycle?.snapshot ?? (caps.snapshots ? 'after-setup' : 'none')\n const maxAgeMs = parseMaxAgeMs(config.lifecycle?.snapshotMaxAge)\n\n const existing = await store.get(key)\n if (existing) {\n // Check whether the record has exceeded snapshotMaxAge; if so,\n // discard and fall through to a fresh create.\n const tooOld =\n maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs\n\n if (!tooOld) {\n // 1) Try to reconnect to the still-running sandbox.\n const resumed = await config.provider.resume({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n if (resumed) {\n await store.upsert({\n ...existing,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return resumed\n }\n // 2) Else restore from the latest snapshot, if supported.\n if (\n existing.latestSnapshotId &&\n caps.snapshots &&\n config.provider.restoreSnapshot\n ) {\n const restored = await config.provider.restoreSnapshot({\n snapshotId: existing.latestSnapshotId,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n await store.upsert({\n ...existing,\n providerSandboxId: restored.id,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return restored\n }\n }\n // 3) Else fall through and re-create under the same identity\n // (capability-aware degradation for ephemeral-disk providers, or\n // snapshotMaxAge TTL exceeded).\n }\n\n const created = await config.provider.create({\n // Deterministic id so consumers can reconstruct the provider sandbox\n // address from run context (not just from the store record).\n id: key,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n\n if (config.workspace) {\n try {\n await bootstrapWorkspace(created, config.workspace, {\n signal: ctx.signal,\n })\n } catch (error) {\n // Bootstrap failed after the sandbox was created but before it was\n // recorded — destroy the orphan so a failed/retried run doesn't leak\n // a (billed) sandbox, then surface the original error.\n await created.destroy().catch(() => {})\n throw error\n }\n }\n\n let latestSnapshotId: string | undefined\n if (\n effectiveSnapshot === 'after-setup' &&\n caps.snapshots &&\n created.snapshot\n ) {\n latestSnapshotId = (await created.snapshot('after-setup')).id\n }\n\n await store.upsert({\n key,\n provider: config.provider.name,\n providerSandboxId: created.id,\n latestSnapshotId,\n threadId: ctx.threadId,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return created\n })\n }\n\n const destroy = async (ctx: SandboxEnsureContext): Promise<void> => {\n const store = ctx.store ?? fallbackStore\n const key = computeSandboxKey(keyInputFor(ctx))\n const existing = await store.get(key)\n if (!existing) return\n await config.provider.destroy({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n await store.delete(key)\n }\n\n return {\n id: config.id,\n provider: config.provider,\n workspace: config.workspace,\n policy: config.policy,\n lifecycle: config.lifecycle,\n hooks: config.hooks,\n fileEvents: config.fileEvents,\n key: (ctx) => computeSandboxKey(keyInputFor(ctx)),\n ensure,\n destroy,\n }\n}\n"],"names":[],"mappings":"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"}
|
package/dist/esm/watch.js
CHANGED
|
@@ -16,11 +16,12 @@ function diffSnapshots(prev, next, timestamp) {
|
|
|
16
16
|
}
|
|
17
17
|
return events;
|
|
18
18
|
}
|
|
19
|
-
function buildFindCommand(
|
|
19
|
+
function buildFindCommand(ignore) {
|
|
20
20
|
const prunes = ignore.map((entry) => `-not -path ${q(`*/${entry}/*`)}`).join(" ");
|
|
21
|
-
return `find
|
|
21
|
+
return `find . -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'`;
|
|
22
22
|
}
|
|
23
|
-
function parseFindOutput(stdout) {
|
|
23
|
+
function parseFindOutput(stdout, root) {
|
|
24
|
+
const base = root.replace(/\/+$/, "");
|
|
24
25
|
const snapshot = /* @__PURE__ */ new Map();
|
|
25
26
|
for (const line of stdout.split("\n")) {
|
|
26
27
|
if (line === "") continue;
|
|
@@ -29,7 +30,8 @@ function parseFindOutput(stdout) {
|
|
|
29
30
|
if (firstTab === -1 || secondTab === -1) continue;
|
|
30
31
|
const mtime = line.slice(0, firstTab);
|
|
31
32
|
const size = line.slice(firstTab + 1, secondTab);
|
|
32
|
-
const
|
|
33
|
+
const rel = line.slice(secondTab + 1).replace(/^\.\/?/, "");
|
|
34
|
+
const path = rel === "" ? base : `${base}/${rel}`;
|
|
33
35
|
snapshot.set(path, `${mtime} ${size}`);
|
|
34
36
|
}
|
|
35
37
|
return snapshot;
|
|
@@ -81,14 +83,14 @@ async function startNativeWatch(handle, options) {
|
|
|
81
83
|
}
|
|
82
84
|
async function startPollWatch(handle, options) {
|
|
83
85
|
const { onEvent, root, ignore, intervalMs } = options;
|
|
84
|
-
const command = buildFindCommand(
|
|
86
|
+
const command = buildFindCommand(ignore);
|
|
85
87
|
const controller = new AbortController();
|
|
86
88
|
const snapshot = async () => {
|
|
87
89
|
const result = await handle.process.exec(command, {
|
|
88
90
|
cwd: root,
|
|
89
91
|
signal: controller.signal
|
|
90
92
|
});
|
|
91
|
-
return result.exitCode === 0 ? parseFindOutput(result.stdout) : /* @__PURE__ */ new Map();
|
|
93
|
+
return result.exitCode === 0 ? parseFindOutput(result.stdout, root) : /* @__PURE__ */ new Map();
|
|
92
94
|
};
|
|
93
95
|
let previous = await snapshot();
|
|
94
96
|
const state = { running: true };
|
package/dist/esm/watch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"watch.js","sources":["../../src/watch.ts"],"sourcesContent":["/**\n * Sandbox file-event hooks — observe create / change / delete of files inside a\n * sandbox (e.g. as an in-sandbox agent edits the workspace).\n *\n * Provider-agnostic: coded against the {@link SandboxHandle} contract only.\n * Two mechanisms, auto-selected:\n *\n * - **Native** — when a provider implements the optional `fs.watch` seam\n * (local-process does, via Node `fs.watch`), OS events drive the feed with low\n * latency.\n * - **Exec-poll** — otherwise (Docker, Cloudflare, any exec-only provider), a\n * single `find … -printf` snapshot of `mtime\\tsize\\tpath` is taken every\n * `intervalMs` and diffed. Works on any Linux container with GNU findutils\n * (true for `node:*` / debian images) with no extra deps or image changes.\n *\n * The feed intentionally rides only the portable surface, so the same\n * `watchWorkspace` call behaves identically across providers.\n */\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type { SandboxHandle } from './contracts'\nimport type { SandboxFileEvent } from '@tanstack/ai'\n\nexport type { SandboxFileEvent } from '@tanstack/ai'\n/** @deprecated alias retained for the low-level watch API. */\nexport type FileEvent = SandboxFileEvent\nexport type FileEventType = SandboxFileEvent['type']\n\nexport interface WatchOptions {\n /** Called for every observed file event. */\n onEvent: (event: SandboxFileEvent) => void\n /** Workspace root to watch. Defaults to `/workspace`. */\n root?: string\n /** Poll interval for the exec-poll fallback, in ms. Defaults to 700. */\n intervalMs?: number\n /**\n * Directory-name fragments to ignore (a path containing `/<entry>/` is\n * skipped). Defaults to `['.git', 'node_modules']`.\n */\n ignore?: Array<string>\n /** Stop watching when this signal aborts. */\n signal?: AbortSignal\n}\n\nexport interface SandboxWatchHandle {\n /** Stop the watcher and release its resources. */\n stop: () => Promise<void>\n}\n\nconst DEFAULT_INTERVAL_MS = 700\nconst DEFAULT_IGNORE = ['.git', 'node_modules']\n\n/** POSIX single-quote escape for embedding values in a shell command. */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/**\n * Diff two file snapshots (`Map<path, signature>`, signature = `mtime\\tsize`).\n * Pure — the heart of the exec-poll path, unit-tested in isolation.\n */\nexport function diffSnapshots(\n prev: Map<string, string>,\n next: Map<string, string>,\n timestamp: number,\n): Array<SandboxFileEvent> {\n const events: Array<SandboxFileEvent> = []\n for (const [path, sig] of next) {\n const before = prev.get(path)\n if (before === undefined) events.push({ type: 'create', path, timestamp })\n else if (before !== sig) events.push({ type: 'change', path, timestamp })\n }\n for (const path of prev.keys()) {\n if (!next.has(path)) events.push({ type: 'delete', path, timestamp })\n }\n return events\n}\n\n/** Build the `find` command that prints `mtime\\tsize\\tpath` for every file. */\nfunction buildFindCommand(root: string, ignore: Array<string>): string {\n const prunes = ignore\n .map((entry) => `-not -path ${q(`*/${entry}/*`)}`)\n .join(' ')\n return `find ${q(root)} -type f ${prunes} -printf '%T@\\\\t%s\\\\t%p\\\\n'`\n}\n\n/** Parse `find -printf` output into a `Map<path, signature>`. */\nfunction parseFindOutput(stdout: string): Map<string, string> {\n const snapshot = new Map<string, string>()\n for (const line of stdout.split('\\n')) {\n if (line === '') continue\n const firstTab = line.indexOf('\\t')\n const secondTab = line.indexOf('\\t', firstTab + 1)\n if (firstTab === -1 || secondTab === -1) continue\n const mtime = line.slice(0, firstTab)\n const size = line.slice(firstTab + 1, secondTab)\n const path = line.slice(secondTab + 1)\n snapshot.set(path, `${mtime}\\t${size}`)\n }\n return snapshot\n}\n\n/** Whether a path should be ignored (contains a `/<entry>/` fragment). */\nfunction isIgnored(path: string, ignore: Array<string>): boolean {\n return ignore.some((entry) => path.includes(`/${entry}/`))\n}\n\n/**\n * Start watching a sandbox workspace for file events. Picks the native\n * `fs.watch` fast-path when the provider advertises it, otherwise polls via\n * `find`. Returns a handle whose `stop()` tears everything down.\n */\nexport async function watchWorkspace(\n handle: SandboxHandle,\n options: WatchOptions,\n): Promise<SandboxWatchHandle> {\n const root = options.root ?? DEFAULT_WORKSPACE_ROOT\n const ignore = options.ignore ?? DEFAULT_IGNORE\n const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS\n\n // Already aborted before we start — don't begin any async work.\n if (options.signal?.aborted) return { stop: () => Promise.resolve() }\n\n if (handle.fs.watch) {\n return startNativeWatch(handle, { ...options, root, ignore })\n }\n return startPollWatch(handle, { ...options, root, ignore, intervalMs })\n}\n\n/** Native fs.watch path: OS events, disambiguated against a known-path set. */\nasync function startNativeWatch(\n handle: SandboxHandle,\n options: WatchOptions & { root: string; ignore: Array<string> },\n): Promise<SandboxWatchHandle> {\n const { onEvent, root, ignore } = options\n const watch = handle.fs.watch\n if (!watch) throw new Error('native watch is unavailable on this provider')\n // Seed the set of existing files so the first event per path is classified\n // correctly (create vs change).\n const known = await collectPaths(handle, root, ignore)\n\n const subscription = await watch(root, (raw) => {\n const path = raw.path\n if (isIgnored(path, ignore)) return\n void (async () => {\n const exists = await handle.fs.exists(path)\n const timestamp = Date.now()\n if (!exists) {\n if (known.delete(path)) onEvent({ type: 'delete', path, timestamp })\n return\n }\n if (known.has(path)) onEvent({ type: 'change', path, timestamp })\n else {\n known.add(path)\n onEvent({ type: 'create', path, timestamp })\n }\n })().catch(() => undefined)\n })\n\n const onAbort = (): void => void subscription.stop().catch(() => undefined)\n options.signal?.addEventListener('abort', onAbort, { once: true })\n // The signal may have aborted during the awaits above (the once-listener\n // would have missed it) — tear down now if so.\n if (options.signal?.aborted) void subscription.stop().catch(() => undefined)\n\n return {\n stop: async () => {\n options.signal?.removeEventListener('abort', onAbort)\n await subscription.stop()\n },\n }\n}\n\n/** Exec-poll path: snapshot `find -printf` on an interval and diff. */\nasync function startPollWatch(\n handle: SandboxHandle,\n options: WatchOptions & {\n root: string\n ignore: Array<string>\n intervalMs: number\n },\n): Promise<SandboxWatchHandle> {\n const { onEvent, root, ignore, intervalMs } = options\n const command = buildFindCommand(root, ignore)\n const controller = new AbortController()\n\n const snapshot = async (): Promise<Map<string, string>> => {\n const result = await handle.process.exec(command, {\n cwd: root,\n signal: controller.signal,\n })\n return result.exitCode === 0\n ? parseFindOutput(result.stdout)\n : new Map<string, string>()\n }\n\n let previous = await snapshot()\n const state = { running: true }\n\n const tick = async (): Promise<void> => {\n if (!state.running) return\n try {\n const next = await snapshot()\n for (const event of diffSnapshots(previous, next, Date.now())) {\n onEvent(event)\n }\n previous = next\n } catch {\n // transient exec failure (e.g. mid-teardown) — try again next tick\n }\n }\n\n const timer = setInterval(() => void tick(), intervalMs)\n // Don't keep the event loop alive on the watcher alone.\n if (typeof timer.unref === 'function') timer.unref()\n\n const stop = (): Promise<void> => {\n if (state.running) {\n state.running = false\n clearInterval(timer)\n controller.abort()\n options.signal?.removeEventListener('abort', onAbort)\n }\n return Promise.resolve()\n }\n const onAbort = (): void => void stop()\n options.signal?.addEventListener('abort', onAbort, { once: true })\n // The signal may have aborted during the initial `await snapshot()` above\n // (the once-listener would have missed it) — tear down now if so.\n if (options.signal?.aborted) void stop()\n\n return { stop }\n}\n\n/** Recursively collect file paths under `root`, honoring `ignore`. */\nasync function collectPaths(\n handle: SandboxHandle,\n root: string,\n ignore: Array<string>,\n): Promise<Set<string>> {\n const files = new Set<string>()\n const walk = async (dir: string): Promise<void> => {\n let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>\n try {\n entries = await handle.fs.list(dir)\n } catch {\n return\n }\n for (const entry of entries) {\n if (ignore.includes(entry.name)) continue\n if (entry.type === 'dir') await walk(entry.path)\n else files.add(entry.path)\n }\n }\n await walk(root)\n return files\n}\n"],"names":[],"mappings":";AAgDA,MAAM,sBAAsB;AAC5B,MAAM,iBAAiB,CAAC,QAAQ,cAAc;AAG9C,SAAS,EAAE,OAAuB;AAChC,SAAO,IAAI,MAAM,QAAQ,MAAM,OAAO,CAAC;AACzC;AAMO,SAAS,cACd,MACA,MACA,WACyB;AACzB,QAAM,SAAkC,CAAA;AACxC,aAAW,CAAC,MAAM,GAAG,KAAK,MAAM;AAC9B,UAAM,SAAS,KAAK,IAAI,IAAI;AAC5B,QAAI,WAAW,OAAW,QAAO,KAAK,EAAE,MAAM,UAAU,MAAM,WAAW;AAAA,aAChE,WAAW,IAAK,QAAO,KAAK,EAAE,MAAM,UAAU,MAAM,WAAW;AAAA,EAC1E;AACA,aAAW,QAAQ,KAAK,QAAQ;AAC9B,QAAI,CAAC,KAAK,IAAI,IAAI,EAAG,QAAO,KAAK,EAAE,MAAM,UAAU,MAAM,UAAA,CAAW;AAAA,EACtE;AACA,SAAO;AACT;AAGA,SAAS,iBAAiB,MAAc,QAA+B;AACrE,QAAM,SAAS,OACZ,IAAI,CAAC,UAAU,cAAc,EAAE,KAAK,KAAK,IAAI,CAAC,EAAE,EAChD,KAAK,GAAG;AACX,SAAO,QAAQ,EAAE,IAAI,CAAC,YAAY,MAAM;AAC1C;AAGA,SAAS,gBAAgB,QAAqC;AAC5D,QAAM,+BAAe,IAAA;AACrB,aAAW,QAAQ,OAAO,MAAM,IAAI,GAAG;AACrC,QAAI,SAAS,GAAI;AACjB,UAAM,WAAW,KAAK,QAAQ,GAAI;AAClC,UAAM,YAAY,KAAK,QAAQ,KAAM,WAAW,CAAC;AACjD,QAAI,aAAa,MAAM,cAAc,GAAI;AACzC,UAAM,QAAQ,KAAK,MAAM,GAAG,QAAQ;AACpC,UAAM,OAAO,KAAK,MAAM,WAAW,GAAG,SAAS;AAC/C,UAAM,OAAO,KAAK,MAAM,YAAY,CAAC;AACrC,aAAS,IAAI,MAAM,GAAG,KAAK,IAAK,IAAI,EAAE;AAAA,EACxC;AACA,SAAO;AACT;AAGA,SAAS,UAAU,MAAc,QAAgC;AAC/D,SAAO,OAAO,KAAK,CAAC,UAAU,KAAK,SAAS,IAAI,KAAK,GAAG,CAAC;AAC3D;AAOA,eAAsB,eACpB,QACA,SAC6B;AAC7B,QAAM,OAAO,QAAQ,QAAQ;AAC7B,QAAM,SAAS,QAAQ,UAAU;AACjC,QAAM,aAAa,QAAQ,cAAc;AAGzC,MAAI,QAAQ,QAAQ,QAAS,QAAO,EAAE,MAAM,MAAM,QAAQ,UAAQ;AAElE,MAAI,OAAO,GAAG,OAAO;AACnB,WAAO,iBAAiB,QAAQ,EAAE,GAAG,SAAS,MAAM,QAAQ;AAAA,EAC9D;AACA,SAAO,eAAe,QAAQ,EAAE,GAAG,SAAS,MAAM,QAAQ,YAAY;AACxE;AAGA,eAAe,iBACb,QACA,SAC6B;AAC7B,QAAM,EAAE,SAAS,MAAM,OAAA,IAAW;AAClC,QAAM,QAAQ,OAAO,GAAG;AACxB,MAAI,CAAC,MAAO,OAAM,IAAI,MAAM,8CAA8C;AAG1E,QAAM,QAAQ,MAAM,aAAa,QAAQ,MAAM,MAAM;AAErD,QAAM,eAAe,MAAM,MAAM,MAAM,CAAC,QAAQ;AAC9C,UAAM,OAAO,IAAI;AACjB,QAAI,UAAU,MAAM,MAAM,EAAG;AAC7B,UAAM,YAAY;AAChB,YAAM,SAAS,MAAM,OAAO,GAAG,OAAO,IAAI;AAC1C,YAAM,YAAY,KAAK,IAAA;AACvB,UAAI,CAAC,QAAQ;AACX,YAAI,MAAM,OAAO,IAAI,EAAG,SAAQ,EAAE,MAAM,UAAU,MAAM,WAAW;AACnE;AAAA,MACF;AACA,UAAI,MAAM,IAAI,IAAI,EAAG,SAAQ,EAAE,MAAM,UAAU,MAAM,WAAW;AAAA,WAC3D;AACH,cAAM,IAAI,IAAI;AACd,gBAAQ,EAAE,MAAM,UAAU,MAAM,WAAW;AAAA,MAC7C;AAAA,IACF,GAAA,EAAK,MAAM,MAAM,MAAS;AAAA,EAC5B,CAAC;AAED,QAAM,UAAU,MAAY,KAAK,aAAa,OAAO,MAAM,MAAM,MAAS;AAC1E,UAAQ,QAAQ,iBAAiB,SAAS,SAAS,EAAE,MAAM,MAAM;AAGjE,MAAI,QAAQ,QAAQ,QAAS,MAAK,aAAa,KAAA,EAAO,MAAM,MAAM,MAAS;AAE3E,SAAO;AAAA,IACL,MAAM,YAAY;AAChB,cAAQ,QAAQ,oBAAoB,SAAS,OAAO;AACpD,YAAM,aAAa,KAAA;AAAA,IACrB;AAAA,EAAA;AAEJ;AAGA,eAAe,eACb,QACA,SAK6B;AAC7B,QAAM,EAAE,SAAS,MAAM,QAAQ,eAAe;AAC9C,QAAM,UAAU,iBAAiB,MAAM,MAAM;AAC7C,QAAM,aAAa,IAAI,gBAAA;AAEvB,QAAM,WAAW,YAA0C;AACzD,UAAM,SAAS,MAAM,OAAO,QAAQ,KAAK,SAAS;AAAA,MAChD,KAAK;AAAA,MACL,QAAQ,WAAW;AAAA,IAAA,CACpB;AACD,WAAO,OAAO,aAAa,IACvB,gBAAgB,OAAO,MAAM,wBACzB,IAAA;AAAA,EACV;AAEA,MAAI,WAAW,MAAM,SAAA;AACrB,QAAM,QAAQ,EAAE,SAAS,KAAA;AAEzB,QAAM,OAAO,YAA2B;AACtC,QAAI,CAAC,MAAM,QAAS;AACpB,QAAI;AACF,YAAM,OAAO,MAAM,SAAA;AACnB,iBAAW,SAAS,cAAc,UAAU,MAAM,KAAK,IAAA,CAAK,GAAG;AAC7D,gBAAQ,KAAK;AAAA,MACf;AACA,iBAAW;AAAA,IACb,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,QAAQ,YAAY,MAAM,KAAK,KAAA,GAAQ,UAAU;AAEvD,MAAI,OAAO,MAAM,UAAU,kBAAkB,MAAA;AAE7C,QAAM,OAAO,MAAqB;AAChC,QAAI,MAAM,SAAS;AACjB,YAAM,UAAU;AAChB,oBAAc,KAAK;AACnB,iBAAW,MAAA;AACX,cAAQ,QAAQ,oBAAoB,SAAS,OAAO;AAAA,IACtD;AACA,WAAO,QAAQ,QAAA;AAAA,EACjB;AACA,QAAM,UAAU,MAAY,KAAK,KAAA;AACjC,UAAQ,QAAQ,iBAAiB,SAAS,SAAS,EAAE,MAAM,MAAM;AAGjE,MAAI,QAAQ,QAAQ,QAAS,MAAK,KAAA;AAElC,SAAO,EAAE,KAAA;AACX;AAGA,eAAe,aACb,QACA,MACA,QACsB;AACtB,QAAM,4BAAY,IAAA;AAClB,QAAM,OAAO,OAAO,QAA+B;AACjD,QAAI;AACJ,QAAI;AACF,gBAAU,MAAM,OAAO,GAAG,KAAK,GAAG;AAAA,IACpC,QAAQ;AACN;AAAA,IACF;AACA,eAAW,SAAS,SAAS;AAC3B,UAAI,OAAO,SAAS,MAAM,IAAI,EAAG;AACjC,UAAI,MAAM,SAAS,MAAO,OAAM,KAAK,MAAM,IAAI;AAAA,UAC1C,OAAM,IAAI,MAAM,IAAI;AAAA,IAC3B;AAAA,EACF;AACA,QAAM,KAAK,IAAI;AACf,SAAO;AACT;"}
|
|
1
|
+
{"version":3,"file":"watch.js","sources":["../../src/watch.ts"],"sourcesContent":["/**\n * Sandbox file-event hooks — observe create / change / delete of files inside a\n * sandbox (e.g. as an in-sandbox agent edits the workspace).\n *\n * Provider-agnostic: coded against the {@link SandboxHandle} contract only.\n * Two mechanisms, auto-selected:\n *\n * - **Native** — when a provider implements the optional `fs.watch` seam\n * (local-process does, via Node `fs.watch`), OS events drive the feed with low\n * latency.\n * - **Exec-poll** — otherwise (Docker, Cloudflare, any exec-only provider), a\n * single `find … -printf` snapshot of `mtime\\tsize\\tpath` is taken every\n * `intervalMs` and diffed. Works on any Linux container with GNU findutils\n * (true for `node:*` / debian images) with no extra deps or image changes.\n *\n * The feed intentionally rides only the portable surface, so the same\n * `watchWorkspace` call behaves identically across providers.\n */\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type { SandboxHandle } from './contracts'\nimport type { SandboxFileEvent } from '@tanstack/ai'\n\nexport type { SandboxFileEvent } from '@tanstack/ai'\n/** @deprecated alias retained for the low-level watch API. */\nexport type FileEvent = SandboxFileEvent\nexport type FileEventType = SandboxFileEvent['type']\n\nexport interface WatchOptions {\n /** Called for every observed file event. */\n onEvent: (event: SandboxFileEvent) => void\n /** Workspace root to watch. Defaults to `/workspace`. */\n root?: string\n /** Poll interval for the exec-poll fallback, in ms. Defaults to 700. */\n intervalMs?: number\n /**\n * Directory-name fragments to ignore (a path containing `/<entry>/` is\n * skipped). Defaults to `['.git', 'node_modules']`.\n */\n ignore?: Array<string>\n /** Stop watching when this signal aborts. */\n signal?: AbortSignal\n}\n\nexport interface SandboxWatchHandle {\n /** Stop the watcher and release its resources. */\n stop: () => Promise<void>\n}\n\nconst DEFAULT_INTERVAL_MS = 700\nconst DEFAULT_IGNORE = ['.git', 'node_modules']\n\n/** POSIX single-quote escape for embedding values in a shell command. */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/**\n * Diff two file snapshots (`Map<path, signature>`, signature = `mtime\\tsize`).\n * Pure — the heart of the exec-poll path, unit-tested in isolation.\n */\nexport function diffSnapshots(\n prev: Map<string, string>,\n next: Map<string, string>,\n timestamp: number,\n): Array<SandboxFileEvent> {\n const events: Array<SandboxFileEvent> = []\n for (const [path, sig] of next) {\n const before = prev.get(path)\n if (before === undefined) events.push({ type: 'create', path, timestamp })\n else if (before !== sig) events.push({ type: 'change', path, timestamp })\n }\n for (const path of prev.keys()) {\n if (!next.has(path)) events.push({ type: 'delete', path, timestamp })\n }\n return events\n}\n\n/**\n * Build the `find` command that prints `mtime\\tsize\\tpath` for every file.\n * Searches `.` (relative to the exec `cwd`) rather than an absolute root: a\n * provider's `exec` maps only `cwd` onto the real filesystem, not literal path\n * arguments, so `find <virtual-root>` would look at a non-existent host path on\n * mapped-root providers (e.g. local-process). Emitted `%p` values are\n * root-normalized in {@link parseFindOutput}.\n */\nfunction buildFindCommand(ignore: Array<string>): string {\n const prunes = ignore\n .map((entry) => `-not -path ${q(`*/${entry}/*`)}`)\n .join(' ')\n return `find . -type f ${prunes} -printf '%T@\\\\t%s\\\\t%p\\\\n'`\n}\n\n/**\n * Parse `find -printf` output into a `Map<path, signature>`. `find .` prints\n * paths like `./sub/file`; map them back under `root` so event paths match the\n * native-watch shape (`<root>/sub/file`).\n */\nfunction parseFindOutput(stdout: string, root: string): Map<string, string> {\n const base = root.replace(/\\/+$/, '')\n const snapshot = new Map<string, string>()\n for (const line of stdout.split('\\n')) {\n if (line === '') continue\n const firstTab = line.indexOf('\\t')\n const secondTab = line.indexOf('\\t', firstTab + 1)\n if (firstTab === -1 || secondTab === -1) continue\n const mtime = line.slice(0, firstTab)\n const size = line.slice(firstTab + 1, secondTab)\n const rel = line.slice(secondTab + 1).replace(/^\\.\\/?/, '')\n const path = rel === '' ? base : `${base}/${rel}`\n snapshot.set(path, `${mtime}\\t${size}`)\n }\n return snapshot\n}\n\n/** Whether a path should be ignored (contains a `/<entry>/` fragment). */\nfunction isIgnored(path: string, ignore: Array<string>): boolean {\n return ignore.some((entry) => path.includes(`/${entry}/`))\n}\n\n/**\n * Start watching a sandbox workspace for file events. Picks the native\n * `fs.watch` fast-path when the provider advertises it, otherwise polls via\n * `find`. Returns a handle whose `stop()` tears everything down.\n */\nexport async function watchWorkspace(\n handle: SandboxHandle,\n options: WatchOptions,\n): Promise<SandboxWatchHandle> {\n const root = options.root ?? DEFAULT_WORKSPACE_ROOT\n const ignore = options.ignore ?? DEFAULT_IGNORE\n const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS\n\n // Already aborted before we start — don't begin any async work.\n if (options.signal?.aborted) return { stop: () => Promise.resolve() }\n\n if (handle.fs.watch) {\n return startNativeWatch(handle, { ...options, root, ignore })\n }\n return startPollWatch(handle, { ...options, root, ignore, intervalMs })\n}\n\n/** Native fs.watch path: OS events, disambiguated against a known-path set. */\nasync function startNativeWatch(\n handle: SandboxHandle,\n options: WatchOptions & { root: string; ignore: Array<string> },\n): Promise<SandboxWatchHandle> {\n const { onEvent, root, ignore } = options\n const watch = handle.fs.watch\n if (!watch) throw new Error('native watch is unavailable on this provider')\n // Seed the set of existing files so the first event per path is classified\n // correctly (create vs change).\n const known = await collectPaths(handle, root, ignore)\n\n const subscription = await watch(root, (raw) => {\n const path = raw.path\n if (isIgnored(path, ignore)) return\n void (async () => {\n const exists = await handle.fs.exists(path)\n const timestamp = Date.now()\n if (!exists) {\n if (known.delete(path)) onEvent({ type: 'delete', path, timestamp })\n return\n }\n if (known.has(path)) onEvent({ type: 'change', path, timestamp })\n else {\n known.add(path)\n onEvent({ type: 'create', path, timestamp })\n }\n })().catch(() => undefined)\n })\n\n const onAbort = (): void => void subscription.stop().catch(() => undefined)\n options.signal?.addEventListener('abort', onAbort, { once: true })\n // The signal may have aborted during the awaits above (the once-listener\n // would have missed it) — tear down now if so.\n if (options.signal?.aborted) void subscription.stop().catch(() => undefined)\n\n return {\n stop: async () => {\n options.signal?.removeEventListener('abort', onAbort)\n await subscription.stop()\n },\n }\n}\n\n/** Exec-poll path: snapshot `find -printf` on an interval and diff. */\nasync function startPollWatch(\n handle: SandboxHandle,\n options: WatchOptions & {\n root: string\n ignore: Array<string>\n intervalMs: number\n },\n): Promise<SandboxWatchHandle> {\n const { onEvent, root, ignore, intervalMs } = options\n const command = buildFindCommand(ignore)\n const controller = new AbortController()\n\n const snapshot = async (): Promise<Map<string, string>> => {\n const result = await handle.process.exec(command, {\n cwd: root,\n signal: controller.signal,\n })\n return result.exitCode === 0\n ? parseFindOutput(result.stdout, root)\n : new Map<string, string>()\n }\n\n let previous = await snapshot()\n const state = { running: true }\n\n const tick = async (): Promise<void> => {\n if (!state.running) return\n try {\n const next = await snapshot()\n for (const event of diffSnapshots(previous, next, Date.now())) {\n onEvent(event)\n }\n previous = next\n } catch {\n // transient exec failure (e.g. mid-teardown) — try again next tick\n }\n }\n\n const timer = setInterval(() => void tick(), intervalMs)\n // Don't keep the event loop alive on the watcher alone.\n if (typeof timer.unref === 'function') timer.unref()\n\n const stop = (): Promise<void> => {\n if (state.running) {\n state.running = false\n clearInterval(timer)\n controller.abort()\n options.signal?.removeEventListener('abort', onAbort)\n }\n return Promise.resolve()\n }\n const onAbort = (): void => void stop()\n options.signal?.addEventListener('abort', onAbort, { once: true })\n // The signal may have aborted during the initial `await snapshot()` above\n // (the once-listener would have missed it) — tear down now if so.\n if (options.signal?.aborted) void stop()\n\n return { stop }\n}\n\n/** Recursively collect file paths under `root`, honoring `ignore`. */\nasync function collectPaths(\n handle: SandboxHandle,\n root: string,\n ignore: Array<string>,\n): Promise<Set<string>> {\n const files = new Set<string>()\n const walk = async (dir: string): Promise<void> => {\n let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>\n try {\n entries = await handle.fs.list(dir)\n } catch {\n return\n }\n for (const entry of entries) {\n if (ignore.includes(entry.name)) continue\n if (entry.type === 'dir') await walk(entry.path)\n else files.add(entry.path)\n }\n }\n await walk(root)\n return files\n}\n"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-sandbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@ngrok/ngrok": "^1.0.0",
|
|
47
|
-
"@tanstack/ai": "^0.
|
|
47
|
+
"@tanstack/ai": "^0.40.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"@ngrok/ngrok": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@ngrok/ngrok": "^1.7.0",
|
|
56
56
|
"@vitest/coverage-v8": "4.0.14",
|
|
57
|
-
"@tanstack/ai": "0.
|
|
57
|
+
"@tanstack/ai": "0.40.0"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
60
|
"build": "vite build",
|
package/src/file-diff.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai'
|
|
2
|
+
import type { SandboxHandle } from './contracts'
|
|
3
|
+
|
|
4
|
+
/** Path relative to the repo/workspace root, POSIX form. */
|
|
5
|
+
function relTo(root: string, path: string): string {
|
|
6
|
+
const prefix = root.endsWith('/') ? root : `${root}/`
|
|
7
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : path
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* POSIX single-quote escape for embedding a value in a shell command.
|
|
12
|
+
*/
|
|
13
|
+
function q(value: string): string {
|
|
14
|
+
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Minimal unified add-patch for a brand-new file (non-git workspaces). */
|
|
18
|
+
function synthesizeAddPatch(path: string, content: string): string {
|
|
19
|
+
const lines = content === '' ? [] : content.replace(/\n$/, '').split('\n')
|
|
20
|
+
const body = lines.map((l) => `+${l}`).join('\n')
|
|
21
|
+
return `--- /dev/null\n+++ ${path}\n@@ -0,0 +1,${lines.length} @@\n${body}${body ? '\n' : ''}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to
|
|
26
|
+
* the live handle. `baseSha` is the session baseline (`''` when the workspace
|
|
27
|
+
* isn't a git repo). Never throws.
|
|
28
|
+
*/
|
|
29
|
+
export function buildFileHookEvent(
|
|
30
|
+
handle: SandboxHandle,
|
|
31
|
+
root: string,
|
|
32
|
+
baseSha: string,
|
|
33
|
+
event: SandboxFileEvent,
|
|
34
|
+
): SandboxFileHookEvent {
|
|
35
|
+
const after = async (): Promise<string> => {
|
|
36
|
+
if (event.type === 'delete') return ''
|
|
37
|
+
try {
|
|
38
|
+
return await handle.fs.read(event.path)
|
|
39
|
+
} catch {
|
|
40
|
+
return ''
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const before = async (): Promise<string> => {
|
|
44
|
+
if (baseSha === '') return ''
|
|
45
|
+
const rel = relTo(root, event.path)
|
|
46
|
+
try {
|
|
47
|
+
const res = await handle.process.exec(
|
|
48
|
+
`git show ${q(baseSha)}:${q(rel)}`,
|
|
49
|
+
{ cwd: root },
|
|
50
|
+
)
|
|
51
|
+
return res.exitCode === 0 ? res.stdout : ''
|
|
52
|
+
} catch {
|
|
53
|
+
return ''
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const diff = async (): Promise<string> => {
|
|
57
|
+
if (baseSha === '') {
|
|
58
|
+
if (event.type === 'delete') return ''
|
|
59
|
+
return synthesizeAddPatch(event.path, await after())
|
|
60
|
+
}
|
|
61
|
+
// Pathspec must be relative to `root` (like `before()` above) — a bare
|
|
62
|
+
// leading `/` (e.g. the virtual `/workspace/x.ts`) is resolved by git
|
|
63
|
+
// against the filesystem root, not the repo root, and fails with
|
|
64
|
+
// "fatal: Invalid path" whenever the real repo root differs from
|
|
65
|
+
// `/workspace` (e.g. every local-process sandbox).
|
|
66
|
+
const rel = relTo(root, event.path)
|
|
67
|
+
try {
|
|
68
|
+
const res = await handle.process.exec(
|
|
69
|
+
`git diff ${q(baseSha)} -- ${q(rel)}`,
|
|
70
|
+
{
|
|
71
|
+
cwd: root,
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
return res.exitCode === 0 ? res.stdout : ''
|
|
75
|
+
} catch {
|
|
76
|
+
return ''
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { ...event, before, after, diff }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ResolvedFileEvents {
|
|
83
|
+
enabled: boolean
|
|
84
|
+
diff: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */
|
|
88
|
+
export function resolveFileEvents(
|
|
89
|
+
opt: boolean | { diff?: boolean } | undefined,
|
|
90
|
+
): ResolvedFileEvents {
|
|
91
|
+
if (opt === false) return { enabled: false, diff: false }
|
|
92
|
+
if (opt === undefined || opt === true) return { enabled: true, diff: false }
|
|
93
|
+
return { enabled: true, diff: opt.diff === true }
|
|
94
|
+
}
|
package/src/middleware.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
provideSandboxPolicy,
|
|
25
25
|
} from './capabilities'
|
|
26
26
|
import { computeWorkspaceHash } from './key'
|
|
27
|
+
import { buildFileHookEvent, resolveFileEvents } from './file-diff'
|
|
27
28
|
import { ProjectionCapability, provideWorkspaceProjection } from './projection'
|
|
28
29
|
import { resolveSecret } from './secrets'
|
|
29
30
|
import { watchWorkspace } from './watch'
|
|
@@ -33,6 +34,7 @@ import type {
|
|
|
33
34
|
ChatMiddlewareContext,
|
|
34
35
|
DefinedChatMiddleware,
|
|
35
36
|
SandboxFileEvent,
|
|
37
|
+
SandboxFileHookEvent,
|
|
36
38
|
} from '@tanstack/ai'
|
|
37
39
|
import type { SandboxHandle } from './contracts'
|
|
38
40
|
import type {
|
|
@@ -47,6 +49,10 @@ interface SandboxRunState {
|
|
|
47
49
|
handle: SandboxHandle
|
|
48
50
|
ensureCtx: SandboxEnsureContext
|
|
49
51
|
watcher?: SandboxWatchHandle
|
|
52
|
+
/** In-flight `enriched.diff()` promises queued by the `fileEvents.diff`
|
|
53
|
+
* watcher callback, awaited before teardown so a pending diff isn't
|
|
54
|
+
* dropped when the run finishes/aborts/errors mid-computation. */
|
|
55
|
+
pendingDiffs: Array<Promise<void>>
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
const runState = new WeakMap<object, SandboxRunState>()
|
|
@@ -81,7 +87,7 @@ function buildEnsureCtx(ctx: ChatMiddlewareContext): SandboxEnsureContext {
|
|
|
81
87
|
*/
|
|
82
88
|
async function dispatchDefinitionHooks(
|
|
83
89
|
hooks: SandboxHooks | undefined,
|
|
84
|
-
event:
|
|
90
|
+
event: SandboxFileHookEvent,
|
|
85
91
|
): Promise<void> {
|
|
86
92
|
if (!hooks) return
|
|
87
93
|
const typed = (
|
|
@@ -122,6 +128,17 @@ export function withSandbox(
|
|
|
122
128
|
provideSandbox(ctx, handle)
|
|
123
129
|
if (definition.policy) provideSandboxPolicy(ctx, definition.policy)
|
|
124
130
|
|
|
131
|
+
const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT
|
|
132
|
+
let baseSha = ''
|
|
133
|
+
try {
|
|
134
|
+
const shaRes = await handle.process.exec('git rev-parse HEAD', {
|
|
135
|
+
cwd: watchRoot,
|
|
136
|
+
})
|
|
137
|
+
if (shaRes.exitCode === 0) baseSha = shaRes.stdout.trim()
|
|
138
|
+
} catch {
|
|
139
|
+
// non-git workspace / exec rejects → baseSha stays '' (accessors fall back)
|
|
140
|
+
}
|
|
141
|
+
|
|
125
142
|
const workspace = definition.workspace
|
|
126
143
|
if (workspace !== undefined) {
|
|
127
144
|
const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT
|
|
@@ -149,19 +166,42 @@ export function withSandbox(
|
|
|
149
166
|
const hooks = definition.hooks
|
|
150
167
|
await hooks?.onReady?.(handle)
|
|
151
168
|
|
|
169
|
+
const fe = resolveFileEvents(definition.fileEvents)
|
|
170
|
+
const pendingDiffs: Array<Promise<void>> = []
|
|
152
171
|
let watcher: SandboxWatchHandle | undefined
|
|
153
|
-
if (
|
|
172
|
+
if (fe.enabled) {
|
|
154
173
|
const runtime = getSandboxRuntime(ctx, { optional: true })
|
|
155
174
|
watcher = await watchWorkspace(handle, {
|
|
156
175
|
onEvent: (event: SandboxFileEvent) => {
|
|
157
|
-
|
|
158
|
-
|
|
176
|
+
const enriched = buildFileHookEvent(
|
|
177
|
+
handle,
|
|
178
|
+
watchRoot,
|
|
179
|
+
baseSha,
|
|
180
|
+
event,
|
|
181
|
+
)
|
|
182
|
+
void dispatchDefinitionHooks(hooks, enriched)
|
|
183
|
+
runtime?.emit(enriched)
|
|
184
|
+
if (fe.diff) {
|
|
185
|
+
pendingDiffs.push(
|
|
186
|
+
enriched
|
|
187
|
+
.diff()
|
|
188
|
+
.then((diff) => {
|
|
189
|
+
runtime?.emitFileDiff({ path: event.path, diff })
|
|
190
|
+
})
|
|
191
|
+
.catch(() => undefined),
|
|
192
|
+
)
|
|
193
|
+
}
|
|
159
194
|
},
|
|
160
195
|
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
|
|
161
196
|
})
|
|
162
197
|
}
|
|
163
198
|
|
|
164
|
-
runState.set(ctx, {
|
|
199
|
+
runState.set(ctx, {
|
|
200
|
+
handle,
|
|
201
|
+
ensureCtx,
|
|
202
|
+
pendingDiffs,
|
|
203
|
+
...(watcher ? { watcher } : {}),
|
|
204
|
+
})
|
|
165
205
|
},
|
|
166
206
|
|
|
167
207
|
async onFinish(ctx) {
|
|
@@ -170,6 +210,7 @@ export function withSandbox(
|
|
|
170
210
|
const { handle, ensureCtx } = state
|
|
171
211
|
|
|
172
212
|
await state.watcher?.stop()
|
|
213
|
+
await Promise.allSettled(state.pendingDiffs)
|
|
173
214
|
|
|
174
215
|
const lifecycle = definition.lifecycle
|
|
175
216
|
|
|
@@ -204,6 +245,7 @@ export function withSandbox(
|
|
|
204
245
|
if (!state) return
|
|
205
246
|
|
|
206
247
|
await state.watcher?.stop()
|
|
248
|
+
await Promise.allSettled(state.pendingDiffs)
|
|
207
249
|
|
|
208
250
|
// ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.
|
|
209
251
|
// The in-sandbox agent process is not killed by closing its IO stream
|
|
@@ -220,6 +262,7 @@ export function withSandbox(
|
|
|
220
262
|
if (!state) return
|
|
221
263
|
|
|
222
264
|
await state.watcher?.stop()
|
|
265
|
+
await Promise.allSettled(state.pendingDiffs)
|
|
223
266
|
await definition.hooks?.onError?.(info.error)
|
|
224
267
|
|
|
225
268
|
// On failure, only tear down when the lifecycle says so; otherwise leave
|
package/src/sandbox.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { bootstrapWorkspace } from './bootstrap'
|
|
|
10
10
|
import { resolveAllSecrets } from './secrets'
|
|
11
11
|
import { computeSandboxKey } from './key'
|
|
12
12
|
import { InMemoryLockStore, InMemorySandboxStore } from './store'
|
|
13
|
-
import type {
|
|
13
|
+
import type { SandboxFileHookEvent } from '@tanstack/ai'
|
|
14
14
|
import type { SandboxHandle, SandboxProvider } from './contracts'
|
|
15
15
|
import type { SandboxKeyInput } from './key'
|
|
16
16
|
import type { LockStore, SandboxStore } from './store'
|
|
@@ -22,10 +22,10 @@ import type { WorkspaceDefinition } from './workspace'
|
|
|
22
22
|
* create/change/delete during a chat run; lifecycle hooks fire server-side.
|
|
23
23
|
*/
|
|
24
24
|
export interface SandboxHooks {
|
|
25
|
-
onFile?: (e:
|
|
26
|
-
onFileCreate?: (e:
|
|
27
|
-
onFileChange?: (e:
|
|
28
|
-
onFileDelete?: (e:
|
|
25
|
+
onFile?: (e: SandboxFileHookEvent) => void | Promise<void>
|
|
26
|
+
onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>
|
|
27
|
+
onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>
|
|
28
|
+
onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>
|
|
29
29
|
onReady?: (handle: SandboxHandle) => void | Promise<void>
|
|
30
30
|
onError?: (err: unknown) => void | Promise<void>
|
|
31
31
|
onDestroy?: () => void | Promise<void>
|
|
@@ -59,8 +59,9 @@ export interface SandboxConfig {
|
|
|
59
59
|
lifecycle?: SandboxLifecycle
|
|
60
60
|
/** Sandbox-scoped file/lifecycle hooks. */
|
|
61
61
|
hooks?: SandboxHooks
|
|
62
|
-
/** Watch the workspace for file events (default true).
|
|
63
|
-
|
|
62
|
+
/** Watch the workspace for file events (default true). `false` disables the
|
|
63
|
+
* watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */
|
|
64
|
+
fileEvents?: boolean | { diff?: boolean }
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */
|
|
@@ -83,8 +84,9 @@ export interface SandboxDefinition {
|
|
|
83
84
|
readonly lifecycle?: SandboxLifecycle
|
|
84
85
|
/** Sandbox-scoped file/lifecycle hooks. */
|
|
85
86
|
readonly hooks?: SandboxHooks
|
|
86
|
-
/** Watch the workspace for file events (default true).
|
|
87
|
-
|
|
87
|
+
/** Watch the workspace for file events (default true). `false` disables the
|
|
88
|
+
* watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */
|
|
89
|
+
readonly fileEvents?: boolean | { diff?: boolean }
|
|
88
90
|
/** Compound instance key for a given run context. */
|
|
89
91
|
key: (ctx: SandboxEnsureContext) => string
|
|
90
92
|
/** Resume-or-create the sandbox for this thread/run. */
|
package/src/watch.ts
CHANGED
|
@@ -75,16 +75,28 @@ export function diffSnapshots(
|
|
|
75
75
|
return events
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Build the `find` command that prints `mtime\tsize\tpath` for every file.
|
|
80
|
+
* Searches `.` (relative to the exec `cwd`) rather than an absolute root: a
|
|
81
|
+
* provider's `exec` maps only `cwd` onto the real filesystem, not literal path
|
|
82
|
+
* arguments, so `find <virtual-root>` would look at a non-existent host path on
|
|
83
|
+
* mapped-root providers (e.g. local-process). Emitted `%p` values are
|
|
84
|
+
* root-normalized in {@link parseFindOutput}.
|
|
85
|
+
*/
|
|
86
|
+
function buildFindCommand(ignore: Array<string>): string {
|
|
80
87
|
const prunes = ignore
|
|
81
88
|
.map((entry) => `-not -path ${q(`*/${entry}/*`)}`)
|
|
82
89
|
.join(' ')
|
|
83
|
-
return `find
|
|
90
|
+
return `find . -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'`
|
|
84
91
|
}
|
|
85
92
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Parse `find -printf` output into a `Map<path, signature>`. `find .` prints
|
|
95
|
+
* paths like `./sub/file`; map them back under `root` so event paths match the
|
|
96
|
+
* native-watch shape (`<root>/sub/file`).
|
|
97
|
+
*/
|
|
98
|
+
function parseFindOutput(stdout: string, root: string): Map<string, string> {
|
|
99
|
+
const base = root.replace(/\/+$/, '')
|
|
88
100
|
const snapshot = new Map<string, string>()
|
|
89
101
|
for (const line of stdout.split('\n')) {
|
|
90
102
|
if (line === '') continue
|
|
@@ -93,7 +105,8 @@ function parseFindOutput(stdout: string): Map<string, string> {
|
|
|
93
105
|
if (firstTab === -1 || secondTab === -1) continue
|
|
94
106
|
const mtime = line.slice(0, firstTab)
|
|
95
107
|
const size = line.slice(firstTab + 1, secondTab)
|
|
96
|
-
const
|
|
108
|
+
const rel = line.slice(secondTab + 1).replace(/^\.\/?/, '')
|
|
109
|
+
const path = rel === '' ? base : `${base}/${rel}`
|
|
97
110
|
snapshot.set(path, `${mtime}\t${size}`)
|
|
98
111
|
}
|
|
99
112
|
return snapshot
|
|
@@ -180,7 +193,7 @@ async function startPollWatch(
|
|
|
180
193
|
},
|
|
181
194
|
): Promise<SandboxWatchHandle> {
|
|
182
195
|
const { onEvent, root, ignore, intervalMs } = options
|
|
183
|
-
const command = buildFindCommand(
|
|
196
|
+
const command = buildFindCommand(ignore)
|
|
184
197
|
const controller = new AbortController()
|
|
185
198
|
|
|
186
199
|
const snapshot = async (): Promise<Map<string, string>> => {
|
|
@@ -189,7 +202,7 @@ async function startPollWatch(
|
|
|
189
202
|
signal: controller.signal,
|
|
190
203
|
})
|
|
191
204
|
return result.exitCode === 0
|
|
192
|
-
? parseFindOutput(result.stdout)
|
|
205
|
+
? parseFindOutput(result.stdout, root)
|
|
193
206
|
: new Map<string, string>()
|
|
194
207
|
}
|
|
195
208
|
|