@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/src/align.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay-from-zero with log alignment: the mechanism that makes a resumed
|
|
3
|
+
* journal read idempotent.
|
|
4
|
+
*
|
|
5
|
+
* A host translates journal bytes 0..1000 and appends the resulting chunks, then
|
|
6
|
+
* dies. A successor re-reads the journal **from byte 0** and re-translates it,
|
|
7
|
+
* producing the same chunks again. This transform reads what is already in the
|
|
8
|
+
* event log, verifies that the replay reproduces it, suppresses that prefix, and
|
|
9
|
+
* passes only the remainder downstream to be appended.
|
|
10
|
+
*
|
|
11
|
+
* Why this shape rather than the offset-upsert the design sketched:
|
|
12
|
+
*
|
|
13
|
+
* - `StreamDurability.append` does not accept caller-supplied offsets, and
|
|
14
|
+
* `UpsertableStreamDurability.upsert` is deliberately **not** used here:
|
|
15
|
+
* `memoryStream.upsert` rejects any offset it did not mint itself, and
|
|
16
|
+
* `durableStream` has no `upsert` at all (its offsets embed a
|
|
17
|
+
* backend-assigned cursor). The journal path therefore only ever *appends*,
|
|
18
|
+
* and this function's whole job is deciding where that append starts. Do not
|
|
19
|
+
* "simplify" it into an `upsert` — the recommended production adapter cannot
|
|
20
|
+
* accept one.
|
|
21
|
+
* - Even if it could, re-translation is only reproducible because
|
|
22
|
+
* `createRunScopedIdGen` makes it so. The dedupe boundary therefore has to be
|
|
23
|
+
* *derived from the log*, not tracked beside it — which also means there is no
|
|
24
|
+
* window in which a checkpoint and the log can disagree, because the log is
|
|
25
|
+
* the checkpoint.
|
|
26
|
+
* - The log stays append-only with strictly increasing offsets. That is what
|
|
27
|
+
* `durableStream`'s backend enforces and what the client's offset de-dup
|
|
28
|
+
* (`ai-client`'s `seen` set) relies on — the client is NOT tolerant of a
|
|
29
|
+
* duplicated text or tool-argument delta.
|
|
30
|
+
*
|
|
31
|
+
* Divergence is a bug, not a condition to recover from, so it throws.
|
|
32
|
+
*/
|
|
33
|
+
import { EventType } from '@tanstack/ai'
|
|
34
|
+
import {
|
|
35
|
+
chunkFingerprint,
|
|
36
|
+
chunkFingerprintIgnoringThreadId,
|
|
37
|
+
chunkThreadId,
|
|
38
|
+
} from './chunk-identity'
|
|
39
|
+
import type { InternalLogger } from '@tanstack/ai/adapter-internals'
|
|
40
|
+
import type { StreamChunk, StreamDurability } from '@tanstack/ai'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Default bound on consecutive stored chunks alignment will skip as out-of-band.
|
|
44
|
+
*
|
|
45
|
+
* A bound is what keeps this a tolerance rather than a search. Unbounded, a
|
|
46
|
+
* genuine determinism regression would make alignment scan forward through the
|
|
47
|
+
* whole log looking for a fingerprint that happens to match, suppress
|
|
48
|
+
* everything it passed, and deliver a stream whose prefix and suffix disagree —
|
|
49
|
+
* the exact failure {@link JournalReplayDivergedError} exists to prevent. 64 is
|
|
50
|
+
* well above any realistic burst of bridged console events between two
|
|
51
|
+
* translated chunks and well below a log length where a false match becomes
|
|
52
|
+
* plausible.
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_MAX_OUT_OF_BAND_SKIP = 64
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The out-of-band predicate for the harness adapters.
|
|
58
|
+
*
|
|
59
|
+
* `ai-codex` and `ai-claude-code` splice `createBridgeEventChannel`'s stream
|
|
60
|
+
* into their translated output with `mergeChunkStreams`. That channel is the
|
|
61
|
+
* only producer on the path and it emits exclusively `EventType.CUSTOM` chunks
|
|
62
|
+
* (`bridge-events.ts:53-63`), fired by LIVE bridged-tool execution. A replay
|
|
63
|
+
* runs no tools, so those chunks exist in the log and not in the replay.
|
|
64
|
+
*
|
|
65
|
+
* Structural rather than a list of event names on purpose: a new bridged tool
|
|
66
|
+
* inventing a new `name` must not silently reintroduce the divergence.
|
|
67
|
+
*/
|
|
68
|
+
export function isBridgeCustomChunk(chunk: StreamChunk): boolean {
|
|
69
|
+
return chunk.type === EventType.CUSTOM
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The replay produced a different chunk than the log already holds at that
|
|
74
|
+
* index. Means translation stopped being deterministic — a `genId` that is not
|
|
75
|
+
* run-scoped, a translator that consults the clock, or a journal that was
|
|
76
|
+
* rewritten. Fail loud: suppressing the mismatch would deliver a stream whose
|
|
77
|
+
* prefix and suffix disagree about message identity.
|
|
78
|
+
*/
|
|
79
|
+
export class JournalReplayDivergedError extends Error {
|
|
80
|
+
constructor(
|
|
81
|
+
readonly index: number,
|
|
82
|
+
readonly stored: string,
|
|
83
|
+
readonly replayed: string,
|
|
84
|
+
) {
|
|
85
|
+
super(
|
|
86
|
+
`journal replay diverged at index ${index}: stored ${stored} but replayed ${replayed}`,
|
|
87
|
+
)
|
|
88
|
+
this.name = 'JournalReplayDivergedError'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The replay reproduced the stored chunk EXACTLY except for its `threadId`.
|
|
94
|
+
*
|
|
95
|
+
* A distinct diagnosis because the cause and the fix are entirely different from
|
|
96
|
+
* a real divergence. The adapters resolve `threadId` as
|
|
97
|
+
* `options.threadId ?? this.generateId()`, and that id lands in every emitted
|
|
98
|
+
* chunk — so an attach route that drives a run without passing the run record's
|
|
99
|
+
* `threadId` mints a fresh one, and the very first chunk (`RUN_STARTED`) fails
|
|
100
|
+
* alignment. The agent behaved identically; only the id moved. Reported as a
|
|
101
|
+
* generic divergence, that sends the reader hunting for non-determinism in the
|
|
102
|
+
* translator, which is the wrong place entirely.
|
|
103
|
+
*
|
|
104
|
+
* A SUBCLASS of {@link JournalReplayDivergedError}, deliberately: this is still a
|
|
105
|
+
* divergence and still fatal, so a consumer already branching on the general
|
|
106
|
+
* class keeps working. The two are not collapsed — a genuine content divergence
|
|
107
|
+
* throws the base class, so `instanceof JournalReplayThreadIdMismatchError`
|
|
108
|
+
* separates a config mistake from a determinism bug in exactly one check.
|
|
109
|
+
*/
|
|
110
|
+
export class JournalReplayThreadIdMismatchError extends JournalReplayDivergedError {
|
|
111
|
+
constructor(
|
|
112
|
+
index: number,
|
|
113
|
+
stored: string,
|
|
114
|
+
replayed: string,
|
|
115
|
+
readonly storedThreadId: string | undefined,
|
|
116
|
+
readonly replayedThreadId: string | undefined,
|
|
117
|
+
) {
|
|
118
|
+
super(index, stored, replayed)
|
|
119
|
+
this.name = 'JournalReplayThreadIdMismatchError'
|
|
120
|
+
this.message =
|
|
121
|
+
`journal replay diverged at index ${index} ONLY by threadId: stored ${JSON.stringify(storedThreadId)} but replayed ${JSON.stringify(replayedThreadId)}. ` +
|
|
122
|
+
`Every other field of the chunk is identical, so the agent did NOT behave differently — the attaching run generated a new threadId instead of reusing the run record's. ` +
|
|
123
|
+
`Pass the run record's threadId (RunRecord.threadId, which sandboxRunDriver hands to drive({ runId, threadId, signal })) into chat() on the attach route; ` +
|
|
124
|
+
`without it the adapter falls back to generateId() and every chunk carries an id the stored log cannot match.`
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Classify a mismatch before throwing.
|
|
130
|
+
*
|
|
131
|
+
* The `threadId`-only case is recognized by comparing the two chunks a SECOND
|
|
132
|
+
* time with `threadId` excluded: equal there and unequal under the real
|
|
133
|
+
* fingerprint means `threadId` is the only field that moved. Cheap, because it
|
|
134
|
+
* runs only on the failure path, and precise, because it is derived from the same
|
|
135
|
+
* fingerprint function rather than a hand-written field diff.
|
|
136
|
+
*/
|
|
137
|
+
function divergenceError(
|
|
138
|
+
index: number,
|
|
139
|
+
storedChunk: StreamChunk,
|
|
140
|
+
replayedChunk: StreamChunk,
|
|
141
|
+
stored: string,
|
|
142
|
+
replayed: string,
|
|
143
|
+
): JournalReplayDivergedError {
|
|
144
|
+
const storedThreadId = chunkThreadId(storedChunk)
|
|
145
|
+
const replayedThreadId = chunkThreadId(replayedChunk)
|
|
146
|
+
if (
|
|
147
|
+
storedThreadId !== replayedThreadId &&
|
|
148
|
+
chunkFingerprintIgnoringThreadId(storedChunk) ===
|
|
149
|
+
chunkFingerprintIgnoringThreadId(replayedChunk)
|
|
150
|
+
) {
|
|
151
|
+
return new JournalReplayThreadIdMismatchError(
|
|
152
|
+
index,
|
|
153
|
+
stored,
|
|
154
|
+
replayed,
|
|
155
|
+
storedThreadId,
|
|
156
|
+
replayedThreadId,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
return new JournalReplayDivergedError(index, stored, replayed)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface AlignToStoredLogOptions<TOffset extends string = string> {
|
|
163
|
+
/**
|
|
164
|
+
* The run's event log. Read from the beginning; never written here.
|
|
165
|
+
*
|
|
166
|
+
* Generic in the offset type, defaulted to `string`, for the same reason
|
|
167
|
+
* {@link RunDeps} is: a branded-cursor backend's `StreamDurability<TOffset>`
|
|
168
|
+
* is not assignable to `StreamDurability<string>`.
|
|
169
|
+
*
|
|
170
|
+
* Narrowed to `snapshot` — the only member this transform touches, as the
|
|
171
|
+
* function docs below spell out — so the capability-bus view of a log
|
|
172
|
+
* (`SandboxDurabilityLog`, which omits the offset-invariant `read`) can be
|
|
173
|
+
* passed straight through by `alignedIfAttaching`. A full `StreamDurability`
|
|
174
|
+
* still satisfies it, so no existing caller changes.
|
|
175
|
+
*/
|
|
176
|
+
durability: Pick<StreamDurability<TOffset>, 'snapshot'>
|
|
177
|
+
/** Optional sink for the alignment summary. */
|
|
178
|
+
logger?: InternalLogger
|
|
179
|
+
/**
|
|
180
|
+
* Recognizes a stored chunk that the replay CANNOT reproduce, so alignment
|
|
181
|
+
* skips it instead of throwing.
|
|
182
|
+
*
|
|
183
|
+
* Absent by default, which keeps strict positional comparison: any stored
|
|
184
|
+
* chunk the replay does not produce is a determinism bug and fails loudly.
|
|
185
|
+
* Pass {@link isBridgeCustomChunk} on the harness attach path, where the
|
|
186
|
+
* previous host spliced live bridged-tool events into the log.
|
|
187
|
+
*
|
|
188
|
+
* The predicate is applied to the STORED chunk, never to the replayed one. A
|
|
189
|
+
* skipped entry is suppressed, not re-appended, so the client's view is
|
|
190
|
+
* unchanged: it already received that chunk under its own offset.
|
|
191
|
+
*/
|
|
192
|
+
isOutOfBand?: (chunk: StreamChunk) => boolean
|
|
193
|
+
/**
|
|
194
|
+
* Maximum CONSECUTIVE stored chunks that may be skipped as out-of-band before
|
|
195
|
+
* alignment gives up. Reset by every match. Defaults to
|
|
196
|
+
* {@link DEFAULT_MAX_OUT_OF_BAND_SKIP}. Ignored when `isOutOfBand` is absent.
|
|
197
|
+
*/
|
|
198
|
+
maxOutOfBandSkip?: number
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Suppress the chunks already present in the event log and yield the rest.
|
|
203
|
+
*
|
|
204
|
+
* The stored prefix is read exactly once, eagerly, before the first replay
|
|
205
|
+
* chunk is pulled. Both halves of that matter:
|
|
206
|
+
*
|
|
207
|
+
* - **Exactly once**, because a second read mid-stream would race the appends
|
|
208
|
+
* the caller is making downstream of this transform and could classify a
|
|
209
|
+
* chunk this very run just appended as an already-stored one, dropping it.
|
|
210
|
+
* - **Via `snapshot()`, never `read()`**. `read` *tails*: it returns only when
|
|
211
|
+
* the log is terminalized with `close()` or the caller aborts. A takeover's
|
|
212
|
+
* log is open by definition — the host that would have closed it is the host
|
|
213
|
+
* that died — so `for await (… of read('-1'))` would never finish, and on an
|
|
214
|
+
* empty log `memoryStream` rejects a from-start join outright once its
|
|
215
|
+
* first-chunk deadline elapses. `snapshot()` is the bounded read: it resolves
|
|
216
|
+
* with what is stored right now, including while the log is still open, and
|
|
217
|
+
* resolves to `[]` for a run with nothing stored.
|
|
218
|
+
*/
|
|
219
|
+
export async function* alignToStoredLog<TOffset extends string = string>(
|
|
220
|
+
chunks: AsyncIterable<StreamChunk>,
|
|
221
|
+
options: AlignToStoredLogOptions<TOffset>,
|
|
222
|
+
): AsyncIterable<StreamChunk> {
|
|
223
|
+
const entries = await options.durability.snapshot()
|
|
224
|
+
const stored = entries.map((entry) => chunkFingerprint(entry.chunk))
|
|
225
|
+
|
|
226
|
+
const isOutOfBand = options.isOutOfBand
|
|
227
|
+
const maxSkip = options.maxOutOfBandSkip ?? DEFAULT_MAX_OUT_OF_BAND_SKIP
|
|
228
|
+
|
|
229
|
+
let cursor = 0
|
|
230
|
+
let suppressed = 0
|
|
231
|
+
let skipped = 0
|
|
232
|
+
let forwarded = 0
|
|
233
|
+
|
|
234
|
+
for await (const chunk of chunks) {
|
|
235
|
+
// Past the end of the stored log: everything from here is new.
|
|
236
|
+
if (cursor >= stored.length) {
|
|
237
|
+
forwarded += 1
|
|
238
|
+
yield chunk
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const actual = chunkFingerprint(chunk)
|
|
243
|
+
let consecutiveSkips = 0
|
|
244
|
+
for (;;) {
|
|
245
|
+
// `entries` and `stored` are the same length by construction; both are
|
|
246
|
+
// bound because the predicate needs the CHUNK while the comparison needs
|
|
247
|
+
// its fingerprint.
|
|
248
|
+
const entry = entries[cursor]
|
|
249
|
+
const expected = stored[cursor]
|
|
250
|
+
if (entry === undefined || expected === undefined) {
|
|
251
|
+
forwarded += 1
|
|
252
|
+
yield chunk
|
|
253
|
+
break
|
|
254
|
+
}
|
|
255
|
+
if (expected === actual) {
|
|
256
|
+
cursor += 1
|
|
257
|
+
suppressed += 1
|
|
258
|
+
break
|
|
259
|
+
}
|
|
260
|
+
// Mismatch. Only a stored chunk the replay provably cannot reproduce may
|
|
261
|
+
// be skipped, and only `maxSkip` of them in a row.
|
|
262
|
+
if (isOutOfBand === undefined || !isOutOfBand(entry.chunk)) {
|
|
263
|
+
throw divergenceError(cursor, entry.chunk, chunk, expected, actual)
|
|
264
|
+
}
|
|
265
|
+
if (consecutiveSkips >= maxSkip) {
|
|
266
|
+
throw divergenceError(cursor, entry.chunk, chunk, expected, actual)
|
|
267
|
+
}
|
|
268
|
+
cursor += 1
|
|
269
|
+
consecutiveSkips += 1
|
|
270
|
+
skipped += 1
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Trailing stored entries. Out-of-band ones are expected (a bridged tool's
|
|
275
|
+
// last event lands after the final translated chunk); anything else means the
|
|
276
|
+
// journal no longer accounts for chunks the log already delivered, which
|
|
277
|
+
// nothing downstream can repair.
|
|
278
|
+
while (cursor < stored.length) {
|
|
279
|
+
const entry = entries[cursor]
|
|
280
|
+
if (
|
|
281
|
+
entry === undefined ||
|
|
282
|
+
isOutOfBand === undefined ||
|
|
283
|
+
!isOutOfBand(entry.chunk)
|
|
284
|
+
) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`journal replay is shorter than the stored log: ${stored.length - cursor} stored chunk(s) from index ${cursor} were not reproduced`,
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
cursor += 1
|
|
290
|
+
skipped += 1
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
options.logger?.provider(
|
|
294
|
+
`journal alignment: suppressed ${suppressed} stored chunk(s), skipped ${skipped} out-of-band, forwarded ${forwarded}`,
|
|
295
|
+
{ suppressed, skipped, forwarded },
|
|
296
|
+
)
|
|
297
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate that turns a HOPELESS attach into an error instead of an infinite
|
|
3
|
+
* wait.
|
|
4
|
+
*
|
|
5
|
+
* `journalFollowCommand` creates the journal before tailing it (`: >> file`),
|
|
6
|
+
* because `tail -f` on a missing path prints a diagnostic and EXITS rather than
|
|
7
|
+
* waiting — a defect that made a legitimate attach racing the driver's first
|
|
8
|
+
* write deliver zero lines. But creating the file has a cost: an attach for a
|
|
9
|
+
* `runId` that never had a journal creates an EMPTY one and tails it forever. No
|
|
10
|
+
* `{"__exit":N}` sentinel can ever arrive, so the caller waits indefinitely with
|
|
11
|
+
* no error, no timeout, and no log line — for what is the single most likely
|
|
12
|
+
* mistake on this path (a stale link, a typo, a run whose journal was cleaned up
|
|
13
|
+
* after completing).
|
|
14
|
+
*
|
|
15
|
+
* Absence of the journal alone cannot decide the question, which is exactly why
|
|
16
|
+
* `: >> file` exists: "not written YET" and "will never be written" look
|
|
17
|
+
* identical on the filesystem. The RUN RECORD is what distinguishes them, and it
|
|
18
|
+
* is authoritative — `runs.get(runId)` says whether the run exists at all,
|
|
19
|
+
* whether it is terminal, and (via `detachedSince`) whether anyone is expected
|
|
20
|
+
* to be driving it. So the policy is:
|
|
21
|
+
*
|
|
22
|
+
* | journal | record | decision |
|
|
23
|
+
* | --------- | ----------------------- | ------------------------------------- |
|
|
24
|
+
* | exists | (not consulted) | attach, under the reader's own bound |
|
|
25
|
+
* | absent | unknown (`null`) | fail fast, `'unknown-run'` |
|
|
26
|
+
* | absent | terminal | fail fast, `'terminal-run'` |
|
|
27
|
+
* | absent | running / interrupted | BOUNDED wait, then `'journal-timeout'`|
|
|
28
|
+
* | unusable | running / interrupted | BOUNDED wait, then `'journal-timeout'`|
|
|
29
|
+
*
|
|
30
|
+
* Four deliberate choices in that table:
|
|
31
|
+
*
|
|
32
|
+
* 1. **An existing journal short-circuits this gate**, before the store is read
|
|
33
|
+
* at all — because gating it would make a perfectly readable journal
|
|
34
|
+
* unreadable whenever a store lost its record. It does NOT mean the read is
|
|
35
|
+
* unbounded: this module used to justify the short-circuit with "a journal
|
|
36
|
+
* that exists either carries a sentinel or is still being appended to, neither
|
|
37
|
+
* hangs", and that trichotomy was FALSE. `journalFollowCommand`'s first act is
|
|
38
|
+
* `: >> file`, so the reader itself manufactures the third state — a file that
|
|
39
|
+
* exists, receives nothing, and can never receive a sentinel — and the same
|
|
40
|
+
* state is independently reachable by SIGKILL/OOM of the agent's shell before
|
|
41
|
+
* its `printf`. The bound for it lives where it belongs, on the read:
|
|
42
|
+
* `journal-reader.ts` fails a follow/poll that receives no bytes at all within
|
|
43
|
+
* {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS} with `'journal-stalled'`.
|
|
44
|
+
* 2. **A terminal record with no journal fails rather than waiting.** Nothing
|
|
45
|
+
* will ever be appended: the run is over and `journalCleanupCommand` deletes a
|
|
46
|
+
* terminal run's files by design. Its transcript lives in the event log, which
|
|
47
|
+
* the resume response serves independently of this path.
|
|
48
|
+
* 3. **A live or detached record waits, but not forever.** A driver that has
|
|
49
|
+
* claimed the run and not yet written its first line is the normal case, not
|
|
50
|
+
* the unlucky one, so failing fast here would break the very race
|
|
51
|
+
* `journalFollowCommand` was fixed to tolerate. `detachedSince` does NOT
|
|
52
|
+
* change the decision — a detached run's journal is exactly what a successor
|
|
53
|
+
* is supposed to read, and a driver that died before its first write leaves an
|
|
54
|
+
* identical filesystem state — but it IS reported in the timeout message,
|
|
55
|
+
* since "detached with no journal after N ms" and "attached with no journal
|
|
56
|
+
* after N ms" point at different causes.
|
|
57
|
+
* 4. **An UNUSABLE probe falls through to the bounded wait; it does not skip the
|
|
58
|
+
* gate.** This module used to fail open here — `if (existence === 'unknown')
|
|
59
|
+
* return` — on the reasoning that a diagnostic gate must not break an attach
|
|
60
|
+
* that would otherwise have worked. That reasoning inverted the actual risk.
|
|
61
|
+
* Returning handed control to a reader whose very first act CREATES the
|
|
62
|
+
* journal (`journalFollowCommand`'s `: >> file`) and then tails it forever, so
|
|
63
|
+
* the fail-open path did not preserve a working attach — it manufactured the
|
|
64
|
+
* exact infinite wait this module exists to prevent. Worse, it was
|
|
65
|
+
* self-perpetuating: the file it created made `test -f` succeed from then on,
|
|
66
|
+
* so every LATER attach short-circuited at choice 1 and hung too, permanently,
|
|
67
|
+
* long after the transient probe failure had cleared. An unanswerable probe is
|
|
68
|
+
* precisely when a deadline matters most, so an unusable probe is re-polled
|
|
69
|
+
* (it may recover) and, failing that, times out. The store checks still run
|
|
70
|
+
* first and need no probe, so an unknown or terminal `runId` still fails fast.
|
|
71
|
+
*/
|
|
72
|
+
import { isTerminalRunStatus } from '@tanstack/ai'
|
|
73
|
+
import { journalExistsCommand } from './journal'
|
|
74
|
+
import type { JournalPaths } from './journal'
|
|
75
|
+
import type { SandboxHandle } from './contracts'
|
|
76
|
+
import type { InternalLogger } from '@tanstack/ai/adapter-internals'
|
|
77
|
+
import type { RunRecord, RunStore } from '@tanstack/ai'
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* How long an attach waits for a live run's journal to appear before failing.
|
|
81
|
+
*
|
|
82
|
+
* User-relevant, hence exported: this bounds how long an attach REQUEST can sit
|
|
83
|
+
* before it answers, so an application that fronts the attach route with its own
|
|
84
|
+
* timeout needs to know the number. Generous relative to the gap between a
|
|
85
|
+
* driver claiming a run and its first journal write (a `spawn` plus one line),
|
|
86
|
+
* and short relative to any sane HTTP timeout. Override per run with
|
|
87
|
+
* `SandboxDurabilityOptions.attachWaitMs`.
|
|
88
|
+
*/
|
|
89
|
+
export const DEFAULT_ATTACH_JOURNAL_WAIT_MS = 10_000
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* How often the bounded wait re-probes for the journal. Not user-facing: it
|
|
93
|
+
* trades a `test -f` per interval for attach latency, and neither number is
|
|
94
|
+
* something an application tunes.
|
|
95
|
+
*/
|
|
96
|
+
export const DEFAULT_ATTACH_PROBE_INTERVAL_MS = 100
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Which of the three hopeless-attach cases was hit. Exported so a consumer can
|
|
100
|
+
* branch (a 404 for `'unknown-run'`, a 410 for `'terminal-run'`, a 504 for
|
|
101
|
+
* `'journal-timeout'`) instead of matching on message text.
|
|
102
|
+
*/
|
|
103
|
+
export type AttachUnavailableReason =
|
|
104
|
+
| 'unknown-run'
|
|
105
|
+
| 'terminal-run'
|
|
106
|
+
| 'journal-timeout'
|
|
107
|
+
/**
|
|
108
|
+
* The journal EXISTS but produced no bytes at all within the deadline, so no
|
|
109
|
+
* sentinel can be coming and the follow would tail an empty (or abandoned)
|
|
110
|
+
* file forever. Raised by `journal-reader.ts`, not by the preflight: the
|
|
111
|
+
* preflight cannot see this state, because `test -f` succeeds for it.
|
|
112
|
+
*
|
|
113
|
+
* A 504 at an attach route, exactly like `'journal-timeout'`, which is why it
|
|
114
|
+
* shares {@link JournalAttachUnavailableError} — but a distinct value, because
|
|
115
|
+
* the cause is different: `'journal-timeout'` means nobody created the
|
|
116
|
+
* journal, `'journal-stalled'` means somebody did and then stopped (a
|
|
117
|
+
* SIGKILLed agent shell, a destroyed sandbox, a reader that created the file
|
|
118
|
+
* itself on a fail-open path).
|
|
119
|
+
*/
|
|
120
|
+
| 'journal-stalled'
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* An attach cannot succeed, and waiting longer would not change that.
|
|
124
|
+
*
|
|
125
|
+
* One class with a {@link AttachUnavailableReason} discriminant rather than three
|
|
126
|
+
* classes: every consumer of this path handles all three cases at the same seam
|
|
127
|
+
* (the attach route), so one `instanceof` plus a `switch (error.reason)` is the
|
|
128
|
+
* shape that is actually written, while the message names the specific case for a
|
|
129
|
+
* human reading a log.
|
|
130
|
+
*/
|
|
131
|
+
export class JournalAttachUnavailableError extends Error {
|
|
132
|
+
constructor(
|
|
133
|
+
readonly runId: string,
|
|
134
|
+
readonly reason: AttachUnavailableReason,
|
|
135
|
+
detail: string,
|
|
136
|
+
) {
|
|
137
|
+
super(`cannot attach to run ${runId}: ${detail}`)
|
|
138
|
+
this.name = 'JournalAttachUnavailableError'
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Existence of the journal, or `'unknown'` when the probe itself failed. */
|
|
143
|
+
type JournalExistence = 'yes' | 'no' | 'unknown'
|
|
144
|
+
|
|
145
|
+
export interface AwaitAttachableJournalOptions {
|
|
146
|
+
/** The run's journal paths, as {@link journalPaths} derived them. */
|
|
147
|
+
paths: JournalPaths
|
|
148
|
+
/** Run id, for the store lookup and the error messages. */
|
|
149
|
+
runId: string
|
|
150
|
+
/**
|
|
151
|
+
* The authoritative run record store. Omitted only by a caller with no store
|
|
152
|
+
* wired, which loses the unknown/terminal classification but keeps the bound.
|
|
153
|
+
*/
|
|
154
|
+
runs?: RunStore
|
|
155
|
+
/** Bounded wait. Defaults to {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS}. */
|
|
156
|
+
waitMs?: number
|
|
157
|
+
/** Re-probe interval. Defaults to {@link DEFAULT_ATTACH_PROBE_INTERVAL_MS}. */
|
|
158
|
+
probeIntervalMs?: number
|
|
159
|
+
/**
|
|
160
|
+
* The consumer's abort. An aborted wait returns rather than throwing: the
|
|
161
|
+
* caller stopped caring, which is not a diagnosis about the run.
|
|
162
|
+
*/
|
|
163
|
+
signal?: AbortSignal
|
|
164
|
+
logger?: InternalLogger
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Shell `test -f`, never `handle.fs.exists` — `journal.ts` rule 3: on
|
|
169
|
+
* local-process the two resolve `/tmp` differently, so `fs.exists` would probe a
|
|
170
|
+
* path the journal was never written to and report `false` for every run.
|
|
171
|
+
*/
|
|
172
|
+
async function probeJournal(
|
|
173
|
+
handle: SandboxHandle,
|
|
174
|
+
options: AwaitAttachableJournalOptions,
|
|
175
|
+
): Promise<JournalExistence> {
|
|
176
|
+
try {
|
|
177
|
+
const result = await handle.process.exec(
|
|
178
|
+
journalExistsCommand(options.paths),
|
|
179
|
+
)
|
|
180
|
+
return result.exitCode === 0 ? 'yes' : 'no'
|
|
181
|
+
} catch (error) {
|
|
182
|
+
options.logger?.provider(
|
|
183
|
+
`attach preflight: journal existence probe failed for run ${options.runId}; re-probing under the bounded wait rather than attaching blind`,
|
|
184
|
+
{ runId: options.runId, error },
|
|
185
|
+
)
|
|
186
|
+
return 'unknown'
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `null` means the store answered "no such run" — a real, actionable fact.
|
|
192
|
+
* `undefined` means there is no answer to be had (no store, or `get` threw), and
|
|
193
|
+
* the caller must not treat that as "unknown run".
|
|
194
|
+
*/
|
|
195
|
+
async function readRecord(
|
|
196
|
+
options: AwaitAttachableJournalOptions,
|
|
197
|
+
): Promise<RunRecord | null | undefined> {
|
|
198
|
+
if (options.runs === undefined) return undefined
|
|
199
|
+
try {
|
|
200
|
+
return await options.runs.get(options.runId)
|
|
201
|
+
} catch (error) {
|
|
202
|
+
options.logger?.errors(
|
|
203
|
+
`attach preflight: reading the run record failed for run ${options.runId}`,
|
|
204
|
+
{ runId: options.runId, error },
|
|
205
|
+
)
|
|
206
|
+
return undefined
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
211
|
+
if (ms <= 0) return Promise.resolve()
|
|
212
|
+
return new Promise<void>((resolve) => {
|
|
213
|
+
const timer = setTimeout(finish, ms)
|
|
214
|
+
function finish(): void {
|
|
215
|
+
clearTimeout(timer)
|
|
216
|
+
signal?.removeEventListener('abort', finish)
|
|
217
|
+
resolve()
|
|
218
|
+
}
|
|
219
|
+
signal?.addEventListener('abort', finish, { once: true })
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function describeRecord(record: RunRecord): string {
|
|
224
|
+
return record.detachedSince === undefined
|
|
225
|
+
? `status '${record.status}' with a viewer attached`
|
|
226
|
+
: `status '${record.status}', detached since ${new Date(record.detachedSince).toISOString()}`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve once the run's journal can be tailed, or reject with a
|
|
231
|
+
* {@link JournalAttachUnavailableError} explaining why it never will be.
|
|
232
|
+
*
|
|
233
|
+
* Call this BEFORE the first follow/poll read of an attach, never on a fresh
|
|
234
|
+
* run: a fresh run's journal is created by its own `journaledCommand` spawn,
|
|
235
|
+
* which has not happened yet, so gating it would fail every new run.
|
|
236
|
+
*/
|
|
237
|
+
export async function awaitAttachableJournal(
|
|
238
|
+
handle: SandboxHandle,
|
|
239
|
+
options: AwaitAttachableJournalOptions,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const existence = await probeJournal(handle, options)
|
|
242
|
+
if (existence === 'yes') return
|
|
243
|
+
|
|
244
|
+
const record = await readRecord(options)
|
|
245
|
+
if (record === null) {
|
|
246
|
+
throw new JournalAttachUnavailableError(
|
|
247
|
+
options.runId,
|
|
248
|
+
'unknown-run',
|
|
249
|
+
`no run record exists and the journal (${options.paths.journal}) has never been written, so nothing will ever be appended to it. ` +
|
|
250
|
+
`The runId is unknown to the RunStore — it is mistyped, from another deployment, or its record has been evicted.`,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
if (record !== undefined && isTerminalRunStatus(record.status)) {
|
|
254
|
+
throw new JournalAttachUnavailableError(
|
|
255
|
+
options.runId,
|
|
256
|
+
'terminal-run',
|
|
257
|
+
`the run is already '${record.status}' and its journal (${options.paths.journal}) does not exist, so nothing will ever be appended to it. ` +
|
|
258
|
+
`A terminal run's transcript lives in its event log, not in a journal — serve the log instead of attaching.`,
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// NOTE: no fail-open branch here. An `existence === 'unknown'` probe falls
|
|
263
|
+
// through into the bounded wait below — see choice 4 in the module doc for why
|
|
264
|
+
// returning was worse than timing out, not safer.
|
|
265
|
+
const waitMs = options.waitMs ?? DEFAULT_ATTACH_JOURNAL_WAIT_MS
|
|
266
|
+
const probeIntervalMs =
|
|
267
|
+
options.probeIntervalMs ?? DEFAULT_ATTACH_PROBE_INTERVAL_MS
|
|
268
|
+
const deadline = Date.now() + waitMs
|
|
269
|
+
let lastExistence: JournalExistence = existence
|
|
270
|
+
for (;;) {
|
|
271
|
+
const remaining = deadline - Date.now()
|
|
272
|
+
if (remaining <= 0) {
|
|
273
|
+
throw new JournalAttachUnavailableError(
|
|
274
|
+
options.runId,
|
|
275
|
+
'journal-timeout',
|
|
276
|
+
`the run record says ${record === undefined ? 'nothing (no run store is wired)' : describeRecord(record)}, ` +
|
|
277
|
+
(lastExistence === 'unknown'
|
|
278
|
+
? `and its journal (${options.paths.journal}) could not be probed at all within ${waitMs}ms — every '${journalExistsCommand(options.paths)}' failed. ` +
|
|
279
|
+
`Attaching anyway would create that journal and tail it forever, so this fails instead. Check that the sandbox is still alive and that its exec transport works.`
|
|
280
|
+
: `but its journal (${options.paths.journal}) did not appear within ${waitMs}ms. ` +
|
|
281
|
+
`Either the driver died before writing its first line, or the journal directory does not match the one the agent was started with.`),
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
// The consumer gave up (client gone, lease lost). Returning hands control
|
|
285
|
+
// back to the reader, whose own AbortSignal handling ends the read — a
|
|
286
|
+
// caller's abort is not a diagnosis about the run.
|
|
287
|
+
if (options.signal?.aborted) return
|
|
288
|
+
await sleep(Math.min(probeIntervalMs, remaining), options.signal)
|
|
289
|
+
lastExistence = await probeJournal(handle, options)
|
|
290
|
+
if (lastExistence === 'yes') return
|
|
291
|
+
}
|
|
292
|
+
}
|
package/src/capabilities.ts
CHANGED
|
@@ -1,26 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Capability tokens the sandbox layer
|
|
3
|
-
* `@tanstack/ai` middleware capability system.
|
|
2
|
+
* Capability tokens the sandbox layer owns and provides.
|
|
4
3
|
*
|
|
5
4
|
* - `SandboxCapability` is PROVIDED by `withSandbox` and REQUIRED by harness
|
|
6
5
|
* adapters (`requires: [SandboxCapability]`).
|
|
7
|
-
* - `
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
6
|
+
* - `SandboxInstanceStoreCapability` lives in
|
|
7
|
+
* `./instance-store` (same package). `LocksCapability` / `withLocks` live in
|
|
8
|
+
* `@tanstack/ai/locks` and are not re-exported here.
|
|
10
9
|
*/
|
|
11
10
|
import { createCapability } from '@tanstack/ai'
|
|
12
11
|
import type { SandboxHandle } from './contracts'
|
|
13
|
-
import type { LockStore, SandboxStore } from './store'
|
|
14
12
|
import type { SandboxPolicy } from './policy'
|
|
15
13
|
import type { ToolBridgeProvisioner } from './tool-bridge'
|
|
16
14
|
|
|
17
15
|
export const SandboxCapability = createCapability<SandboxHandle>()('sandbox')
|
|
18
16
|
|
|
19
|
-
export const SandboxStoreCapability =
|
|
20
|
-
createCapability<SandboxStore>()('sandbox-store')
|
|
21
|
-
|
|
22
|
-
export const LocksCapability = createCapability<LockStore>()('locks')
|
|
23
|
-
|
|
24
17
|
/**
|
|
25
18
|
* The active sandbox policy, provided by `withSandbox` from the definition.
|
|
26
19
|
* Harness adapters read it to map allow/ask/deny rules onto their native
|
|
@@ -40,8 +33,6 @@ export const ToolBridgeProvisionerCapability =
|
|
|
40
33
|
|
|
41
34
|
/** Destructured accessors for adapters: `getSandbox(ctx)` reads the handle. */
|
|
42
35
|
export const [getSandbox, provideSandbox] = SandboxCapability
|
|
43
|
-
export const [getSandboxStore, provideSandboxStore] = SandboxStoreCapability
|
|
44
|
-
export const [getLocks, provideLocks] = LocksCapability
|
|
45
36
|
export const [getSandboxPolicy, provideSandboxPolicy] = SandboxPolicyCapability
|
|
46
37
|
export const [getToolBridgeProvisioner, provideToolBridgeProvisioner] =
|
|
47
38
|
ToolBridgeProvisionerCapability
|