@tanstack/ai-sandbox 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/dist/esm/agents-file.js +53 -34
  2. package/dist/esm/agents-file.js.map +1 -1
  3. package/dist/esm/align.d.ts +121 -0
  4. package/dist/esm/align.js +197 -0
  5. package/dist/esm/align.js.map +1 -0
  6. package/dist/esm/approvals.js +63 -29
  7. package/dist/esm/approvals.js.map +1 -1
  8. package/dist/esm/attach-preflight.d.ts +85 -0
  9. package/dist/esm/attach-preflight.js +189 -0
  10. package/dist/esm/attach-preflight.js.map +1 -0
  11. package/dist/esm/bootstrap.js +103 -117
  12. package/dist/esm/bootstrap.js.map +1 -1
  13. package/dist/esm/bridge-events.js +96 -71
  14. package/dist/esm/bridge-events.js.map +1 -1
  15. package/dist/esm/capabilities.d.ts +0 -5
  16. package/dist/esm/capabilities.js +32 -28
  17. package/dist/esm/capabilities.js.map +1 -1
  18. package/dist/esm/chunk-identity.d.ts +52 -0
  19. package/dist/esm/chunk-identity.js +102 -0
  20. package/dist/esm/chunk-identity.js.map +1 -0
  21. package/dist/esm/claim.d.ts +187 -0
  22. package/dist/esm/claim.js +349 -0
  23. package/dist/esm/claim.js.map +1 -0
  24. package/dist/esm/contracts.d.ts +13 -0
  25. package/dist/esm/driver.d.ts +83 -0
  26. package/dist/esm/driver.js +138 -0
  27. package/dist/esm/driver.js.map +1 -0
  28. package/dist/esm/durability.d.ts +263 -0
  29. package/dist/esm/durability.js +230 -0
  30. package/dist/esm/durability.js.map +1 -0
  31. package/dist/esm/errors.js +28 -24
  32. package/dist/esm/errors.js.map +1 -1
  33. package/dist/esm/file-diff.js +151 -135
  34. package/dist/esm/file-diff.js.map +1 -1
  35. package/dist/esm/git-exec.js +51 -62
  36. package/dist/esm/git-exec.js.map +1 -1
  37. package/dist/esm/harness-cwd.js +24 -19
  38. package/dist/esm/harness-cwd.js.map +1 -1
  39. package/dist/esm/index.d.ts +30 -8
  40. package/dist/esm/index.js +23 -91
  41. package/dist/esm/instance-store.d.ts +88 -0
  42. package/dist/esm/instance-store.js +67 -0
  43. package/dist/esm/instance-store.js.map +1 -0
  44. package/dist/esm/journal-bytes.d.ts +67 -0
  45. package/dist/esm/journal-bytes.js +110 -0
  46. package/dist/esm/journal-bytes.js.map +1 -0
  47. package/dist/esm/journal-reader.d.ts +66 -0
  48. package/dist/esm/journal-reader.js +228 -0
  49. package/dist/esm/journal-reader.js.map +1 -0
  50. package/dist/esm/journal-sweep.d.ts +113 -0
  51. package/dist/esm/journal-sweep.js +309 -0
  52. package/dist/esm/journal-sweep.js.map +1 -0
  53. package/dist/esm/journal.d.ts +542 -0
  54. package/dist/esm/journal.js +679 -0
  55. package/dist/esm/journal.js.map +1 -0
  56. package/dist/esm/key.js +36 -33
  57. package/dist/esm/key.js.map +1 -1
  58. package/dist/esm/middleware.d.ts +50 -2
  59. package/dist/esm/middleware.js +335 -208
  60. package/dist/esm/middleware.js.map +1 -1
  61. package/dist/esm/ngrok.js +75 -49
  62. package/dist/esm/ngrok.js.map +1 -1
  63. package/dist/esm/policy.js +43 -34
  64. package/dist/esm/policy.js.map +1 -1
  65. package/dist/esm/projection.js +16 -8
  66. package/dist/esm/projection.js.map +1 -1
  67. package/dist/esm/reap.d.ts +238 -0
  68. package/dist/esm/reap.js +355 -0
  69. package/dist/esm/reap.js.map +1 -0
  70. package/dist/esm/reclaim.d.ts +84 -0
  71. package/dist/esm/reclaim.js +106 -0
  72. package/dist/esm/reclaim.js.map +1 -0
  73. package/dist/esm/remote-tools.js +73 -62
  74. package/dist/esm/remote-tools.js.map +1 -1
  75. package/dist/esm/run.d.ts +93 -25
  76. package/dist/esm/run.js +274 -79
  77. package/dist/esm/run.js.map +1 -1
  78. package/dist/esm/runner.d.ts +119 -2
  79. package/dist/esm/runner.js +270 -51
  80. package/dist/esm/runner.js.map +1 -1
  81. package/dist/esm/sandbox.d.ts +3 -2
  82. package/dist/esm/sandbox.js +139 -123
  83. package/dist/esm/sandbox.js.map +1 -1
  84. package/dist/esm/secrets.js +39 -47
  85. package/dist/esm/secrets.js.map +1 -1
  86. package/dist/esm/setup-plan.js +22 -14
  87. package/dist/esm/setup-plan.js.map +1 -1
  88. package/dist/esm/shell.d.ts +8 -0
  89. package/dist/esm/shell.js +197 -158
  90. package/dist/esm/shell.js.map +1 -1
  91. package/dist/esm/testkit/conformance.d.ts +16 -0
  92. package/dist/esm/testkit/conformance.js +97 -0
  93. package/dist/esm/testkit/conformance.js.map +1 -0
  94. package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
  95. package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
  96. package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
  97. package/dist/esm/testkit/journal-conformance.d.ts +51 -0
  98. package/dist/esm/testkit/journal-conformance.js +378 -0
  99. package/dist/esm/testkit/journal-conformance.js.map +1 -0
  100. package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
  101. package/dist/esm/testkit/reaper-conformance.js +847 -0
  102. package/dist/esm/testkit/reaper-conformance.js.map +1 -0
  103. package/dist/esm/testkit/shell-spawn.d.ts +2 -0
  104. package/dist/esm/testkit/shell-spawn.js +60 -0
  105. package/dist/esm/testkit/shell-spawn.js.map +1 -0
  106. package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
  107. package/dist/esm/testkit/takeover-conformance.js +685 -0
  108. package/dist/esm/testkit/takeover-conformance.js.map +1 -0
  109. package/dist/esm/tool-bridge.js +227 -180
  110. package/dist/esm/tool-bridge.js.map +1 -1
  111. package/dist/esm/tool-history.d.ts +62 -0
  112. package/dist/esm/tool-history.js +171 -0
  113. package/dist/esm/tool-history.js.map +1 -0
  114. package/dist/esm/watch.js +310 -236
  115. package/dist/esm/watch.js.map +1 -1
  116. package/dist/esm/workspace.d.ts +1 -1
  117. package/dist/esm/workspace.js +49 -28
  118. package/dist/esm/workspace.js.map +1 -1
  119. package/package.json +16 -6
  120. package/skills/ai-sandbox/SKILL.md +658 -20
  121. package/src/align.ts +297 -0
  122. package/src/attach-preflight.ts +292 -0
  123. package/src/capabilities.ts +4 -13
  124. package/src/chunk-identity.ts +154 -0
  125. package/src/claim.ts +479 -0
  126. package/src/contracts.ts +13 -0
  127. package/src/driver.ts +205 -0
  128. package/src/durability.ts +380 -0
  129. package/src/index.ts +212 -27
  130. package/src/instance-store.ts +122 -0
  131. package/src/journal-bytes.ts +136 -0
  132. package/src/journal-reader.ts +359 -0
  133. package/src/journal-sweep.ts +406 -0
  134. package/src/journal.ts +875 -0
  135. package/src/middleware.ts +470 -30
  136. package/src/reap.ts +723 -0
  137. package/src/reclaim.ts +191 -0
  138. package/src/run.ts +365 -75
  139. package/src/runner.ts +347 -3
  140. package/src/sandbox.ts +38 -8
  141. package/src/shell.ts +106 -38
  142. package/src/testkit/conformance.ts +117 -0
  143. package/src/testkit/durable-run-fields-conformance.ts +147 -0
  144. package/src/testkit/journal-conformance.ts +676 -0
  145. package/src/testkit/reaper-conformance.ts +1201 -0
  146. package/src/testkit/shell-spawn.ts +67 -0
  147. package/src/testkit/takeover-conformance.ts +1040 -0
  148. package/src/tool-history.ts +245 -0
  149. package/src/workspace.ts +1 -1
  150. package/dist/esm/index.js.map +0 -1
  151. package/dist/esm/run-log.d.ts +0 -81
  152. package/dist/esm/run-log.js +0 -107
  153. package/dist/esm/run-log.js.map +0 -1
  154. package/dist/esm/store.d.ts +0 -53
  155. package/dist/esm/store.js +0 -34
  156. package/dist/esm/store.js.map +0 -1
  157. package/src/run-log.ts +0 -224
  158. 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
+ }