@tanstack/ai-sandbox 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/agents-file.js +53 -34
- package/dist/esm/agents-file.js.map +1 -1
- package/dist/esm/align.d.ts +121 -0
- package/dist/esm/align.js +197 -0
- package/dist/esm/align.js.map +1 -0
- package/dist/esm/approvals.js +63 -29
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/attach-preflight.d.ts +85 -0
- package/dist/esm/attach-preflight.js +189 -0
- package/dist/esm/attach-preflight.js.map +1 -0
- package/dist/esm/bootstrap.js +103 -117
- package/dist/esm/bootstrap.js.map +1 -1
- package/dist/esm/bridge-events.js +96 -71
- package/dist/esm/bridge-events.js.map +1 -1
- package/dist/esm/capabilities.d.ts +0 -5
- package/dist/esm/capabilities.js +32 -28
- package/dist/esm/capabilities.js.map +1 -1
- package/dist/esm/chunk-identity.d.ts +52 -0
- package/dist/esm/chunk-identity.js +102 -0
- package/dist/esm/chunk-identity.js.map +1 -0
- package/dist/esm/claim.d.ts +187 -0
- package/dist/esm/claim.js +349 -0
- package/dist/esm/claim.js.map +1 -0
- package/dist/esm/contracts.d.ts +13 -0
- package/dist/esm/driver.d.ts +83 -0
- package/dist/esm/driver.js +138 -0
- package/dist/esm/driver.js.map +1 -0
- package/dist/esm/durability.d.ts +263 -0
- package/dist/esm/durability.js +230 -0
- package/dist/esm/durability.js.map +1 -0
- package/dist/esm/errors.js +28 -24
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/file-diff.js +151 -135
- package/dist/esm/file-diff.js.map +1 -1
- package/dist/esm/git-exec.js +51 -62
- package/dist/esm/git-exec.js.map +1 -1
- package/dist/esm/harness-cwd.js +24 -19
- package/dist/esm/harness-cwd.js.map +1 -1
- package/dist/esm/index.d.ts +30 -8
- package/dist/esm/index.js +23 -91
- package/dist/esm/instance-store.d.ts +88 -0
- package/dist/esm/instance-store.js +67 -0
- package/dist/esm/instance-store.js.map +1 -0
- package/dist/esm/journal-bytes.d.ts +67 -0
- package/dist/esm/journal-bytes.js +110 -0
- package/dist/esm/journal-bytes.js.map +1 -0
- package/dist/esm/journal-reader.d.ts +66 -0
- package/dist/esm/journal-reader.js +228 -0
- package/dist/esm/journal-reader.js.map +1 -0
- package/dist/esm/journal-sweep.d.ts +113 -0
- package/dist/esm/journal-sweep.js +309 -0
- package/dist/esm/journal-sweep.js.map +1 -0
- package/dist/esm/journal.d.ts +542 -0
- package/dist/esm/journal.js +679 -0
- package/dist/esm/journal.js.map +1 -0
- package/dist/esm/key.js +36 -33
- package/dist/esm/key.js.map +1 -1
- package/dist/esm/middleware.d.ts +50 -2
- package/dist/esm/middleware.js +335 -208
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.js +75 -49
- package/dist/esm/ngrok.js.map +1 -1
- package/dist/esm/policy.js +43 -34
- package/dist/esm/policy.js.map +1 -1
- package/dist/esm/projection.js +16 -8
- package/dist/esm/projection.js.map +1 -1
- package/dist/esm/reap.d.ts +238 -0
- package/dist/esm/reap.js +355 -0
- package/dist/esm/reap.js.map +1 -0
- package/dist/esm/reclaim.d.ts +84 -0
- package/dist/esm/reclaim.js +106 -0
- package/dist/esm/reclaim.js.map +1 -0
- package/dist/esm/remote-tools.js +73 -62
- package/dist/esm/remote-tools.js.map +1 -1
- package/dist/esm/run.d.ts +93 -25
- package/dist/esm/run.js +274 -79
- package/dist/esm/run.js.map +1 -1
- package/dist/esm/runner.d.ts +119 -2
- package/dist/esm/runner.js +270 -51
- package/dist/esm/runner.js.map +1 -1
- package/dist/esm/sandbox.d.ts +3 -2
- package/dist/esm/sandbox.js +139 -123
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/secrets.js +39 -47
- package/dist/esm/secrets.js.map +1 -1
- package/dist/esm/setup-plan.js +22 -14
- package/dist/esm/setup-plan.js.map +1 -1
- package/dist/esm/shell.d.ts +8 -0
- package/dist/esm/shell.js +197 -158
- package/dist/esm/shell.js.map +1 -1
- package/dist/esm/testkit/conformance.d.ts +16 -0
- package/dist/esm/testkit/conformance.js +97 -0
- package/dist/esm/testkit/conformance.js.map +1 -0
- package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
- package/dist/esm/testkit/journal-conformance.d.ts +51 -0
- package/dist/esm/testkit/journal-conformance.js +378 -0
- package/dist/esm/testkit/journal-conformance.js.map +1 -0
- package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
- package/dist/esm/testkit/reaper-conformance.js +847 -0
- package/dist/esm/testkit/reaper-conformance.js.map +1 -0
- package/dist/esm/testkit/shell-spawn.d.ts +2 -0
- package/dist/esm/testkit/shell-spawn.js +60 -0
- package/dist/esm/testkit/shell-spawn.js.map +1 -0
- package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
- package/dist/esm/testkit/takeover-conformance.js +685 -0
- package/dist/esm/testkit/takeover-conformance.js.map +1 -0
- package/dist/esm/tool-bridge.js +227 -180
- package/dist/esm/tool-bridge.js.map +1 -1
- package/dist/esm/tool-history.d.ts +62 -0
- package/dist/esm/tool-history.js +171 -0
- package/dist/esm/tool-history.js.map +1 -0
- package/dist/esm/watch.js +310 -236
- package/dist/esm/watch.js.map +1 -1
- package/dist/esm/workspace.d.ts +1 -1
- package/dist/esm/workspace.js +49 -28
- package/dist/esm/workspace.js.map +1 -1
- package/package.json +16 -6
- package/skills/ai-sandbox/SKILL.md +658 -20
- package/src/align.ts +297 -0
- package/src/attach-preflight.ts +292 -0
- package/src/capabilities.ts +4 -13
- package/src/chunk-identity.ts +154 -0
- package/src/claim.ts +479 -0
- package/src/contracts.ts +13 -0
- package/src/driver.ts +205 -0
- package/src/durability.ts +380 -0
- package/src/index.ts +212 -27
- package/src/instance-store.ts +122 -0
- package/src/journal-bytes.ts +136 -0
- package/src/journal-reader.ts +359 -0
- package/src/journal-sweep.ts +406 -0
- package/src/journal.ts +875 -0
- package/src/middleware.ts +470 -30
- package/src/reap.ts +723 -0
- package/src/reclaim.ts +191 -0
- package/src/run.ts +365 -75
- package/src/runner.ts +347 -3
- package/src/sandbox.ts +38 -8
- package/src/shell.ts +106 -38
- package/src/testkit/conformance.ts +117 -0
- package/src/testkit/durable-run-fields-conformance.ts +147 -0
- package/src/testkit/journal-conformance.ts +676 -0
- package/src/testkit/reaper-conformance.ts +1201 -0
- package/src/testkit/shell-spawn.ts +67 -0
- package/src/testkit/takeover-conformance.ts +1040 -0
- package/src/tool-history.ts +245 -0
- package/src/workspace.ts +1 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/run-log.d.ts +0 -81
- package/dist/esm/run-log.js +0 -107
- package/dist/esm/run-log.js.map +0 -1
- package/dist/esm/store.d.ts +0 -53
- package/dist/esm/store.js +0 -34
- package/dist/esm/store.js.map +0 -1
- package/src/run-log.ts +0 -224
- package/src/store.ts +0 -83
package/dist/esm/runner.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ProcessOptions, SandboxHandle } from './contracts.js';
|
|
2
|
+
import { RunStore } from '@tanstack/ai';
|
|
2
3
|
export interface SpawnNdjsonOptions extends ProcessOptions {
|
|
3
4
|
/**
|
|
4
5
|
* Called for each raw stdout line that is non-empty but fails JSON parsing
|
|
@@ -10,12 +11,128 @@ export interface SpawnNdjsonOptions extends ProcessOptions {
|
|
|
10
11
|
* e.g. the agent prompt for `claude -p`. Avoids putting the prompt in argv.
|
|
11
12
|
*/
|
|
12
13
|
input?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Route the agent's stdout through an in-sandbox journal rather than holding
|
|
16
|
+
* its pipe directly.
|
|
17
|
+
*
|
|
18
|
+
* This is what makes a run survive host death: with nothing piped, there is
|
|
19
|
+
* no reader whose disappearance can SIGPIPE the agent, and a later host reads
|
|
20
|
+
* the same file from byte 0. Opt-in so every existing caller (and every
|
|
21
|
+
* existing test) is unaffected.
|
|
22
|
+
*/
|
|
23
|
+
journal?: JournalOptions;
|
|
13
24
|
}
|
|
25
|
+
/** Journaling configuration for {@link spawnNdjson}. */
|
|
26
|
+
export interface JournalOptions {
|
|
27
|
+
/** Run id the journal path is derived from. Must match across hosts. */
|
|
28
|
+
runId: string;
|
|
29
|
+
/** Journal directory. Defaults to `/tmp/tanstack-runs`. */
|
|
30
|
+
dir?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Read an EXISTING journal instead of starting the agent. The read still
|
|
33
|
+
* begins at byte 0 — the alignment step, not the reader, decides what has
|
|
34
|
+
* already been delivered.
|
|
35
|
+
*/
|
|
36
|
+
attach?: boolean;
|
|
37
|
+
/** Poll interval for providers that cannot follow. */
|
|
38
|
+
pollIntervalMs?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Run record store, consulted ONLY on an attach and only when the journal is
|
|
41
|
+
* absent, to tell "not written yet" from "will never be written" (see
|
|
42
|
+
* `attach-preflight.ts`). Optional so an attach with no store wired keeps the
|
|
43
|
+
* bounded wait while losing the unknown/terminal classification.
|
|
44
|
+
*/
|
|
45
|
+
runs?: RunStore;
|
|
46
|
+
/**
|
|
47
|
+
* Bounded wait for a live run's journal to appear on an attach, AND — on every
|
|
48
|
+
* path, attach or fresh — the bound on the read's first byte
|
|
49
|
+
* (`ReadJournalOptions.firstByteTimeoutMs`). One knob for both because they
|
|
50
|
+
* bound the same question from two sides: the preflight covers "the file does
|
|
51
|
+
* not exist", the read covers "the file exists but nothing is writing to it",
|
|
52
|
+
* and a caller that widens one always means to widen the other.
|
|
53
|
+
*
|
|
54
|
+
* Defaults to `DEFAULT_ATTACH_JOURNAL_WAIT_MS`.
|
|
55
|
+
*/
|
|
56
|
+
attachWaitMs?: number;
|
|
57
|
+
}
|
|
58
|
+
type JournaledOptions = SpawnNdjsonOptions & {
|
|
59
|
+
journal: JournalOptions;
|
|
60
|
+
};
|
|
14
61
|
/** Split a stream of arbitrary string chunks into complete lines. */
|
|
15
62
|
export declare function toLines(chunks: AsyncIterable<string>): AsyncIterable<string>;
|
|
63
|
+
/**
|
|
64
|
+
* Start the agent with its stdout (and the `{"__exit":N}` sentinel) redirected
|
|
65
|
+
* into the journal, then return.
|
|
66
|
+
*
|
|
67
|
+
* Deliberately does NOT wait for the process and does NOT read its stdout: the
|
|
68
|
+
* whole point of journaling is that the host holds no handle on the agent's
|
|
69
|
+
* output, so a host that dies mid-run cannot take the agent down with it (no
|
|
70
|
+
* pipe to SIGPIPE). The spawned process is left running in the sandbox; the
|
|
71
|
+
* sentinel line the wrapper appends on exit is how anyone — this host or a
|
|
72
|
+
* successor — learns it finished. Stdin is still written directly to the
|
|
73
|
+
* spawned process, exactly as the unjournaled path does, since that transport
|
|
74
|
+
* is unaffected by where stdout goes.
|
|
75
|
+
*/
|
|
76
|
+
export declare function startJournaledAgent(handle: SandboxHandle, command: string, options: JournaledOptions): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Read a run's journal and yield each line parsed as JSON.
|
|
79
|
+
*
|
|
80
|
+
* Always reads from byte 0 — the alignment step (a later phase), not this
|
|
81
|
+
* reader, decides what a client has already seen. Stops at the `{"__exit":N}`
|
|
82
|
+
* sentinel, and throws for a non-zero N so the calling adapter's existing
|
|
83
|
+
* `catch` turns it into a `RUN_ERROR`, the same observable outcome the
|
|
84
|
+
* unjournaled path produces from a non-zero `wait()`. There is nothing to
|
|
85
|
+
* `wait()` on here: the host holds a `tail`, not the agent process, so the
|
|
86
|
+
* sentinel line IS the exit code.
|
|
87
|
+
*
|
|
88
|
+
* The sentinel is also what bounds journal growth: reaching it means the run is
|
|
89
|
+
* terminal, and a terminal run's record is the event log, so both journal files
|
|
90
|
+
* are deleted before this iterable finishes. The ordering below is load-bearing
|
|
91
|
+
* and is asserted, not merely commented:
|
|
92
|
+
*
|
|
93
|
+
* - The sentinel is captured and the loop is `break`-ed, so the source's
|
|
94
|
+
* `finally` kills the `tail` BEFORE the `rm` runs — the reader is stopped, then
|
|
95
|
+
* its input is deleted, never the other way round.
|
|
96
|
+
* - `exitCode` stays `undefined` if the journal stream ends without a sentinel.
|
|
97
|
+
* NOTHING is deleted on that path either way — the run may be mid-flight and a
|
|
98
|
+
* successor host may still need every byte — but the two causes are then
|
|
99
|
+
* separated by `options.signal.aborted`: an aborted consumer returns quietly,
|
|
100
|
+
* while a stream that died on its own (killed `tail`, destroyed sandbox, torn
|
|
101
|
+
* pipe) THROWS. Returning for both is how a truncated read used to reach the
|
|
102
|
+
* client as a normally-completing run.
|
|
103
|
+
* - A non-zero sentinel deletes too. The run is terminal either way.
|
|
104
|
+
* - The stderr sidecar is read BEFORE the deletion that destroys it, so a
|
|
105
|
+
* non-zero exit carries up to {@link STDERR_ERROR_CHARS} chars of the agent's
|
|
106
|
+
* own diagnostics, exactly as the unjournaled path below does. That closes the
|
|
107
|
+
* "Known regression" this function used to document; the read is bounded and
|
|
108
|
+
* failure-swallowing (see {@link readStderrTail}), so it cannot turn a run
|
|
109
|
+
* failure into a cleanup failure.
|
|
110
|
+
*
|
|
111
|
+
* On an ATTACH (`journal.attach === true`) the read is preceded by
|
|
112
|
+
* {@link awaitAttachableJournal}, which fails fast for a runId the store does not
|
|
113
|
+
* know or has already terminalized and otherwise waits a BOUNDED time for a live
|
|
114
|
+
* run's journal to appear. Without it, an attach to a runId with no journal
|
|
115
|
+
* created an empty one (`journalFollowCommand` does that deliberately) and tailed
|
|
116
|
+
* it forever — no sentinel, no error, no timeout.
|
|
117
|
+
*
|
|
118
|
+
* One case this does NOT bound: a run that reaches its sentinel while DETACHED
|
|
119
|
+
* has no host reading it, so nothing observes the sentinel and nothing here
|
|
120
|
+
* runs. Sweeping those is `pruneJournals`' job (`journal-sweep.ts`): it consults
|
|
121
|
+
* the run store's status for each journal it finds and deletes only the terminal
|
|
122
|
+
* ones, from a cron the application schedules rather than from a run.
|
|
123
|
+
*/
|
|
124
|
+
export declare function readJournalNdjson(handle: SandboxHandle, options: JournaledOptions): AsyncIterable<unknown>;
|
|
16
125
|
/**
|
|
17
126
|
* Spawn `command` in the sandbox and yield each stdout line parsed as JSON.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
127
|
+
*
|
|
128
|
+
* Without `options.journal`, behavior is byte-identical to before: resolves
|
|
129
|
+
* the spawn handle's exit via `wait()` after stdout closes; a non-zero exit
|
|
130
|
+
* with no events surfaced is the adapter's concern to detect.
|
|
131
|
+
*
|
|
132
|
+
* With `options.journal`, the agent's stdout is redirected into an in-sandbox
|
|
133
|
+
* journal (unless `journal.attach` is set, meaning a run already in flight)
|
|
134
|
+
* and then read back from byte 0 — one code path for a fresh run and an
|
|
135
|
+
* attach, both going through {@link readJournalNdjson}.
|
|
20
136
|
*/
|
|
21
137
|
export declare function spawnNdjson(handle: SandboxHandle, command: string, options?: SpawnNdjsonOptions): AsyncIterable<unknown>;
|
|
138
|
+
export {};
|
package/dist/esm/runner.js
CHANGED
|
@@ -1,54 +1,273 @@
|
|
|
1
|
+
import { journalCleanupCommand, journalPaths, journalStderrReadCommand, journaledCommand, parseExitSentinel } from "./journal.js";
|
|
2
|
+
import { awaitAttachableJournal } from "./attach-preflight.js";
|
|
3
|
+
import { decodeBase64Stream } from "./journal-bytes.js";
|
|
4
|
+
import { readJournal } from "./journal-reader.js";
|
|
5
|
+
//#region src/runner.ts
|
|
6
|
+
/**
|
|
7
|
+
* The reusable "run an agent CLI inside a sandbox and stream its events out"
|
|
8
|
+
* primitive. Harness adapters (claude-code, codex, …) spawn their CLI via the
|
|
9
|
+
* uniform {@link SandboxHandle} and consume newline-delimited JSON from stdout,
|
|
10
|
+
* which they then translate into AG-UI StreamChunks.
|
|
11
|
+
*
|
|
12
|
+
* This is intentionally transport-minimal: a stdout NDJSON pipe. Multi-client
|
|
13
|
+
* reconnect / replay belongs to the persistence/EventLog layer, not here.
|
|
14
|
+
*
|
|
15
|
+
* The `journal` option adds a second, opt-in transport: instead of holding the
|
|
16
|
+
* agent's stdout pipe directly, the host redirects it into an append-only file
|
|
17
|
+
* inside the sandbox and tails that file. See `journal.ts` for why (host death
|
|
18
|
+
* cannot SIGPIPE the agent, and a later host can resume the same file from byte
|
|
19
|
+
* 0). `spawnNdjson`'s signature and unjournaled behavior are unchanged; every
|
|
20
|
+
* existing caller keeps working exactly as before.
|
|
21
|
+
*/
|
|
22
|
+
function isJournaled(options) {
|
|
23
|
+
return options.journal !== void 0;
|
|
24
|
+
}
|
|
25
|
+
function resolvePaths(options) {
|
|
26
|
+
return journalPaths(options.journal.runId, options.journal.dir);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Strip the runner-only options, leaving what `handle.process.spawn` accepts.
|
|
30
|
+
*
|
|
31
|
+
* `signal` is deliberately KEPT: on the UNJOURNALED path the host holds the
|
|
32
|
+
* agent's stdout pipe, so a client disconnect should take the process down with
|
|
33
|
+
* it. The journaled path must not forward it — see
|
|
34
|
+
* {@link toJournaledSpawnOptions}.
|
|
35
|
+
*/
|
|
36
|
+
function toProcessOptions(options) {
|
|
37
|
+
const { onNonJsonLine, input, journal, ...rest } = options;
|
|
38
|
+
return rest;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The journaled agent's spawn options: {@link toProcessOptions} MINUS `signal`.
|
|
42
|
+
*
|
|
43
|
+
* The request's signal must never reach the agent process on this path. Providers
|
|
44
|
+
* DO act on it at spawn time — local-process registers it to `killTree` the
|
|
45
|
+
* process group, and daytona and docker honor it too — so forwarding it means a
|
|
46
|
+
* client disconnect kills the journaled agent. The agent then writes no exit
|
|
47
|
+
* sentinel, and a successor host takes over a run that is already dead: the exact
|
|
48
|
+
* opposite of the guarantee documented on {@link startJournaledAgent}, and of the
|
|
49
|
+
* reason the journal is a file rather than a pipe.
|
|
50
|
+
*
|
|
51
|
+
* The signal is still honored for the READ. `readJournalNdjson` forwards it to
|
|
52
|
+
* `readJournal` and `awaitAttachableJournal` on its own, so a disconnecting
|
|
53
|
+
* client stops tailing immediately. Only the agent spawn outlives the request.
|
|
54
|
+
*/
|
|
55
|
+
function toJournaledSpawnOptions(options) {
|
|
56
|
+
const { signal, ...rest } = toProcessOptions(options);
|
|
57
|
+
return rest;
|
|
58
|
+
}
|
|
59
|
+
/** Split a stream of arbitrary string chunks into complete lines. */
|
|
1
60
|
async function* toLines(chunks) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
61
|
+
let buffer = "";
|
|
62
|
+
for await (const chunk of chunks) {
|
|
63
|
+
buffer += chunk;
|
|
64
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
65
|
+
while (newlineIndex !== -1) {
|
|
66
|
+
const line = buffer.slice(0, newlineIndex);
|
|
67
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
68
|
+
yield line;
|
|
69
|
+
newlineIndex = buffer.indexOf("\n");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (buffer.length > 0) yield buffer;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Start the agent with its stdout (and the `{"__exit":N}` sentinel) redirected
|
|
76
|
+
* into the journal, then return.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately does NOT wait for the process and does NOT read its stdout: the
|
|
79
|
+
* whole point of journaling is that the host holds no handle on the agent's
|
|
80
|
+
* output, so a host that dies mid-run cannot take the agent down with it (no
|
|
81
|
+
* pipe to SIGPIPE). The spawned process is left running in the sandbox; the
|
|
82
|
+
* sentinel line the wrapper appends on exit is how anyone — this host or a
|
|
83
|
+
* successor — learns it finished. Stdin is still written directly to the
|
|
84
|
+
* spawned process, exactly as the unjournaled path does, since that transport
|
|
85
|
+
* is unaffected by where stdout goes.
|
|
86
|
+
*/
|
|
87
|
+
async function startJournaledAgent(handle, command, options) {
|
|
88
|
+
const paths = resolvePaths(options);
|
|
89
|
+
const proc = await handle.process.spawn(journaledCommand(command, paths), toJournaledSpawnOptions(options));
|
|
90
|
+
if (options.input !== void 0) {
|
|
91
|
+
await proc.stdin.write(options.input);
|
|
92
|
+
await proc.stdin.end();
|
|
93
|
+
}
|
|
14
94
|
}
|
|
95
|
+
/** Chars of stderr attached to a non-zero-exit error, on both paths. */
|
|
96
|
+
var STDERR_ERROR_CHARS = 1e3;
|
|
97
|
+
async function* singleValue(value) {
|
|
98
|
+
yield value;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Read the tail of a run's stderr sidecar, for the error message only.
|
|
102
|
+
*
|
|
103
|
+
* Returns `''` on ANY failure — a provider whose `exec` rejects, a sidecar that
|
|
104
|
+
* no longer exists, a base64 frame the provider truncated. The caller is on its
|
|
105
|
+
* way to throwing the real failure (the agent's non-zero exit), and losing a
|
|
106
|
+
* diagnostic suffix must never replace that error with a cleanup error. Decoding
|
|
107
|
+
* is deliberately lossy: `journalStderrReadCommand` reads the LAST N bytes, so
|
|
108
|
+
* byte 0 of the frame can sit mid-character.
|
|
109
|
+
*/
|
|
110
|
+
async function readStderrTail(handle, paths) {
|
|
111
|
+
try {
|
|
112
|
+
const result = await handle.process.exec(journalStderrReadCommand(paths));
|
|
113
|
+
const decoder = new TextDecoder();
|
|
114
|
+
let text = "";
|
|
115
|
+
for await (const bytes of decodeBase64Stream(singleValue(result.stdout))) text += decoder.decode(bytes, { stream: true });
|
|
116
|
+
text += decoder.decode();
|
|
117
|
+
return text.trim();
|
|
118
|
+
} catch {
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Delete a terminal run's journal. Best effort by construction: see
|
|
124
|
+
* {@link journalCleanupCommand} for why a failure here cannot be allowed to fail
|
|
125
|
+
* a run that has already finished.
|
|
126
|
+
*/
|
|
127
|
+
async function cleanupJournal(handle, paths) {
|
|
128
|
+
try {
|
|
129
|
+
await handle.process.exec(journalCleanupCommand(paths));
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Read a run's journal and yield each line parsed as JSON.
|
|
134
|
+
*
|
|
135
|
+
* Always reads from byte 0 — the alignment step (a later phase), not this
|
|
136
|
+
* reader, decides what a client has already seen. Stops at the `{"__exit":N}`
|
|
137
|
+
* sentinel, and throws for a non-zero N so the calling adapter's existing
|
|
138
|
+
* `catch` turns it into a `RUN_ERROR`, the same observable outcome the
|
|
139
|
+
* unjournaled path produces from a non-zero `wait()`. There is nothing to
|
|
140
|
+
* `wait()` on here: the host holds a `tail`, not the agent process, so the
|
|
141
|
+
* sentinel line IS the exit code.
|
|
142
|
+
*
|
|
143
|
+
* The sentinel is also what bounds journal growth: reaching it means the run is
|
|
144
|
+
* terminal, and a terminal run's record is the event log, so both journal files
|
|
145
|
+
* are deleted before this iterable finishes. The ordering below is load-bearing
|
|
146
|
+
* and is asserted, not merely commented:
|
|
147
|
+
*
|
|
148
|
+
* - The sentinel is captured and the loop is `break`-ed, so the source's
|
|
149
|
+
* `finally` kills the `tail` BEFORE the `rm` runs — the reader is stopped, then
|
|
150
|
+
* its input is deleted, never the other way round.
|
|
151
|
+
* - `exitCode` stays `undefined` if the journal stream ends without a sentinel.
|
|
152
|
+
* NOTHING is deleted on that path either way — the run may be mid-flight and a
|
|
153
|
+
* successor host may still need every byte — but the two causes are then
|
|
154
|
+
* separated by `options.signal.aborted`: an aborted consumer returns quietly,
|
|
155
|
+
* while a stream that died on its own (killed `tail`, destroyed sandbox, torn
|
|
156
|
+
* pipe) THROWS. Returning for both is how a truncated read used to reach the
|
|
157
|
+
* client as a normally-completing run.
|
|
158
|
+
* - A non-zero sentinel deletes too. The run is terminal either way.
|
|
159
|
+
* - The stderr sidecar is read BEFORE the deletion that destroys it, so a
|
|
160
|
+
* non-zero exit carries up to {@link STDERR_ERROR_CHARS} chars of the agent's
|
|
161
|
+
* own diagnostics, exactly as the unjournaled path below does. That closes the
|
|
162
|
+
* "Known regression" this function used to document; the read is bounded and
|
|
163
|
+
* failure-swallowing (see {@link readStderrTail}), so it cannot turn a run
|
|
164
|
+
* failure into a cleanup failure.
|
|
165
|
+
*
|
|
166
|
+
* On an ATTACH (`journal.attach === true`) the read is preceded by
|
|
167
|
+
* {@link awaitAttachableJournal}, which fails fast for a runId the store does not
|
|
168
|
+
* know or has already terminalized and otherwise waits a BOUNDED time for a live
|
|
169
|
+
* run's journal to appear. Without it, an attach to a runId with no journal
|
|
170
|
+
* created an empty one (`journalFollowCommand` does that deliberately) and tailed
|
|
171
|
+
* it forever — no sentinel, no error, no timeout.
|
|
172
|
+
*
|
|
173
|
+
* One case this does NOT bound: a run that reaches its sentinel while DETACHED
|
|
174
|
+
* has no host reading it, so nothing observes the sentinel and nothing here
|
|
175
|
+
* runs. Sweeping those is `pruneJournals`' job (`journal-sweep.ts`): it consults
|
|
176
|
+
* the run store's status for each journal it finds and deletes only the terminal
|
|
177
|
+
* ones, from a cron the application schedules rather than from a run.
|
|
178
|
+
*/
|
|
179
|
+
async function* readJournalNdjson(handle, options) {
|
|
180
|
+
const paths = resolvePaths(options);
|
|
181
|
+
if (options.journal.attach === true) await awaitAttachableJournal(handle, {
|
|
182
|
+
paths,
|
|
183
|
+
runId: options.journal.runId,
|
|
184
|
+
...options.journal.runs === void 0 ? {} : { runs: options.journal.runs },
|
|
185
|
+
...options.journal.attachWaitMs === void 0 ? {} : { waitMs: options.journal.attachWaitMs },
|
|
186
|
+
...options.signal === void 0 ? {} : { signal: options.signal }
|
|
187
|
+
});
|
|
188
|
+
let exitCode;
|
|
189
|
+
for await (const { line } of readJournal(handle, {
|
|
190
|
+
paths,
|
|
191
|
+
fromByte: 0,
|
|
192
|
+
runId: options.journal.runId,
|
|
193
|
+
...options.signal === void 0 ? {} : { signal: options.signal },
|
|
194
|
+
...options.journal.pollIntervalMs === void 0 ? {} : { pollIntervalMs: options.journal.pollIntervalMs },
|
|
195
|
+
...options.journal.attachWaitMs === void 0 ? {} : { firstByteTimeoutMs: options.journal.attachWaitMs }
|
|
196
|
+
})) {
|
|
197
|
+
const trimmed = line.trim();
|
|
198
|
+
if (trimmed === "") continue;
|
|
199
|
+
const sentinel = parseExitSentinel(trimmed, paths);
|
|
200
|
+
if (sentinel !== null) {
|
|
201
|
+
exitCode = sentinel;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
let parsed;
|
|
205
|
+
try {
|
|
206
|
+
parsed = JSON.parse(trimmed);
|
|
207
|
+
} catch {
|
|
208
|
+
options.onNonJsonLine?.(trimmed);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
yield parsed;
|
|
212
|
+
}
|
|
213
|
+
if (exitCode === void 0) {
|
|
214
|
+
if (options.signal?.aborted === true) return;
|
|
215
|
+
throw new Error(`Agent journal stream for run ${options.journal.runId} ended without an exit sentinel (${paths.journal}). The run was NOT observed to finish: the tail was torn down, the sandbox went away, or the agent's shell died before writing its sentinel. Both journal files are left in place for a successor host.`);
|
|
216
|
+
}
|
|
217
|
+
const stderr = exitCode === 0 ? "" : await readStderrTail(handle, paths);
|
|
218
|
+
await cleanupJournal(handle, paths);
|
|
219
|
+
if (exitCode !== 0) throw new Error(`Agent process exited with code ${exitCode}` + (stderr ? `: ${stderr.slice(0, STDERR_ERROR_CHARS)}` : ""));
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Spawn `command` in the sandbox and yield each stdout line parsed as JSON.
|
|
223
|
+
*
|
|
224
|
+
* Without `options.journal`, behavior is byte-identical to before: resolves
|
|
225
|
+
* the spawn handle's exit via `wait()` after stdout closes; a non-zero exit
|
|
226
|
+
* with no events surfaced is the adapter's concern to detect.
|
|
227
|
+
*
|
|
228
|
+
* With `options.journal`, the agent's stdout is redirected into an in-sandbox
|
|
229
|
+
* journal (unless `journal.attach` is set, meaning a run already in flight)
|
|
230
|
+
* and then read back from byte 0 — one code path for a fresh run and an
|
|
231
|
+
* attach, both going through {@link readJournalNdjson}.
|
|
232
|
+
*/
|
|
15
233
|
async function* spawnNdjson(handle, command, options = {}) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
};
|
|
54
|
-
|
|
234
|
+
if (isJournaled(options)) {
|
|
235
|
+
if (options.journal.attach !== true) await startJournaledAgent(handle, command, options);
|
|
236
|
+
yield* readJournalNdjson(handle, options);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const { onNonJsonLine, input, ...processOptions } = options;
|
|
240
|
+
const proc = await handle.process.spawn(command, processOptions);
|
|
241
|
+
if (input !== void 0) {
|
|
242
|
+
await proc.stdin.write(input);
|
|
243
|
+
await proc.stdin.end();
|
|
244
|
+
}
|
|
245
|
+
const stderrChunks = [];
|
|
246
|
+
const stderrDrained = (async () => {
|
|
247
|
+
try {
|
|
248
|
+
for await (const chunk of proc.stderr) stderrChunks.push(chunk);
|
|
249
|
+
} catch {}
|
|
250
|
+
})();
|
|
251
|
+
for await (const line of toLines(proc.stdout)) {
|
|
252
|
+
const trimmed = line.trim();
|
|
253
|
+
if (trimmed === "") continue;
|
|
254
|
+
let parsed;
|
|
255
|
+
try {
|
|
256
|
+
parsed = JSON.parse(trimmed);
|
|
257
|
+
} catch {
|
|
258
|
+
onNonJsonLine?.(trimmed);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
yield parsed;
|
|
262
|
+
}
|
|
263
|
+
const exitCode = await proc.wait();
|
|
264
|
+
await stderrDrained;
|
|
265
|
+
if (exitCode !== 0) {
|
|
266
|
+
const stderr = stderrChunks.join("").trim();
|
|
267
|
+
throw new Error(`Agent process exited with code ${exitCode}` + (stderr ? `: ${stderr.slice(0, STDERR_ERROR_CHARS)}` : ""));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
271
|
+
export { readJournalNdjson, spawnNdjson, startJournaledAgent, toLines };
|
|
272
|
+
|
|
273
|
+
//# sourceMappingURL=runner.js.map
|
package/dist/esm/runner.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner.js","sources":["../../src/runner.ts"],"sourcesContent":["/**\n * The reusable \"run an agent CLI inside a sandbox and stream its events out\"\n * primitive. Harness adapters (claude-code, codex, …) spawn their CLI via the\n * uniform {@link SandboxHandle} and consume newline-delimited JSON from stdout,\n * which they then translate into AG-UI StreamChunks.\n *\n * This is intentionally transport-minimal: a stdout NDJSON pipe. Multi-client\n * reconnect / replay belongs to the persistence/EventLog layer, not here.\n */\nimport type { ProcessOptions, SandboxHandle } from './contracts'\n\nexport interface SpawnNdjsonOptions extends ProcessOptions {\n /**\n * Called for each raw stdout line that is non-empty but fails JSON parsing\n * (e.g. a CLI banner). Defaults to ignoring it. Stderr is never parsed.\n */\n onNonJsonLine?: (line: string) => void\n /**\n * Written to the process stdin (then stdin is closed) right after spawn —\n * e.g. the agent prompt for `claude -p`. Avoids putting the prompt in argv.\n */\n input?: string\n}\n\n/** Split a stream of arbitrary string chunks into complete lines. */\nexport async function* toLines(\n chunks: AsyncIterable<string>,\n): AsyncIterable<string> {\n let buffer = ''\n for await (const chunk of chunks) {\n buffer += chunk\n let newlineIndex = buffer.indexOf('\\n')\n while (newlineIndex !== -1) {\n const line = buffer.slice(0, newlineIndex)\n buffer = buffer.slice(newlineIndex + 1)\n yield line\n newlineIndex = buffer.indexOf('\\n')\n }\n }\n if (buffer.length > 0) yield buffer\n}\n\n/**\n * Spawn `command` in the sandbox and yield each stdout line parsed as JSON.\n * Resolves the spawn handle's exit via `wait()` after stdout closes; a non-zero\n * exit with no events surfaced is the adapter's concern to detect.\n */\nexport async function* spawnNdjson(\n handle: SandboxHandle,\n command: string,\n options: SpawnNdjsonOptions = {},\n): AsyncIterable<unknown> {\n const { onNonJsonLine, input, ...processOptions } = options\n const proc = await handle.process.spawn(command, processOptions)\n\n if (input !== undefined) {\n await proc.stdin.write(input)\n await proc.stdin.end()\n }\n\n // Drain stderr concurrently. A CLI that fails before producing stdout (a\n // broken install, an auth/permission refusal, …) prints to stderr and exits\n // non-zero; without this, stdout-only parsing yields nothing and the failure\n // vanishes. Capturing it lets us surface the cause below.\n const stderrChunks: Array<string> = []\n const stderrDrained = (async () => {\n try {\n for await (const chunk of proc.stderr) stderrChunks.push(chunk)\n } catch {\n // stderr stream torn down — use whatever was captured\n }\n })()\n\n for await (const line of toLines(proc.stdout)) {\n const trimmed = line.trim()\n if (trimmed === '') continue\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n } catch {\n onNonJsonLine?.(trimmed)\n continue\n }\n yield parsed\n }\n\n const exitCode = await proc.wait()\n await stderrDrained\n // A non-zero exit means the agent CLI itself failed. Throw so the adapter's\n // catch turns it into a RUN_ERROR the UI can show, instead of ending the\n // stream silently with no events.\n if (exitCode !== 0) {\n const stderr = stderrChunks.join('').trim()\n throw new Error(\n `Agent process exited with code ${exitCode}` +\n (stderr ? `: ${stderr.slice(0, 1000)}` : ''),\n )\n }\n}\n"],"names":[],"mappings":"AAyBA,gBAAuB,QACrB,QACuB;AACvB,MAAI,SAAS;AACb,mBAAiB,SAAS,QAAQ;AAChC,cAAU;AACV,QAAI,eAAe,OAAO,QAAQ,IAAI;AACtC,WAAO,iBAAiB,IAAI;AAC1B,YAAM,OAAO,OAAO,MAAM,GAAG,YAAY;AACzC,eAAS,OAAO,MAAM,eAAe,CAAC;AACtC,YAAM;AACN,qBAAe,OAAO,QAAQ,IAAI;AAAA,IACpC;AAAA,EACF;AACA,MAAI,OAAO,SAAS,EAAG,OAAM;AAC/B;AAOA,gBAAuB,YACrB,QACA,SACA,UAA8B,CAAA,GACN;AACxB,QAAM,EAAE,eAAe,OAAO,GAAG,mBAAmB;AACpD,QAAM,OAAO,MAAM,OAAO,QAAQ,MAAM,SAAS,cAAc;AAE/D,MAAI,UAAU,QAAW;AACvB,UAAM,KAAK,MAAM,MAAM,KAAK;AAC5B,UAAM,KAAK,MAAM,IAAA;AAAA,EACnB;AAMA,QAAM,eAA8B,CAAA;AACpC,QAAM,iBAAiB,YAAY;AACjC,QAAI;AACF,uBAAiB,SAAS,KAAK,OAAQ,cAAa,KAAK,KAAK;AAAA,IAChE,QAAQ;AAAA,IAER;AAAA,EACF,GAAA;AAEA,mBAAiB,QAAQ,QAAQ,KAAK,MAAM,GAAG;AAC7C,UAAM,UAAU,KAAK,KAAA;AACrB,QAAI,YAAY,GAAI;AACpB,QAAI;AACJ,QAAI;AACF,eAAS,KAAK,MAAM,OAAO;AAAA,IAC7B,QAAQ;AACN,sBAAgB,OAAO;AACvB;AAAA,IACF;AACA,UAAM;AAAA,EACR;AAEA,QAAM,WAAW,MAAM,KAAK,KAAA;AAC5B,QAAM;AAIN,MAAI,aAAa,GAAG;AAClB,UAAM,SAAS,aAAa,KAAK,EAAE,EAAE,KAAA;AACrC,UAAM,IAAI;AAAA,MACR,kCAAkC,QAAQ,MACvC,SAAS,KAAK,OAAO,MAAM,GAAG,GAAI,CAAC,KAAK;AAAA,IAAA;AAAA,EAE/C;AACF;"}
|
|
1
|
+
{"version":3,"file":"runner.js","names":[],"sources":["../../src/runner.ts"],"sourcesContent":["/**\n * The reusable \"run an agent CLI inside a sandbox and stream its events out\"\n * primitive. Harness adapters (claude-code, codex, …) spawn their CLI via the\n * uniform {@link SandboxHandle} and consume newline-delimited JSON from stdout,\n * which they then translate into AG-UI StreamChunks.\n *\n * This is intentionally transport-minimal: a stdout NDJSON pipe. Multi-client\n * reconnect / replay belongs to the persistence/EventLog layer, not here.\n *\n * The `journal` option adds a second, opt-in transport: instead of holding the\n * agent's stdout pipe directly, the host redirects it into an append-only file\n * inside the sandbox and tails that file. See `journal.ts` for why (host death\n * cannot SIGPIPE the agent, and a later host can resume the same file from byte\n * 0). `spawnNdjson`'s signature and unjournaled behavior are unchanged; every\n * existing caller keeps working exactly as before.\n */\nimport {\n journalCleanupCommand,\n journalPaths,\n journalStderrReadCommand,\n journaledCommand,\n parseExitSentinel,\n} from './journal'\nimport { readJournal } from './journal-reader'\nimport { decodeBase64Stream } from './journal-bytes'\nimport { awaitAttachableJournal } from './attach-preflight'\nimport type { JournalPaths } from './journal'\nimport type { ProcessOptions, SandboxHandle } from './contracts'\nimport type { RunStore } from '@tanstack/ai'\n\nexport interface SpawnNdjsonOptions extends ProcessOptions {\n /**\n * Called for each raw stdout line that is non-empty but fails JSON parsing\n * (e.g. a CLI banner). Defaults to ignoring it. Stderr is never parsed.\n */\n onNonJsonLine?: (line: string) => void\n /**\n * Written to the process stdin (then stdin is closed) right after spawn —\n * e.g. the agent prompt for `claude -p`. Avoids putting the prompt in argv.\n */\n input?: string\n /**\n * Route the agent's stdout through an in-sandbox journal rather than holding\n * its pipe directly.\n *\n * This is what makes a run survive host death: with nothing piped, there is\n * no reader whose disappearance can SIGPIPE the agent, and a later host reads\n * the same file from byte 0. Opt-in so every existing caller (and every\n * existing test) is unaffected.\n */\n journal?: JournalOptions\n}\n\n/** Journaling configuration for {@link spawnNdjson}. */\nexport interface JournalOptions {\n /** Run id the journal path is derived from. Must match across hosts. */\n runId: string\n /** Journal directory. Defaults to `/tmp/tanstack-runs`. */\n dir?: string\n /**\n * Read an EXISTING journal instead of starting the agent. The read still\n * begins at byte 0 — the alignment step, not the reader, decides what has\n * already been delivered.\n */\n attach?: boolean\n /** Poll interval for providers that cannot follow. */\n pollIntervalMs?: number\n /**\n * Run record store, consulted ONLY on an attach and only when the journal is\n * absent, to tell \"not written yet\" from \"will never be written\" (see\n * `attach-preflight.ts`). Optional so an attach with no store wired keeps the\n * bounded wait while losing the unknown/terminal classification.\n */\n runs?: RunStore\n /**\n * Bounded wait for a live run's journal to appear on an attach, AND — on every\n * path, attach or fresh — the bound on the read's first byte\n * (`ReadJournalOptions.firstByteTimeoutMs`). One knob for both because they\n * bound the same question from two sides: the preflight covers \"the file does\n * not exist\", the read covers \"the file exists but nothing is writing to it\",\n * and a caller that widens one always means to widen the other.\n *\n * Defaults to `DEFAULT_ATTACH_JOURNAL_WAIT_MS`.\n */\n attachWaitMs?: number\n}\n\ntype JournaledOptions = SpawnNdjsonOptions & { journal: JournalOptions }\n\nfunction isJournaled(options: SpawnNdjsonOptions): options is JournaledOptions {\n return options.journal !== undefined\n}\n\nfunction resolvePaths(options: JournaledOptions) {\n return journalPaths(options.journal.runId, options.journal.dir)\n}\n\n/**\n * Strip the runner-only options, leaving what `handle.process.spawn` accepts.\n *\n * `signal` is deliberately KEPT: on the UNJOURNALED path the host holds the\n * agent's stdout pipe, so a client disconnect should take the process down with\n * it. The journaled path must not forward it — see\n * {@link toJournaledSpawnOptions}.\n */\nfunction toProcessOptions(options: SpawnNdjsonOptions): ProcessOptions {\n const { onNonJsonLine, input, journal, ...rest } = options\n void onNonJsonLine\n void input\n void journal\n return rest\n}\n\n/**\n * The journaled agent's spawn options: {@link toProcessOptions} MINUS `signal`.\n *\n * The request's signal must never reach the agent process on this path. Providers\n * DO act on it at spawn time — local-process registers it to `killTree` the\n * process group, and daytona and docker honor it too — so forwarding it means a\n * client disconnect kills the journaled agent. The agent then writes no exit\n * sentinel, and a successor host takes over a run that is already dead: the exact\n * opposite of the guarantee documented on {@link startJournaledAgent}, and of the\n * reason the journal is a file rather than a pipe.\n *\n * The signal is still honored for the READ. `readJournalNdjson` forwards it to\n * `readJournal` and `awaitAttachableJournal` on its own, so a disconnecting\n * client stops tailing immediately. Only the agent spawn outlives the request.\n */\nfunction toJournaledSpawnOptions(options: JournaledOptions): ProcessOptions {\n const { signal, ...rest } = toProcessOptions(options)\n void signal\n return rest\n}\n\n/** Split a stream of arbitrary string chunks into complete lines. */\nexport async function* toLines(\n chunks: AsyncIterable<string>,\n): AsyncIterable<string> {\n let buffer = ''\n for await (const chunk of chunks) {\n buffer += chunk\n let newlineIndex = buffer.indexOf('\\n')\n while (newlineIndex !== -1) {\n const line = buffer.slice(0, newlineIndex)\n buffer = buffer.slice(newlineIndex + 1)\n yield line\n newlineIndex = buffer.indexOf('\\n')\n }\n }\n if (buffer.length > 0) yield buffer\n}\n\n/**\n * Start the agent with its stdout (and the `{\"__exit\":N}` sentinel) redirected\n * into the journal, then return.\n *\n * Deliberately does NOT wait for the process and does NOT read its stdout: the\n * whole point of journaling is that the host holds no handle on the agent's\n * output, so a host that dies mid-run cannot take the agent down with it (no\n * pipe to SIGPIPE). The spawned process is left running in the sandbox; the\n * sentinel line the wrapper appends on exit is how anyone — this host or a\n * successor — learns it finished. Stdin is still written directly to the\n * spawned process, exactly as the unjournaled path does, since that transport\n * is unaffected by where stdout goes.\n */\nexport async function startJournaledAgent(\n handle: SandboxHandle,\n command: string,\n options: JournaledOptions,\n): Promise<void> {\n const paths = resolvePaths(options)\n const proc = await handle.process.spawn(\n journaledCommand(command, paths),\n toJournaledSpawnOptions(options),\n )\n if (options.input !== undefined) {\n await proc.stdin.write(options.input)\n await proc.stdin.end()\n }\n}\n\n/** Chars of stderr attached to a non-zero-exit error, on both paths. */\nconst STDERR_ERROR_CHARS = 1000\n\nasync function* singleValue(value: string): AsyncIterable<string> {\n yield value\n}\n\n/**\n * Read the tail of a run's stderr sidecar, for the error message only.\n *\n * Returns `''` on ANY failure — a provider whose `exec` rejects, a sidecar that\n * no longer exists, a base64 frame the provider truncated. The caller is on its\n * way to throwing the real failure (the agent's non-zero exit), and losing a\n * diagnostic suffix must never replace that error with a cleanup error. Decoding\n * is deliberately lossy: `journalStderrReadCommand` reads the LAST N bytes, so\n * byte 0 of the frame can sit mid-character.\n */\nasync function readStderrTail(\n handle: SandboxHandle,\n paths: JournalPaths,\n): Promise<string> {\n try {\n const result = await handle.process.exec(journalStderrReadCommand(paths))\n const decoder = new TextDecoder()\n let text = ''\n for await (const bytes of decodeBase64Stream(singleValue(result.stdout))) {\n text += decoder.decode(bytes, { stream: true })\n }\n text += decoder.decode()\n return text.trim()\n } catch {\n return ''\n }\n}\n\n/**\n * Delete a terminal run's journal. Best effort by construction: see\n * {@link journalCleanupCommand} for why a failure here cannot be allowed to fail\n * a run that has already finished.\n */\nasync function cleanupJournal(\n handle: SandboxHandle,\n paths: JournalPaths,\n): Promise<void> {\n try {\n await handle.process.exec(journalCleanupCommand(paths))\n } catch {\n // The sandbox may already be gone, `/tmp` may be read-only, the provider's\n // `exec` may reject. Nothing about a completed run depends on the files\n // still existing OR on them being gone, so there is nothing to report.\n }\n}\n\n/**\n * Read a run's journal and yield each line parsed as JSON.\n *\n * Always reads from byte 0 — the alignment step (a later phase), not this\n * reader, decides what a client has already seen. Stops at the `{\"__exit\":N}`\n * sentinel, and throws for a non-zero N so the calling adapter's existing\n * `catch` turns it into a `RUN_ERROR`, the same observable outcome the\n * unjournaled path produces from a non-zero `wait()`. There is nothing to\n * `wait()` on here: the host holds a `tail`, not the agent process, so the\n * sentinel line IS the exit code.\n *\n * The sentinel is also what bounds journal growth: reaching it means the run is\n * terminal, and a terminal run's record is the event log, so both journal files\n * are deleted before this iterable finishes. The ordering below is load-bearing\n * and is asserted, not merely commented:\n *\n * - The sentinel is captured and the loop is `break`-ed, so the source's\n * `finally` kills the `tail` BEFORE the `rm` runs — the reader is stopped, then\n * its input is deleted, never the other way round.\n * - `exitCode` stays `undefined` if the journal stream ends without a sentinel.\n * NOTHING is deleted on that path either way — the run may be mid-flight and a\n * successor host may still need every byte — but the two causes are then\n * separated by `options.signal.aborted`: an aborted consumer returns quietly,\n * while a stream that died on its own (killed `tail`, destroyed sandbox, torn\n * pipe) THROWS. Returning for both is how a truncated read used to reach the\n * client as a normally-completing run.\n * - A non-zero sentinel deletes too. The run is terminal either way.\n * - The stderr sidecar is read BEFORE the deletion that destroys it, so a\n * non-zero exit carries up to {@link STDERR_ERROR_CHARS} chars of the agent's\n * own diagnostics, exactly as the unjournaled path below does. That closes the\n * \"Known regression\" this function used to document; the read is bounded and\n * failure-swallowing (see {@link readStderrTail}), so it cannot turn a run\n * failure into a cleanup failure.\n *\n * On an ATTACH (`journal.attach === true`) the read is preceded by\n * {@link awaitAttachableJournal}, which fails fast for a runId the store does not\n * know or has already terminalized and otherwise waits a BOUNDED time for a live\n * run's journal to appear. Without it, an attach to a runId with no journal\n * created an empty one (`journalFollowCommand` does that deliberately) and tailed\n * it forever — no sentinel, no error, no timeout.\n *\n * One case this does NOT bound: a run that reaches its sentinel while DETACHED\n * has no host reading it, so nothing observes the sentinel and nothing here\n * runs. Sweeping those is `pruneJournals`' job (`journal-sweep.ts`): it consults\n * the run store's status for each journal it finds and deletes only the terminal\n * ones, from a cron the application schedules rather than from a run.\n */\nexport async function* readJournalNdjson(\n handle: SandboxHandle,\n options: JournaledOptions,\n): AsyncIterable<unknown> {\n const paths = resolvePaths(options)\n // ATTACH ONLY, and before the first read. `journalFollowCommand` CREATES the\n // journal it tails, so an attach for a runId that never had one would\n // otherwise create an empty file and tail it forever with no sentinel ever\n // arriving. A fresh run must not be gated: its journal is created by its own\n // `journaledCommand` spawn, which `spawnNdjson` has just issued.\n if (options.journal.attach === true) {\n await awaitAttachableJournal(handle, {\n paths,\n runId: options.journal.runId,\n ...(options.journal.runs === undefined\n ? {}\n : { runs: options.journal.runs }),\n ...(options.journal.attachWaitMs === undefined\n ? {}\n : { waitMs: options.journal.attachWaitMs }),\n ...(options.signal === undefined ? {} : { signal: options.signal }),\n })\n }\n let exitCode: number | undefined\n for await (const { line } of readJournal(handle, {\n paths,\n fromByte: 0,\n runId: options.journal.runId,\n ...(options.signal === undefined ? {} : { signal: options.signal }),\n ...(options.journal.pollIntervalMs === undefined\n ? {}\n : { pollIntervalMs: options.journal.pollIntervalMs }),\n ...(options.journal.attachWaitMs === undefined\n ? {}\n : { firstByteTimeoutMs: options.journal.attachWaitMs }),\n })) {\n const trimmed = line.trim()\n if (trimmed === '') continue\n // The sentinel test comes FIRST and is nonce-checked, so an agent line that\n // merely looks like a sentinel is delivered as the event it is instead of\n // truncating the run (see `parseExitSentinel`).\n const sentinel = parseExitSentinel(trimmed, paths)\n if (sentinel !== null) {\n exitCode = sentinel\n break\n }\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n } catch {\n options.onNonJsonLine?.(trimmed)\n continue\n }\n yield parsed\n }\n\n // The stream ended with no sentinel. Two very different causes share this\n // shape, and collapsing them is how a torn-down read used to be recorded as a\n // short but SUCCESSFUL run:\n //\n // - The CONSUMER aborted (lease lost, client gone, host shutting down). Not a\n // failure and not terminal: return, having deleted nothing, because a\n // successor host may still need every byte. `pipeToRunLog`'s post-loop check\n // turns this into `'aborted'`.\n // - The read DIED (the `tail` was killed, the sandbox was destroyed, the pipe\n // was torn down). The iterable ends without an error, so returning here made\n // the adapter emit a normally-completing but silently TRUNCATED run — in a\n // function whose documented job is to throw so the adapter converts it to a\n // `RUN_ERROR`. `signal.aborted` is what tells the two apart, and the throw\n // matches the shape the unjournaled path already uses for a bad exit.\n if (exitCode === undefined) {\n if (options.signal?.aborted === true) return\n throw new Error(\n `Agent journal stream for run ${options.journal.runId} ended without an exit sentinel ` +\n `(${paths.journal}). The run was NOT observed to finish: the tail was torn down, ` +\n `the sandbox went away, or the agent's shell died before writing its sentinel. ` +\n `Both journal files are left in place for a successor host.`,\n )\n }\n\n const stderr = exitCode === 0 ? '' : await readStderrTail(handle, paths)\n await cleanupJournal(handle, paths)\n if (exitCode !== 0) {\n throw new Error(\n `Agent process exited with code ${exitCode}` +\n (stderr ? `: ${stderr.slice(0, STDERR_ERROR_CHARS)}` : ''),\n )\n }\n}\n\n/**\n * Spawn `command` in the sandbox and yield each stdout line parsed as JSON.\n *\n * Without `options.journal`, behavior is byte-identical to before: resolves\n * the spawn handle's exit via `wait()` after stdout closes; a non-zero exit\n * with no events surfaced is the adapter's concern to detect.\n *\n * With `options.journal`, the agent's stdout is redirected into an in-sandbox\n * journal (unless `journal.attach` is set, meaning a run already in flight)\n * and then read back from byte 0 — one code path for a fresh run and an\n * attach, both going through {@link readJournalNdjson}.\n */\nexport async function* spawnNdjson(\n handle: SandboxHandle,\n command: string,\n options: SpawnNdjsonOptions = {},\n): AsyncIterable<unknown> {\n if (isJournaled(options)) {\n if (options.journal.attach !== true) {\n await startJournaledAgent(handle, command, options)\n }\n yield* readJournalNdjson(handle, options)\n return\n }\n\n const { onNonJsonLine, input, ...processOptions } = options\n const proc = await handle.process.spawn(command, processOptions)\n\n if (input !== undefined) {\n await proc.stdin.write(input)\n await proc.stdin.end()\n }\n\n // Drain stderr concurrently. A CLI that fails before producing stdout (a\n // broken install, an auth/permission refusal, …) prints to stderr and exits\n // non-zero; without this, stdout-only parsing yields nothing and the failure\n // vanishes. Capturing it lets us surface the cause below.\n const stderrChunks: Array<string> = []\n const stderrDrained = (async () => {\n try {\n for await (const chunk of proc.stderr) stderrChunks.push(chunk)\n } catch {\n // stderr stream torn down — use whatever was captured\n }\n })()\n\n for await (const line of toLines(proc.stdout)) {\n const trimmed = line.trim()\n if (trimmed === '') continue\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n } catch {\n onNonJsonLine?.(trimmed)\n continue\n }\n yield parsed\n }\n\n const exitCode = await proc.wait()\n await stderrDrained\n // A non-zero exit means the agent CLI itself failed. Throw so the adapter's\n // catch turns it into a RUN_ERROR the UI can show, instead of ending the\n // stream silently with no events.\n if (exitCode !== 0) {\n const stderr = stderrChunks.join('').trim()\n throw new Error(\n `Agent process exited with code ${exitCode}` +\n (stderr ? `: ${stderr.slice(0, STDERR_ERROR_CHARS)}` : ''),\n )\n }\n}\n"],"mappings":"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"}
|
package/dist/esm/sandbox.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { LockStore } from '@tanstack/ai/locks';
|
|
1
2
|
import { SandboxFileHookEvent } from '@tanstack/ai';
|
|
3
|
+
import { SandboxInstanceStore } from './instance-store.js';
|
|
2
4
|
import { SandboxHandle, SandboxProvider } from './contracts.js';
|
|
3
|
-
import { LockStore, SandboxStore } from './store.js';
|
|
4
5
|
import { SandboxPolicy } from './policy.js';
|
|
5
6
|
import { WorkspaceDefinition } from './workspace.js';
|
|
6
7
|
/**
|
|
@@ -53,7 +54,7 @@ export interface SandboxEnsureContext {
|
|
|
53
54
|
threadId: string;
|
|
54
55
|
runId: string;
|
|
55
56
|
/** Persistence seam; falls back to an in-memory store when absent. */
|
|
56
|
-
store?:
|
|
57
|
+
store?: SandboxInstanceStore;
|
|
57
58
|
/** Lock seam; falls back to an in-memory lock when absent. */
|
|
58
59
|
locks?: LockStore;
|
|
59
60
|
tenant?: {
|