@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.
- 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
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
import { decodeJournalRunId, exitSentinelLine, journalCleanupCommand, journalExistsCommand, journalListCommand, journalMtimeListCommand, journalPaths, journalReadCommand, journalStderrReadCommand, journaledCommand, parseJournalMtimeListing } from "../journal.js";
|
|
2
|
+
import { chunkFingerprint, createRunScopedIdGen } from "../chunk-identity.js";
|
|
3
|
+
import { journalReadStrategy, readJournal } from "../journal-reader.js";
|
|
4
|
+
import { readJournalNdjson } from "../runner.js";
|
|
5
|
+
import { pruneJournals } from "../journal-sweep.js";
|
|
6
|
+
import { probeRunExit, reapDetachedRuns } from "../reap.js";
|
|
7
|
+
import { waitForJournal } from "./journal-conformance.js";
|
|
8
|
+
import { EventType, InMemoryRunStore } from "@tanstack/ai";
|
|
9
|
+
import { InMemoryLockStore } from "@tanstack/ai/locks";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
//#region src/testkit/reaper-conformance.ts
|
|
13
|
+
/**
|
|
14
|
+
* Provider conformance for the two unattended sweeps: `pruneJournals`
|
|
15
|
+
* (`journal-sweep.ts`) and `reapDetachedRuns` (`reap.ts`), against a REAL
|
|
16
|
+
* sandbox.
|
|
17
|
+
*
|
|
18
|
+
* WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS. Both sweeps are almost
|
|
19
|
+
* entirely *shell* — `ls -1`, `stat -c '%Y %n'`, `rm -f`, `tail -c -N | base64`
|
|
20
|
+
* — composed as strings by `journal.ts` and executed by a provider. The unit
|
|
21
|
+
* suites drive fakes: an `exec` that answers from a scripted table, a
|
|
22
|
+
* filesystem that is a `Map`. A fake cannot be wrong about `stat` the way a
|
|
23
|
+
* BusyBox actually is, and on this feature that gap has already produced four
|
|
24
|
+
* defects that every unit test passed (see `takeover-conformance.ts`'s module
|
|
25
|
+
* doc for the roster). So the four properties the sweeps rest on are asserted
|
|
26
|
+
* here through a real shell against real files:
|
|
27
|
+
*
|
|
28
|
+
* 1. **A deletion really deletes, and a keep really keeps.** Asserted with
|
|
29
|
+
* `test -f` through the provider's shell, NEVER `handle.fs.exists`: on
|
|
30
|
+
* local-process the two resolve `/tmp` differently, so an `fs` probe answers
|
|
31
|
+
* about a path the journal was never written to (`journal.ts` rule 3). A
|
|
32
|
+
* sweep that "succeeded" while deleting nothing passes an `fs` probe.
|
|
33
|
+
* 2. **The age gate's self-witness works on THIS shell.** `journalMtimeListCommand`
|
|
34
|
+
* passes the directory as `stat`'s own first operand precisely because
|
|
35
|
+
* BusyBox exits 1 with EMPTY stdout on an unrecognised flag, and an empty
|
|
36
|
+
* parse read as an empty directory would delete every live run's journal. The
|
|
37
|
+
* docker provider's image is `alpine:3` — BusyBox 1.37, where `find -newermt`
|
|
38
|
+
* and `find -printf` are unrecognised — so the docker matrix is the authority
|
|
39
|
+
* on this case, not the local-process one (on Windows local-process execs
|
|
40
|
+
* through git-bash, whose `find`/`stat` are GNU-flavoured).
|
|
41
|
+
* 3. **The reaper never drives a live run.** The `'producing'` case asserts
|
|
42
|
+
* ABSENCE — nothing appended, `close()` not called, not one `runs.update`,
|
|
43
|
+
* `detachedSince` intact — because that is the shape of the defect
|
|
44
|
+
* `probeRunExit` exists to prevent: entering `pipeToRunLog` to "check" writes
|
|
45
|
+
* a terminal status and drops the run out of `listReclaimable` forever.
|
|
46
|
+
* 4. **A shell-hostile runId cannot become a shell-hostile command.** The encode
|
|
47
|
+
* → journal → follow → `ls` → decode → `rm` round trip runs on a runId
|
|
48
|
+
* containing `/`, a space, `;`, `$( )` and an embedded `touch`, with a canary
|
|
49
|
+
* file asserted absent. An ENCODING bug here is arbitrary command execution
|
|
50
|
+
* inside the sandbox, not a cosmetic defect.
|
|
51
|
+
*
|
|
52
|
+
* **What the canary proves, exactly, and what it does not.** It detects a
|
|
53
|
+
* runId reaching the shell WITHOUT `encodeRunId` — that is the mutation it
|
|
54
|
+
* bites on, and it bites hard: `journaledCommand`, `journalFollowCommand`,
|
|
55
|
+
* `journalExitProbeCommand`, `journalStderrReadCommand` and
|
|
56
|
+
* `journalCleanupCommand` all interpolate the path, so the `;touch` executes
|
|
57
|
+
* and the canary appears. It is BLIND to the loss of `journal.ts`'s
|
|
58
|
+
* `shellQuote`, the second and independent layer. Measured: with `shellQuote`
|
|
59
|
+
* reduced to the identity while `encodeRunId` stays, the redirect target
|
|
60
|
+
* becomes `>> /tmp/…/rp-a_3btouch_20_2ftmp…ndjson` — a single shell word of
|
|
61
|
+
* `[A-Za-z0-9._/-]`, because the encoder already removed every character a
|
|
62
|
+
* shell can act on — so no canary fires and NOTHING in this suite, or in any
|
|
63
|
+
* other real-provider suite, changes. Do not read a green run here as licence
|
|
64
|
+
* to "simplify" `shellQuote` away.
|
|
65
|
+
*
|
|
66
|
+
* The quoting is pinned instead by exact-string unit tests in
|
|
67
|
+
* `packages/ai-sandbox/tests/journal.test.ts`, which compare each composed
|
|
68
|
+
* command to a literal containing the quotes. By name, one per command:
|
|
69
|
+
* `journaledCommand` — "redirects stdout to the journal, stderr to its own
|
|
70
|
+
* file, and appends the exit sentinel" plus "quotes an adversarial runId so it
|
|
71
|
+
* cannot inject shell metacharacters"; `journalFollowCommand` — "translates a
|
|
72
|
+
* 0-based consumed-byte count into tail -c +N (1-based)";
|
|
73
|
+
* `journalReadCommand` — "the bounded read drops -f and keeps the base64
|
|
74
|
+
* frame, so a poll cannot hang"; `journalExistsCommand` — "probes through the
|
|
75
|
+
* shell, never through fs.*"; `journalStderrReadCommand` — "reads a BOUNDED
|
|
76
|
+
* tail of the sidecar, base64-framed, stderr silenced";
|
|
77
|
+
* `journalCleanupCommand`, `journalMtimeListCommand` and
|
|
78
|
+
* `journalExitProbeCommand` — the first `it` under each of their `describe`s.
|
|
79
|
+
* Those are the tests that go red on a dropped `shellQuote`; keep them exact.
|
|
80
|
+
*
|
|
81
|
+
* A provider that cannot satisfy the contract MUST declare `unsupported.reason`.
|
|
82
|
+
* As in the journal and takeover suites there is deliberately no silent-skip
|
|
83
|
+
* path: a conformance case that quietly returns prints as a pass, which is how
|
|
84
|
+
* an unimplemented capability ships green.
|
|
85
|
+
*
|
|
86
|
+
* EVERY WAIT IN THIS FILE IS BOUNDED, and every journal directory is unique per
|
|
87
|
+
* case — see {@link caseDir}. This suite DELETES FILES, and
|
|
88
|
+
* `DEFAULT_JOURNAL_DIR` is a fixed absolute path shared with every other test
|
|
89
|
+
* and, on local-process, with a developer's real runs.
|
|
90
|
+
*
|
|
91
|
+
* Vitest is an OPTIONAL peer dependency: this module is imported only from test
|
|
92
|
+
* files, which already run under Vitest.
|
|
93
|
+
*/
|
|
94
|
+
/** Poll interval handed to providers that cannot follow a growing file. */
|
|
95
|
+
var POLL_INTERVAL_MS = 50;
|
|
96
|
+
/**
|
|
97
|
+
* Quiescence window for the reaper's first append. Short because the agent in
|
|
98
|
+
* these cases has provably stopped (the suite waited for its sentinel) — the
|
|
99
|
+
* gate still runs, it just does not need to wait 5s to observe nothing.
|
|
100
|
+
*/
|
|
101
|
+
var FENCE_QUIET_MS = 25;
|
|
102
|
+
/**
|
|
103
|
+
* Bound on a real journal read, so a reader that delivers nothing FAILS instead
|
|
104
|
+
* of parking CI.
|
|
105
|
+
*
|
|
106
|
+
* Never an assertion, and deliberately far above anything a healthy read needs
|
|
107
|
+
* (measured: 10–18s for the follow cases on both providers). Every use site
|
|
108
|
+
* pairs it with a `backstopped: false` witness, so a read the CLOCK ended fails
|
|
109
|
+
* naming this backstop rather than as a downstream transcript mismatch — which
|
|
110
|
+
* means this number can be raised freely and must never be the thing a case is
|
|
111
|
+
* tuned against.
|
|
112
|
+
*/
|
|
113
|
+
var READ_BACKSTOP_MS = 9e4;
|
|
114
|
+
/** Long enough that nothing in this suite is ever classified as expired. */
|
|
115
|
+
var NEVER_EXPIRES_MS = 3600 * 1e3;
|
|
116
|
+
/**
|
|
117
|
+
* A journal directory nothing else on the machine writes to, created fresh for
|
|
118
|
+
* EVERY case.
|
|
119
|
+
*
|
|
120
|
+
* Not `DEFAULT_JOURNAL_DIR`, and not even one directory per suite. Both sweeps
|
|
121
|
+
* under test enumerate a whole directory and then DELETE from it, so a shared
|
|
122
|
+
* directory would let one case's leftovers become another's input — and on
|
|
123
|
+
* local-process the sandbox shell shares the host's real `/tmp`, where
|
|
124
|
+
* `DEFAULT_JOURNAL_DIR` holds a developer's actual runs.
|
|
125
|
+
*/
|
|
126
|
+
function caseDir() {
|
|
127
|
+
return `/tmp/tanstack-reaper-conformance-${randomUUID()}`;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Unique per run, and it must be: `journalPaths` derives the filename from the
|
|
131
|
+
* runId and the journal is append-only, so a reused id appends BEHIND the
|
|
132
|
+
* previous run's `{"__exit":N}` sentinel and the new run appears to emit nothing
|
|
133
|
+
* at all (see `journal.ts`).
|
|
134
|
+
*/
|
|
135
|
+
function uniqueRunId(label) {
|
|
136
|
+
return `rp-${label}-${randomUUID()}`;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Single-quote a shell word, POSIX-style — the same rule `journal.ts`'s private
|
|
140
|
+
* `shellQuote` applies.
|
|
141
|
+
*
|
|
142
|
+
* Duplicated rather than exported from production code on purpose: this exists
|
|
143
|
+
* only for this suite's `rm -rf` teardown, which is not a production operation
|
|
144
|
+
* and must not become one by growing an export for it.
|
|
145
|
+
*/
|
|
146
|
+
function quote(value) {
|
|
147
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
148
|
+
}
|
|
149
|
+
/** Remove a case's journal directory and everything in it. Best effort. */
|
|
150
|
+
async function removeDir(handle, dir) {
|
|
151
|
+
try {
|
|
152
|
+
await handle.process.exec(`rm -rf ${quote(dir)}`);
|
|
153
|
+
} catch {}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Does `path` exist, according to the SANDBOX'S SHELL?
|
|
157
|
+
*
|
|
158
|
+
* `journalExistsCommand` rather than `handle.fs.exists`, for any path and not
|
|
159
|
+
* just a journal: `journal.ts` rule 3 — on local-process `fs.*` resolves `/tmp`
|
|
160
|
+
* under the sandbox root while a shell redirect hits the host's real `/tmp`, so
|
|
161
|
+
* an `fs` probe would answer about a different file and every deletion
|
|
162
|
+
* assertion in this suite would pass vacuously.
|
|
163
|
+
*/
|
|
164
|
+
async function fileExists(handle, path) {
|
|
165
|
+
return (await handle.process.exec(journalExistsCommand({ journal: path }))).exitCode === 0;
|
|
166
|
+
}
|
|
167
|
+
/** Filename as `ls -1` reports it, for a path inside `dir`. */
|
|
168
|
+
function basename(dir, path) {
|
|
169
|
+
return path.slice(dir.length + 1);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* A real agent: a shell command printing one NDJSON line per delta, then
|
|
173
|
+
* exiting.
|
|
174
|
+
*
|
|
175
|
+
* `printf '%s\n' a b c` reuses the format for every operand on GNU coreutils
|
|
176
|
+
* and on BusyBox alike, so this needs no loop. The JSON contains only double
|
|
177
|
+
* quotes, so it is safe inside the POSIX single-quoted words this builds.
|
|
178
|
+
*/
|
|
179
|
+
function emitLines(deltas) {
|
|
180
|
+
return `printf '%s\\n' ${deltas.map((delta) => `'{"delta":"${delta}"}'`).join(" ")}`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Run a journaled agent to completion, so the `{"__exit":N}` sentinel is in the
|
|
184
|
+
* journal by the time this resolves.
|
|
185
|
+
*
|
|
186
|
+
* `exec`, not `spawn`: `exec` waits, and a bounded wait is the only kind this
|
|
187
|
+
* suite allows. (`SpawnHandle.wait()` is also not safe to call after the fact on
|
|
188
|
+
* every provider — see `journal-conformance.ts`.)
|
|
189
|
+
*/
|
|
190
|
+
async function runAgent(handle, paths, deltas) {
|
|
191
|
+
await handle.process.exec(journaledCommand(emitLines(deltas), paths));
|
|
192
|
+
}
|
|
193
|
+
/** `ls -1` output as a list of names. */
|
|
194
|
+
async function listNames(handle, dir) {
|
|
195
|
+
return (await handle.process.exec(journalListCommand(dir))).stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "");
|
|
196
|
+
}
|
|
197
|
+
/** Decode the base64 frame a bounded journal read produces. */
|
|
198
|
+
function decodeJournalRead(stdout) {
|
|
199
|
+
return Buffer.from(stdout.replace(/\s+/g, ""), "base64").toString("utf8");
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The `stat -c '%Y %n'` listing for `dir`, plus the raw stdout so a case can
|
|
203
|
+
* assert the WITNESS LINE itself rather than only its parsed consequence.
|
|
204
|
+
*/
|
|
205
|
+
async function mtimeListing(handle, dir) {
|
|
206
|
+
const probe = await handle.process.exec(journalMtimeListCommand(dir));
|
|
207
|
+
const parsed = parseJournalMtimeListing(probe.stdout, dir);
|
|
208
|
+
if (parsed.kind !== "listed") throw new Error(`reaper conformance: the mtime listing for ${dir} came back unavailable — stat -c '%Y %n' produced no witness line. stdout: ${JSON.stringify(probe.stdout)}`);
|
|
209
|
+
return {
|
|
210
|
+
stdout: probe.stdout,
|
|
211
|
+
entries: new Map(parsed.entries.map((entry) => [entry.name, entry.mtimeMs]))
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Is there a `<seconds> <dir>` line — `stat`'s report on its own first operand?
|
|
216
|
+
*
|
|
217
|
+
* That line, not the exit status, is the evidence the mechanism ran: BusyBox
|
|
218
|
+
* exits 1 both for an EMPTY directory (whose unexpanded glob it cannot stat) and
|
|
219
|
+
* for an unrecognised flag, and only the witness distinguishes them.
|
|
220
|
+
*/
|
|
221
|
+
function hasWitnessLine(stdout, dir) {
|
|
222
|
+
return stdout.split("\n").some((line) => /^\d+ (?<path>.+)$/.exec(line.trim())?.[1] === dir);
|
|
223
|
+
}
|
|
224
|
+
/** Read one file's mtime out of a listing, loudly when it is missing. */
|
|
225
|
+
function mtimeOf(entries, name) {
|
|
226
|
+
const mtimeMs = entries.get(name);
|
|
227
|
+
if (mtimeMs === void 0) throw new Error(`reaper conformance: ${name} has no mtime in the stat listing, so the age gate cannot be exercised`);
|
|
228
|
+
return mtimeMs;
|
|
229
|
+
}
|
|
230
|
+
function conformanceLog() {
|
|
231
|
+
const entries = [];
|
|
232
|
+
let closes = 0;
|
|
233
|
+
return {
|
|
234
|
+
log: {
|
|
235
|
+
resumeFrom: () => null,
|
|
236
|
+
append: (chunks) => Promise.resolve(chunks.map((chunk) => {
|
|
237
|
+
const offset = `reap:${entries.length}`;
|
|
238
|
+
entries.push({
|
|
239
|
+
offset,
|
|
240
|
+
chunk
|
|
241
|
+
});
|
|
242
|
+
return offset;
|
|
243
|
+
})),
|
|
244
|
+
read: () => void 0,
|
|
245
|
+
close: () => {
|
|
246
|
+
closes += 1;
|
|
247
|
+
return Promise.resolve();
|
|
248
|
+
},
|
|
249
|
+
snapshot: () => Promise.resolve(entries.map((entry) => ({ ...entry })))
|
|
250
|
+
},
|
|
251
|
+
stored: () => entries.map((entry) => entry.chunk),
|
|
252
|
+
closes: () => closes
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function countingRunStore(inner) {
|
|
256
|
+
let updates = 0;
|
|
257
|
+
return {
|
|
258
|
+
runs: {
|
|
259
|
+
createOrResume: (...args) => inner.createOrResume(...args),
|
|
260
|
+
update: (...args) => {
|
|
261
|
+
updates += 1;
|
|
262
|
+
return inner.update(...args);
|
|
263
|
+
},
|
|
264
|
+
get: (...args) => inner.get(...args),
|
|
265
|
+
listByThread: (...args) => inner.listByThread(...args),
|
|
266
|
+
listReclaimable: (...args) => inner.listReclaimable(...args),
|
|
267
|
+
findActiveRun: (...args) => inner.findActiveRun(...args)
|
|
268
|
+
},
|
|
269
|
+
updates: () => updates
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/** The event a journal line translates into. `timestamp` is excluded from `chunkFingerprint`. */
|
|
273
|
+
function contentChunk(messageId, delta) {
|
|
274
|
+
return {
|
|
275
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
276
|
+
messageId,
|
|
277
|
+
delta,
|
|
278
|
+
timestamp: Date.now()
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Narrow one parsed journal line into its chunk.
|
|
283
|
+
*
|
|
284
|
+
* Fields are validated and the chunk REBUILT from them rather than asserted into
|
|
285
|
+
* shape: a cast would let a provider that mangles the bytes reach
|
|
286
|
+
* `chunkFingerprint` as a structurally invalid chunk and fail somewhere
|
|
287
|
+
* unrelated.
|
|
288
|
+
*/
|
|
289
|
+
function toChunk(runId, messageId, value) {
|
|
290
|
+
if (typeof value !== "object" || value === null || !("delta" in value)) throw new Error(`reaper conformance: run ${runId} journal line is not an agent event: ${JSON.stringify(value)}`);
|
|
291
|
+
const delta = value.delta;
|
|
292
|
+
if (typeof delta !== "string") throw new Error(`reaper conformance: run ${runId} journal line has a non-string delta: ${JSON.stringify(value)}`);
|
|
293
|
+
return contentChunk(messageId, delta);
|
|
294
|
+
}
|
|
295
|
+
/** Deterministic translator: re-reading the journal reproduces the same chunks. */
|
|
296
|
+
async function* translate(runId, lines) {
|
|
297
|
+
const messageId = createRunScopedIdGen(runId)();
|
|
298
|
+
for await (const line of lines) yield toChunk(runId, messageId, line);
|
|
299
|
+
}
|
|
300
|
+
/** A comparable transcript: each chunk reduced to its fingerprint. */
|
|
301
|
+
function transcript(chunks) {
|
|
302
|
+
return chunks.map(chunkFingerprint);
|
|
303
|
+
}
|
|
304
|
+
/** The chunks a run over `deltas` must deliver, exactly once and in order. */
|
|
305
|
+
function expectedTranscript(runId, deltas) {
|
|
306
|
+
const messageId = createRunScopedIdGen(runId)();
|
|
307
|
+
return deltas.map((delta) => contentChunk(messageId, delta));
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The reaper's `drive`: read the run's journal from byte 0 and translate it.
|
|
311
|
+
*
|
|
312
|
+
* The read is bounded independently of `signal` so a journal that stops growing
|
|
313
|
+
* fails the case instead of hanging CI.
|
|
314
|
+
*
|
|
315
|
+
* Returns the drive alongside `backstopped()`, the causal witness for
|
|
316
|
+
* {@link READ_BACKSTOP_MS}: the case must assert it is `false` before its
|
|
317
|
+
* transcript assertions, so a read the CLOCK ended fails naming the backstop
|
|
318
|
+
* instead of as a truncated-transcript diff.
|
|
319
|
+
*/
|
|
320
|
+
function driveFromJournal(handle, dir) {
|
|
321
|
+
const backstops = [];
|
|
322
|
+
return {
|
|
323
|
+
drive: ({ runId, signal }) => {
|
|
324
|
+
const backstop = AbortSignal.timeout(READ_BACKSTOP_MS);
|
|
325
|
+
backstops.push(backstop);
|
|
326
|
+
return translate(runId, readJournalNdjson(handle, {
|
|
327
|
+
signal: AbortSignal.any([signal, backstop]),
|
|
328
|
+
journal: {
|
|
329
|
+
runId,
|
|
330
|
+
dir,
|
|
331
|
+
pollIntervalMs: POLL_INTERVAL_MS
|
|
332
|
+
}
|
|
333
|
+
}));
|
|
334
|
+
},
|
|
335
|
+
backstopped: () => backstops.some((s) => s.aborted)
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/** A `'running'`, DETACHED record — the shape `listReclaimable` selects on. */
|
|
339
|
+
async function detachedRun(store, runId, threadId, detachedSince) {
|
|
340
|
+
await store.createOrResume({
|
|
341
|
+
runId,
|
|
342
|
+
threadId,
|
|
343
|
+
startedAt: Date.now()
|
|
344
|
+
});
|
|
345
|
+
await store.update(runId, { detachedSince });
|
|
346
|
+
}
|
|
347
|
+
function sleep(ms) {
|
|
348
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Assert `createHandle` satisfies the sweep conformance contract. Each `it` gets
|
|
352
|
+
* a fresh sandbox via `createHandle`/`dispose`, a fresh journal directory, and
|
|
353
|
+
* unique runIds, so no case can observe another's files.
|
|
354
|
+
*/
|
|
355
|
+
function runReaperConformance(config) {
|
|
356
|
+
describe(`reaper conformance — ${config.name}`, () => {
|
|
357
|
+
if (config.unsupported) {
|
|
358
|
+
it.skip(`unsupported: ${config.unsupported.reason}`, () => {
|
|
359
|
+
expect(true).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
it("deletes a terminal run's journal AND its .err sidecar, while a running run's journal survives the same sweep", { timeout: 6e4 }, async () => {
|
|
364
|
+
const { handle, dispose } = await config.createHandle();
|
|
365
|
+
const dir = caseDir();
|
|
366
|
+
const terminalId = uniqueRunId("terminal");
|
|
367
|
+
const liveId = uniqueRunId("live");
|
|
368
|
+
const terminal = journalPaths(terminalId, dir);
|
|
369
|
+
const live = journalPaths(liveId, dir);
|
|
370
|
+
try {
|
|
371
|
+
await runAgent(handle, terminal, ["1"]);
|
|
372
|
+
await runAgent(handle, live, ["1"]);
|
|
373
|
+
expect({
|
|
374
|
+
terminalJournal: await fileExists(handle, terminal.journal),
|
|
375
|
+
terminalSidecar: await fileExists(handle, terminal.stderr),
|
|
376
|
+
liveJournal: await fileExists(handle, live.journal)
|
|
377
|
+
}).toEqual({
|
|
378
|
+
terminalJournal: true,
|
|
379
|
+
terminalSidecar: true,
|
|
380
|
+
liveJournal: true
|
|
381
|
+
});
|
|
382
|
+
const runs = new InMemoryRunStore();
|
|
383
|
+
await runs.createOrResume({
|
|
384
|
+
runId: terminalId,
|
|
385
|
+
threadId: `${terminalId}-t`,
|
|
386
|
+
startedAt: Date.now()
|
|
387
|
+
});
|
|
388
|
+
await runs.update(terminalId, {
|
|
389
|
+
status: "completed",
|
|
390
|
+
finishedAt: Date.now()
|
|
391
|
+
});
|
|
392
|
+
await runs.createOrResume({
|
|
393
|
+
runId: liveId,
|
|
394
|
+
threadId: `${liveId}-t`,
|
|
395
|
+
startedAt: Date.now()
|
|
396
|
+
});
|
|
397
|
+
const result = await pruneJournals({
|
|
398
|
+
handle,
|
|
399
|
+
runs,
|
|
400
|
+
dir
|
|
401
|
+
});
|
|
402
|
+
expect(result.deleted).toEqual([terminalId]);
|
|
403
|
+
expect(result.failures).toEqual([]);
|
|
404
|
+
expect(result.kept).toEqual([{
|
|
405
|
+
runId: liveId,
|
|
406
|
+
names: expect.any(Array),
|
|
407
|
+
reason: "non-terminal"
|
|
408
|
+
}]);
|
|
409
|
+
expect({
|
|
410
|
+
terminalJournal: await fileExists(handle, terminal.journal),
|
|
411
|
+
terminalSidecar: await fileExists(handle, terminal.stderr),
|
|
412
|
+
liveJournal: await fileExists(handle, live.journal),
|
|
413
|
+
liveSidecar: await fileExists(handle, live.stderr)
|
|
414
|
+
}).toEqual({
|
|
415
|
+
terminalJournal: false,
|
|
416
|
+
terminalSidecar: false,
|
|
417
|
+
liveJournal: true,
|
|
418
|
+
liveSidecar: true
|
|
419
|
+
});
|
|
420
|
+
} finally {
|
|
421
|
+
await removeDir(handle, dir);
|
|
422
|
+
await dispose();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
it("sweeps the same terminal run twice without a failure, and rm -f of an already-absent journal exits 0", { timeout: 6e4 }, async () => {
|
|
426
|
+
const { handle, dispose } = await config.createHandle();
|
|
427
|
+
const dir = caseDir();
|
|
428
|
+
const runId = uniqueRunId("twice");
|
|
429
|
+
const paths = journalPaths(runId, dir);
|
|
430
|
+
try {
|
|
431
|
+
await runAgent(handle, paths, ["1"]);
|
|
432
|
+
const runs = new InMemoryRunStore();
|
|
433
|
+
await runs.createOrResume({
|
|
434
|
+
runId,
|
|
435
|
+
threadId: `${runId}-t`,
|
|
436
|
+
startedAt: Date.now()
|
|
437
|
+
});
|
|
438
|
+
await runs.update(runId, {
|
|
439
|
+
status: "completed",
|
|
440
|
+
finishedAt: Date.now()
|
|
441
|
+
});
|
|
442
|
+
expect((await pruneJournals({
|
|
443
|
+
handle,
|
|
444
|
+
runs,
|
|
445
|
+
dir
|
|
446
|
+
})).deleted).toEqual([runId]);
|
|
447
|
+
const second = await pruneJournals({
|
|
448
|
+
handle,
|
|
449
|
+
runs,
|
|
450
|
+
dir
|
|
451
|
+
});
|
|
452
|
+
expect({
|
|
453
|
+
listed: second.listed,
|
|
454
|
+
runIds: second.runIds,
|
|
455
|
+
deleted: second.deleted,
|
|
456
|
+
kept: second.kept,
|
|
457
|
+
failures: second.failures
|
|
458
|
+
}).toEqual({
|
|
459
|
+
listed: 0,
|
|
460
|
+
runIds: 0,
|
|
461
|
+
deleted: [],
|
|
462
|
+
kept: [],
|
|
463
|
+
failures: []
|
|
464
|
+
});
|
|
465
|
+
expect((await handle.process.exec(journalCleanupCommand(paths))).exitCode).toBe(0);
|
|
466
|
+
} finally {
|
|
467
|
+
await removeDir(handle, dir);
|
|
468
|
+
await dispose();
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
it("leaves a filename it cannot decode alone, while still sweeping the terminal run beside it", { timeout: 6e4 }, async () => {
|
|
472
|
+
const { handle, dispose } = await config.createHandle();
|
|
473
|
+
const dir = caseDir();
|
|
474
|
+
const runId = uniqueRunId("undecodable");
|
|
475
|
+
const paths = journalPaths(runId, dir);
|
|
476
|
+
const strayName = "reaper-conformance-stray_1.ndjson";
|
|
477
|
+
const strayPath = `${dir}/${strayName}`;
|
|
478
|
+
try {
|
|
479
|
+
await runAgent(handle, paths, ["1"]);
|
|
480
|
+
await handle.process.exec(`printf 'not a journal\\n' >> ${quote(strayPath)}`);
|
|
481
|
+
expect(await fileExists(handle, strayPath)).toBe(true);
|
|
482
|
+
expect(decodeJournalRunId(strayName).kind).toBe("malformed");
|
|
483
|
+
const runs = new InMemoryRunStore();
|
|
484
|
+
await runs.createOrResume({
|
|
485
|
+
runId,
|
|
486
|
+
threadId: `${runId}-t`,
|
|
487
|
+
startedAt: Date.now()
|
|
488
|
+
});
|
|
489
|
+
await runs.update(runId, {
|
|
490
|
+
status: "completed",
|
|
491
|
+
finishedAt: Date.now()
|
|
492
|
+
});
|
|
493
|
+
const result = await pruneJournals({
|
|
494
|
+
handle,
|
|
495
|
+
runs,
|
|
496
|
+
dir
|
|
497
|
+
});
|
|
498
|
+
expect(result.deleted).toEqual([runId]);
|
|
499
|
+
expect(result.kept).toEqual([{
|
|
500
|
+
names: [strayName],
|
|
501
|
+
reason: "undecodable-name"
|
|
502
|
+
}]);
|
|
503
|
+
expect(result.failures).toEqual([]);
|
|
504
|
+
expect({
|
|
505
|
+
strayKept: await fileExists(handle, strayPath),
|
|
506
|
+
journalDeleted: !await fileExists(handle, paths.journal)
|
|
507
|
+
}).toEqual({
|
|
508
|
+
strayKept: true,
|
|
509
|
+
journalDeleted: true
|
|
510
|
+
});
|
|
511
|
+
} finally {
|
|
512
|
+
await removeDir(handle, dir);
|
|
513
|
+
await dispose();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
it("emits stat's self-witness line for a populated directory, so the age gate is usable", { timeout: 6e4 }, async () => {
|
|
517
|
+
const { handle, dispose } = await config.createHandle();
|
|
518
|
+
const dir = caseDir();
|
|
519
|
+
const paths = journalPaths(uniqueRunId("witness"), dir);
|
|
520
|
+
try {
|
|
521
|
+
await runAgent(handle, paths, ["1"]);
|
|
522
|
+
const listing = await mtimeListing(handle, dir);
|
|
523
|
+
expect(hasWitnessLine(listing.stdout, dir)).toBe(true);
|
|
524
|
+
expect([...listing.entries.keys()].sort()).toEqual([basename(dir, paths.journal), basename(dir, paths.stderr)].sort());
|
|
525
|
+
for (const mtimeMs of listing.entries.values()) expect(Math.abs(Date.now() - mtimeMs)).toBeLessThan(12e4);
|
|
526
|
+
} finally {
|
|
527
|
+
await removeDir(handle, dir);
|
|
528
|
+
await dispose();
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
it("reports an EMPTY journal directory as witness-only rather than unavailable", { timeout: 6e4 }, async () => {
|
|
532
|
+
const { handle, dispose } = await config.createHandle();
|
|
533
|
+
const dir = caseDir();
|
|
534
|
+
try {
|
|
535
|
+
await handle.process.exec(`mkdir -p ${quote(dir)}`);
|
|
536
|
+
const listing = await mtimeListing(handle, dir);
|
|
537
|
+
expect(hasWitnessLine(listing.stdout, dir)).toBe(true);
|
|
538
|
+
expect([...listing.entries.keys()]).toEqual([]);
|
|
539
|
+
const result = await pruneJournals({
|
|
540
|
+
handle,
|
|
541
|
+
runs: new InMemoryRunStore(),
|
|
542
|
+
dir
|
|
543
|
+
});
|
|
544
|
+
expect({
|
|
545
|
+
listed: result.listed,
|
|
546
|
+
ageGate: result.ageGate,
|
|
547
|
+
deleted: result.deleted,
|
|
548
|
+
failures: result.failures
|
|
549
|
+
}).toEqual({
|
|
550
|
+
listed: 0,
|
|
551
|
+
ageGate: "listed",
|
|
552
|
+
deleted: [],
|
|
553
|
+
failures: []
|
|
554
|
+
});
|
|
555
|
+
} finally {
|
|
556
|
+
await removeDir(handle, dir);
|
|
557
|
+
await dispose();
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
it("keeps an orphan younger than orphanTtlMs and sweeps the older one, in the same pass", { timeout: 12e4 }, async () => {
|
|
561
|
+
const { handle, dispose } = await config.createHandle();
|
|
562
|
+
const dir = caseDir();
|
|
563
|
+
const olderId = uniqueRunId("older");
|
|
564
|
+
const newerId = uniqueRunId("newer");
|
|
565
|
+
const older = journalPaths(olderId, dir);
|
|
566
|
+
const newer = journalPaths(newerId, dir);
|
|
567
|
+
try {
|
|
568
|
+
await runAgent(handle, older, ["1"]);
|
|
569
|
+
await sleep(2500);
|
|
570
|
+
await runAgent(handle, newer, ["1"]);
|
|
571
|
+
const listing = await mtimeListing(handle, dir);
|
|
572
|
+
const newestOf = (paths) => Math.max(mtimeOf(listing.entries, basename(dir, paths.journal)), mtimeOf(listing.entries, basename(dir, paths.stderr)));
|
|
573
|
+
const olderMtime = newestOf(older);
|
|
574
|
+
const newerMtime = newestOf(newer);
|
|
575
|
+
expect(newerMtime - olderMtime).toBeGreaterThanOrEqual(1e3);
|
|
576
|
+
const now = Date.now();
|
|
577
|
+
const cutoff = olderMtime + Math.floor((newerMtime - olderMtime) / 2);
|
|
578
|
+
const result = await pruneJournals({
|
|
579
|
+
handle,
|
|
580
|
+
runs: new InMemoryRunStore(),
|
|
581
|
+
dir,
|
|
582
|
+
now,
|
|
583
|
+
orphanTtlMs: now - cutoff
|
|
584
|
+
});
|
|
585
|
+
expect(result.ageGate).toBe("listed");
|
|
586
|
+
expect(result.deleted).toEqual([olderId]);
|
|
587
|
+
expect(result.kept).toEqual([{
|
|
588
|
+
runId: newerId,
|
|
589
|
+
names: expect.any(Array),
|
|
590
|
+
reason: "orphan-too-recent"
|
|
591
|
+
}]);
|
|
592
|
+
expect(result.failures).toEqual([]);
|
|
593
|
+
expect({
|
|
594
|
+
olderJournal: await fileExists(handle, older.journal),
|
|
595
|
+
olderSidecar: await fileExists(handle, older.stderr),
|
|
596
|
+
newerJournal: await fileExists(handle, newer.journal),
|
|
597
|
+
newerSidecar: await fileExists(handle, newer.stderr)
|
|
598
|
+
}).toEqual({
|
|
599
|
+
olderJournal: false,
|
|
600
|
+
olderSidecar: false,
|
|
601
|
+
newerJournal: true,
|
|
602
|
+
newerSidecar: true
|
|
603
|
+
});
|
|
604
|
+
} finally {
|
|
605
|
+
await removeDir(handle, dir);
|
|
606
|
+
await dispose();
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
it("finalizes a detached run whose agent reached its sentinel, and its transcript lands", { timeout: 12e4 }, async () => {
|
|
610
|
+
const { handle, dispose } = await config.createHandle();
|
|
611
|
+
const dir = caseDir();
|
|
612
|
+
const runId = uniqueRunId("finalize");
|
|
613
|
+
const threadId = `${runId}-t`;
|
|
614
|
+
const deltas = [
|
|
615
|
+
"1",
|
|
616
|
+
"2",
|
|
617
|
+
"3"
|
|
618
|
+
];
|
|
619
|
+
const paths = journalPaths(runId, dir);
|
|
620
|
+
try {
|
|
621
|
+
const runs = new InMemoryRunStore();
|
|
622
|
+
const detachedSince = Date.now();
|
|
623
|
+
await detachedRun(runs, runId, threadId, detachedSince);
|
|
624
|
+
await runAgent(handle, paths, deltas);
|
|
625
|
+
expect(await probeRunExit({
|
|
626
|
+
handle,
|
|
627
|
+
runId,
|
|
628
|
+
dir
|
|
629
|
+
})).toEqual({
|
|
630
|
+
state: "finished",
|
|
631
|
+
exitCode: 0
|
|
632
|
+
});
|
|
633
|
+
const log = conformanceLog();
|
|
634
|
+
const journalDrive = driveFromJournal(handle, dir);
|
|
635
|
+
const result = await reapDetachedRuns({
|
|
636
|
+
runs,
|
|
637
|
+
locks: new InMemoryLockStore(),
|
|
638
|
+
durability: () => log.log,
|
|
639
|
+
hasFinished: (record) => probeRunExit({
|
|
640
|
+
handle,
|
|
641
|
+
runId: record.runId,
|
|
642
|
+
dir
|
|
643
|
+
}),
|
|
644
|
+
drive: journalDrive.drive,
|
|
645
|
+
now: Date.now(),
|
|
646
|
+
detachedRunTtlMs: NEVER_EXPIRES_MS,
|
|
647
|
+
fenceQuietMs: FENCE_QUIET_MS
|
|
648
|
+
});
|
|
649
|
+
expect({ backstopped: journalDrive.backstopped() }).toEqual({ backstopped: false });
|
|
650
|
+
expect({
|
|
651
|
+
considered: result.considered,
|
|
652
|
+
probed: result.probed,
|
|
653
|
+
finalized: result.outcomes.finalized
|
|
654
|
+
}).toEqual({
|
|
655
|
+
considered: 1,
|
|
656
|
+
probed: 1,
|
|
657
|
+
finalized: 1
|
|
658
|
+
});
|
|
659
|
+
expect(result.runs).toEqual([{
|
|
660
|
+
runId,
|
|
661
|
+
outcome: "finalized",
|
|
662
|
+
status: "completed",
|
|
663
|
+
exitCode: 0
|
|
664
|
+
}]);
|
|
665
|
+
expect(transcript(log.stored())).toEqual(transcript(expectedTranscript(runId, deltas)));
|
|
666
|
+
const record = await runs.get(runId);
|
|
667
|
+
expect(record?.status).toBe("completed");
|
|
668
|
+
expect(record?.detachedSince).toBe(detachedSince);
|
|
669
|
+
} finally {
|
|
670
|
+
await removeDir(handle, dir);
|
|
671
|
+
await dispose();
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
it("reports a still-producing detached run as producing and leaves it completely untouched", { timeout: 12e4 }, async () => {
|
|
675
|
+
const { handle, dispose } = await config.createHandle();
|
|
676
|
+
const dir = caseDir();
|
|
677
|
+
const runId = uniqueRunId("producing");
|
|
678
|
+
const threadId = `${runId}-t`;
|
|
679
|
+
const paths = journalPaths(runId, dir);
|
|
680
|
+
const store = countingRunStore(new InMemoryRunStore());
|
|
681
|
+
const agent = await handle.process.spawn(journaledCommand(`${emitLines(["1"])}; sleep 30`, paths));
|
|
682
|
+
try {
|
|
683
|
+
const detachedSince = Date.now();
|
|
684
|
+
await detachedRun(store.runs, runId, threadId, detachedSince);
|
|
685
|
+
await waitForJournal(handle, paths);
|
|
686
|
+
const text = decodeJournalRead((await handle.process.exec(journalReadCommand(paths, 0))).stdout);
|
|
687
|
+
expect(text).toContain("{\"delta\":\"1\"}");
|
|
688
|
+
expect(text).not.toContain("__exit");
|
|
689
|
+
expect(await probeRunExit({
|
|
690
|
+
handle,
|
|
691
|
+
runId,
|
|
692
|
+
dir
|
|
693
|
+
})).toEqual({ state: "producing" });
|
|
694
|
+
const log = conformanceLog();
|
|
695
|
+
let driveCalled = false;
|
|
696
|
+
let durabilityCalls = 0;
|
|
697
|
+
const result = await reapDetachedRuns({
|
|
698
|
+
runs: store.runs,
|
|
699
|
+
locks: new InMemoryLockStore(),
|
|
700
|
+
durability: () => {
|
|
701
|
+
durabilityCalls += 1;
|
|
702
|
+
return log.log;
|
|
703
|
+
},
|
|
704
|
+
hasFinished: (record) => probeRunExit({
|
|
705
|
+
handle,
|
|
706
|
+
runId: record.runId,
|
|
707
|
+
dir
|
|
708
|
+
}),
|
|
709
|
+
drive: () => {
|
|
710
|
+
driveCalled = true;
|
|
711
|
+
return (async function* never() {})();
|
|
712
|
+
},
|
|
713
|
+
now: Date.now(),
|
|
714
|
+
detachedRunTtlMs: NEVER_EXPIRES_MS,
|
|
715
|
+
fenceQuietMs: FENCE_QUIET_MS
|
|
716
|
+
});
|
|
717
|
+
expect(result.runs).toEqual([{
|
|
718
|
+
runId,
|
|
719
|
+
outcome: "producing"
|
|
720
|
+
}]);
|
|
721
|
+
expect({
|
|
722
|
+
considered: result.considered,
|
|
723
|
+
probed: result.probed,
|
|
724
|
+
producing: result.outcomes.producing,
|
|
725
|
+
finalized: result.outcomes.finalized,
|
|
726
|
+
expired: result.outcomes.expired,
|
|
727
|
+
failed: result.outcomes.failed
|
|
728
|
+
}).toEqual({
|
|
729
|
+
considered: 1,
|
|
730
|
+
probed: 1,
|
|
731
|
+
producing: 1,
|
|
732
|
+
finalized: 0,
|
|
733
|
+
expired: 0,
|
|
734
|
+
failed: 0
|
|
735
|
+
});
|
|
736
|
+
const record = await store.runs.get(runId);
|
|
737
|
+
expect({
|
|
738
|
+
driveCalled,
|
|
739
|
+
durabilityCalls,
|
|
740
|
+
appended: log.stored().length,
|
|
741
|
+
closes: log.closes(),
|
|
742
|
+
updatesAfterSetup: store.updates() - 1,
|
|
743
|
+
status: record?.status,
|
|
744
|
+
detachedSince: record?.detachedSince,
|
|
745
|
+
driverEpoch: record?.driverEpoch
|
|
746
|
+
}).toEqual({
|
|
747
|
+
driveCalled: false,
|
|
748
|
+
durabilityCalls: 0,
|
|
749
|
+
appended: 0,
|
|
750
|
+
closes: 0,
|
|
751
|
+
updatesAfterSetup: 0,
|
|
752
|
+
status: "running",
|
|
753
|
+
detachedSince,
|
|
754
|
+
driverEpoch: void 0
|
|
755
|
+
});
|
|
756
|
+
} finally {
|
|
757
|
+
try {
|
|
758
|
+
await agent.kill();
|
|
759
|
+
} catch {}
|
|
760
|
+
await removeDir(handle, dir);
|
|
761
|
+
await dispose();
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
it("round-trips a shell-hostile runId through encode, journal, follow, sidecar read, list, decode and delete without executing any of it" + (config.followUnsupported === void 0 ? "" : ` (follow read omitted: ${config.followUnsupported.reason})`), { timeout: 12e4 }, async () => {
|
|
765
|
+
const { handle, dispose } = await config.createHandle();
|
|
766
|
+
const dir = caseDir();
|
|
767
|
+
const nonce = randomUUID().slice(0, 8);
|
|
768
|
+
const canary = `/tmp/rp-pwn-${nonce}`;
|
|
769
|
+
const runId = `rp-a;touch ${canary};b c/d$(x)-${nonce}`;
|
|
770
|
+
const threadId = `${runId}-t`;
|
|
771
|
+
const paths = journalPaths(runId, dir);
|
|
772
|
+
const journalName = basename(dir, paths.journal);
|
|
773
|
+
try {
|
|
774
|
+
await handle.process.exec(journaledCommand(`${emitLines(["1"])}; printf 'boom\\n' 1>&2`, paths));
|
|
775
|
+
expect(await fileExists(handle, canary)).toBe(false);
|
|
776
|
+
expect(await probeRunExit({
|
|
777
|
+
handle,
|
|
778
|
+
runId,
|
|
779
|
+
dir
|
|
780
|
+
})).toEqual({
|
|
781
|
+
state: "finished",
|
|
782
|
+
exitCode: 0
|
|
783
|
+
});
|
|
784
|
+
expect(journalName).toMatch(/^[A-Za-z0-9._-]+\.ndjson$/);
|
|
785
|
+
expect(paths.journal.startsWith(`${dir}/`)).toBe(true);
|
|
786
|
+
expect(journalReadStrategy(handle)).toBe(config.followUnsupported === void 0 ? "follow" : "poll");
|
|
787
|
+
if (config.followUnsupported === void 0) {
|
|
788
|
+
const followed = [];
|
|
789
|
+
const backstop = AbortSignal.timeout(READ_BACKSTOP_MS);
|
|
790
|
+
for await (const line of readJournal(handle, {
|
|
791
|
+
paths,
|
|
792
|
+
fromByte: 0,
|
|
793
|
+
strategy: "follow",
|
|
794
|
+
signal: backstop
|
|
795
|
+
})) {
|
|
796
|
+
followed.push(line.line);
|
|
797
|
+
if (line.line.includes("__exit")) break;
|
|
798
|
+
}
|
|
799
|
+
expect({ backstopped: backstop.aborted }).toEqual({ backstopped: false });
|
|
800
|
+
expect(followed).toEqual(["{\"delta\":\"1\"}", exitSentinelLine(paths, 0)]);
|
|
801
|
+
expect(await fileExists(handle, canary)).toBe(false);
|
|
802
|
+
}
|
|
803
|
+
expect(decodeJournalRead((await handle.process.exec(journalStderrReadCommand(paths))).stdout)).toBe("boom\n");
|
|
804
|
+
expect(await fileExists(handle, canary)).toBe(false);
|
|
805
|
+
expect((await listNames(handle, dir)).sort()).toEqual([journalName, basename(dir, paths.stderr)].sort());
|
|
806
|
+
expect(decodeJournalRunId(journalName)).toEqual({
|
|
807
|
+
kind: "runId",
|
|
808
|
+
runId
|
|
809
|
+
});
|
|
810
|
+
const runs = new InMemoryRunStore();
|
|
811
|
+
await runs.createOrResume({
|
|
812
|
+
runId,
|
|
813
|
+
threadId,
|
|
814
|
+
startedAt: Date.now()
|
|
815
|
+
});
|
|
816
|
+
await runs.update(runId, {
|
|
817
|
+
status: "completed",
|
|
818
|
+
finishedAt: Date.now()
|
|
819
|
+
});
|
|
820
|
+
const result = await pruneJournals({
|
|
821
|
+
handle,
|
|
822
|
+
runs,
|
|
823
|
+
dir
|
|
824
|
+
});
|
|
825
|
+
expect(result.deleted).toEqual([runId]);
|
|
826
|
+
expect(result.failures).toEqual([]);
|
|
827
|
+
expect({
|
|
828
|
+
journalDeleted: !await fileExists(handle, paths.journal),
|
|
829
|
+
sidecarDeleted: !await fileExists(handle, paths.stderr),
|
|
830
|
+
canaryAbsent: !await fileExists(handle, canary)
|
|
831
|
+
}).toEqual({
|
|
832
|
+
journalDeleted: true,
|
|
833
|
+
sidecarDeleted: true,
|
|
834
|
+
canaryAbsent: true
|
|
835
|
+
});
|
|
836
|
+
} finally {
|
|
837
|
+
await handle.process.exec(`rm -f ${quote(canary)}`).catch(() => void 0);
|
|
838
|
+
await removeDir(handle, dir);
|
|
839
|
+
await dispose();
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
//#endregion
|
|
845
|
+
export { runReaperConformance };
|
|
846
|
+
|
|
847
|
+
//# sourceMappingURL=reaper-conformance.js.map
|