@tanstack/ai-sandbox 0.2.4 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/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/reclaim.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tear down the sandbox behind a terminal run.
|
|
3
|
+
*
|
|
4
|
+
* `RunRecord.sandboxKey` exists so this does not have to re-derive the compound
|
|
5
|
+
* key: `definition.key(ctx)` folds in the thread, the workspace hash, the tenant
|
|
6
|
+
* and the reuse strategy, and the reaper has none of those. Phase 3's detach path
|
|
7
|
+
* records the key at the moment it still knows it.
|
|
8
|
+
*
|
|
9
|
+
* DELIBERATELY NOT AN ENUMERATION. `SandboxInstanceStore` is `get`/`upsert`/
|
|
10
|
+
* `delete` only, and no `list` is added: it would force every backend and the
|
|
11
|
+
* conformance suite to grow an enumeration for one hypothetical caller. The
|
|
12
|
+
* consequence is real and documented rather than hidden — a sandbox whose run
|
|
13
|
+
* record was deleted before a sweep saw it is unreachable from here and leaks
|
|
14
|
+
* until the provider's own idle reclamation takes it.
|
|
15
|
+
*/
|
|
16
|
+
import type { InternalLogger } from '@tanstack/ai/adapter-internals'
|
|
17
|
+
import type { RunRecord } from '@tanstack/ai'
|
|
18
|
+
import type { SandboxProvider } from './contracts'
|
|
19
|
+
import type { SandboxInstanceStore } from './instance-store'
|
|
20
|
+
|
|
21
|
+
export interface ReclaimSandboxOptions {
|
|
22
|
+
provider: SandboxProvider
|
|
23
|
+
instances: SandboxInstanceStore
|
|
24
|
+
logger?: InternalLogger
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ReclaimOutcome =
|
|
28
|
+
/** The provider was asked to destroy it and the instance record is gone. */
|
|
29
|
+
| 'destroyed'
|
|
30
|
+
/**
|
|
31
|
+
* The provider's `destroy` THREW. The instance record was still deleted (see
|
|
32
|
+
* the ordering note on {@link reclaimSandbox}), so the sandbox — if it is in
|
|
33
|
+
* fact still running — is now unreachable from here and bills until the
|
|
34
|
+
* provider's own idle reclamation, if any.
|
|
35
|
+
*
|
|
36
|
+
* This is the one outcome that means the cost leak the reaper exists to stop
|
|
37
|
+
* is still leaking, so it is reported distinctly instead of being folded into
|
|
38
|
+
* `'destroyed'`, and {@link sandboxReclaimer} logs it above debug level.
|
|
39
|
+
*/
|
|
40
|
+
| 'destroy-failed'
|
|
41
|
+
/** The run never ran in a sandbox. */
|
|
42
|
+
| 'no-sandbox-key'
|
|
43
|
+
/** No instance record for that key; nothing to do. */
|
|
44
|
+
| 'not-found'
|
|
45
|
+
/** The record belongs to a different provider; refused. */
|
|
46
|
+
| 'provider-mismatch'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Destroy the sandbox a terminal run was bound to.
|
|
50
|
+
*
|
|
51
|
+
* Two orderings are load-bearing:
|
|
52
|
+
*
|
|
53
|
+
* - **The provider check before either `destroy` or `delete`.** A multi-provider
|
|
54
|
+
* application would otherwise hand a Docker container id to Daytona's
|
|
55
|
+
* `destroy`, which at best errors and at worst matches an unrelated sandbox
|
|
56
|
+
* in the other provider's id namespace. Getting this wrong destroys a
|
|
57
|
+
* stranger's workload, so it is the first gate — a mismatch touches NOTHING,
|
|
58
|
+
* including the record, which the right provider still needs.
|
|
59
|
+
* - **`destroy` before `delete`, and `delete` regardless of whether `destroy`
|
|
60
|
+
* succeeded.** The provider sandbox may already be gone (idle-reclaimed, the
|
|
61
|
+
* region wiped, the container pruned). Keeping an instance record that points
|
|
62
|
+
* at nothing guarantees a failed `resume` on the thread's next turn, which is
|
|
63
|
+
* strictly worse than an orphaned provider sandbox — one is a broken user
|
|
64
|
+
* experience, the other is a bounded cost the provider itself will reclaim.
|
|
65
|
+
* The delete is therefore unconditional — but a failed `destroy` returns
|
|
66
|
+
* `'destroy-failed'`, not `'destroyed'`: the record is gone either way, and an
|
|
67
|
+
* operator has to be able to tell "torn down" from "possibly still billing and
|
|
68
|
+
* no longer reachable from here".
|
|
69
|
+
*/
|
|
70
|
+
export async function reclaimSandbox(
|
|
71
|
+
record: RunRecord,
|
|
72
|
+
options: ReclaimSandboxOptions,
|
|
73
|
+
): Promise<ReclaimOutcome> {
|
|
74
|
+
const key = record.sandboxKey
|
|
75
|
+
if (key === undefined) return 'no-sandbox-key'
|
|
76
|
+
|
|
77
|
+
// NOT guarded: a store failure here means we do not know what to destroy, and
|
|
78
|
+
// the caller (the reaper) records it against the run. Swallowing it would hide
|
|
79
|
+
// a leaking sandbox entirely.
|
|
80
|
+
const instance = await options.instances.get(key)
|
|
81
|
+
if (instance === null) return 'not-found'
|
|
82
|
+
|
|
83
|
+
if (instance.provider !== options.provider.name) {
|
|
84
|
+
options.logger?.warn(
|
|
85
|
+
'reclaim: instance record belongs to a different provider; refusing to destroy',
|
|
86
|
+
{
|
|
87
|
+
runId: record.runId,
|
|
88
|
+
sandboxKey: key,
|
|
89
|
+
recordProvider: instance.provider,
|
|
90
|
+
reclaimerProvider: options.provider.name,
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
return 'provider-mismatch'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let destroyFailed = false
|
|
97
|
+
try {
|
|
98
|
+
await options.provider.destroy({ id: instance.providerSandboxId })
|
|
99
|
+
} catch (error) {
|
|
100
|
+
destroyFailed = true
|
|
101
|
+
options.logger?.warn(
|
|
102
|
+
'reclaim: provider destroy failed; deleting the record anyway',
|
|
103
|
+
{
|
|
104
|
+
runId: record.runId,
|
|
105
|
+
sandboxKey: key,
|
|
106
|
+
providerSandboxId: instance.providerSandboxId,
|
|
107
|
+
error,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
// Unconditional, per the ordering note above — but the OUTCOME must not claim
|
|
112
|
+
// success when the destroy threw. Reporting `'destroyed'` here made a leaked,
|
|
113
|
+
// now-unreachable sandbox indistinguishable from a clean teardown.
|
|
114
|
+
await options.instances.delete(key)
|
|
115
|
+
return destroyFailed ? 'destroy-failed' : 'destroyed'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Thrown by {@link sandboxReclaimer} when {@link reclaimSandbox} answers
|
|
120
|
+
* `'destroy-failed'`.
|
|
121
|
+
*
|
|
122
|
+
* WHY AN EXCEPTION AND NOT A RETURN VALUE. `ReapOptions.reclaim` is
|
|
123
|
+
* `(record) => Promise<void>`, and the sweep's only channel for "the sandbox was
|
|
124
|
+
* NOT reclaimed" is a rejection — `reapOne` catches one and reports the run
|
|
125
|
+
* `'reclaim-failed'` with its `status` and `exitCode` intact. A reclaimer that
|
|
126
|
+
* logged this arm and returned normally therefore reported `'finalized'`, and
|
|
127
|
+
* `outcomes['reclaim-failed']` read `0` on precisely the leak it watches for.
|
|
128
|
+
*
|
|
129
|
+
* It carries no `cause`: `reclaimSandbox` returns a {@link ReclaimOutcome}, not
|
|
130
|
+
* the provider's rejection, and widening that return to smuggle the error out
|
|
131
|
+
* would change an outcome contract whose ordering and arms are load-bearing. The
|
|
132
|
+
* underlying `destroy` rejection is on `reclaimSandbox`'s own `warn` line, which
|
|
133
|
+
* carries the same `runId` and `sandboxKey` this error does.
|
|
134
|
+
*/
|
|
135
|
+
export class SandboxReclaimFailedError extends Error {
|
|
136
|
+
readonly runId: string
|
|
137
|
+
/** Absent only in the impossible case; see the throw site in `sandboxReclaimer`. */
|
|
138
|
+
readonly sandboxKey: string | undefined
|
|
139
|
+
|
|
140
|
+
constructor(runId: string, sandboxKey: string | undefined) {
|
|
141
|
+
super(
|
|
142
|
+
`Reclaiming the sandbox for run "${runId}" failed: the provider's destroy rejected and the instance record${
|
|
143
|
+
sandboxKey === undefined ? '' : ` for "${sandboxKey}"`
|
|
144
|
+
} was deleted anyway, so the sandbox may still be running and is no longer reachable from the instance store.`,
|
|
145
|
+
)
|
|
146
|
+
this.name = 'SandboxReclaimFailedError'
|
|
147
|
+
this.runId = runId
|
|
148
|
+
this.sandboxKey = sandboxKey
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Adapt {@link reclaimSandbox} to `ReapOptions.reclaim`.
|
|
154
|
+
*
|
|
155
|
+
* REJECTS on `'destroy-failed'` — see {@link SandboxReclaimFailedError} for why
|
|
156
|
+
* that arm must not resolve. Every other outcome resolves: `'destroyed'` did the
|
|
157
|
+
* job, and `'no-sandbox-key'` / `'not-found'` / `'provider-mismatch'` all mean
|
|
158
|
+
* there is nothing for this reclaimer to tear down, which is not a sweep failure.
|
|
159
|
+
*/
|
|
160
|
+
export function sandboxReclaimer(
|
|
161
|
+
options: ReclaimSandboxOptions,
|
|
162
|
+
): (record: RunRecord) => Promise<void> {
|
|
163
|
+
return async (record) => {
|
|
164
|
+
const outcome = await reclaimSandbox(record, options)
|
|
165
|
+
const meta = {
|
|
166
|
+
runId: record.runId,
|
|
167
|
+
...(record.sandboxKey === undefined
|
|
168
|
+
? {}
|
|
169
|
+
: { sandboxKey: record.sandboxKey }),
|
|
170
|
+
}
|
|
171
|
+
if (outcome === 'destroy-failed') {
|
|
172
|
+
// ABOVE DEBUG DELIBERATELY. Every other outcome is bookkeeping an operator
|
|
173
|
+
// never needs to see; this one says a billed sandbox may still be running
|
|
174
|
+
// with its only lookup row deleted, which nothing downstream will retry.
|
|
175
|
+
options.logger?.errors(
|
|
176
|
+
'reclaim: destroy failed; sandbox may still be running',
|
|
177
|
+
meta,
|
|
178
|
+
)
|
|
179
|
+
// AND THEN THROWS, so the sweep's `'reclaim-failed'` outcome is reachable
|
|
180
|
+
// through the shipped reclaimer and not only through a custom one. The log
|
|
181
|
+
// line alone is invisible to a `ReapResult` consumer.
|
|
182
|
+
//
|
|
183
|
+
// `record.sandboxKey` is defined on this arm — `reclaimSandbox` answers
|
|
184
|
+
// `'no-sandbox-key'` before it ever reaches `destroy` otherwise — so this
|
|
185
|
+
// is passed through rather than asserted: a non-null assertion is banned
|
|
186
|
+
// here, and inventing a placeholder key would put a fake id in the message.
|
|
187
|
+
throw new SandboxReclaimFailedError(record.runId, record.sandboxKey)
|
|
188
|
+
}
|
|
189
|
+
options.logger?.sandbox(`reclaim: ${outcome}`, meta)
|
|
190
|
+
}
|
|
191
|
+
}
|