@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
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { journalCleanupCommand, journalExistsCommand, journalPaths, journaledCommand } from "../journal.js";
|
|
2
|
+
import { chunkFingerprint, createRunScopedIdGen } from "../chunk-identity.js";
|
|
3
|
+
import { alignedIfAttaching, journalOptionsFor, resolveSandboxDurability } from "../durability.js";
|
|
4
|
+
import { JournalAttachUnavailableError, awaitAttachableJournal } from "../attach-preflight.js";
|
|
5
|
+
import { readJournalNdjson, startJournaledAgent } from "../runner.js";
|
|
6
|
+
import { fenceDurability, withRunClaim } from "../claim.js";
|
|
7
|
+
import { sandboxRunDriver } from "../driver.js";
|
|
8
|
+
import { EventType, InMemoryRunStore } from "@tanstack/ai";
|
|
9
|
+
import { InMemoryLockStore } from "@tanstack/ai/locks";
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
//#region src/testkit/takeover-conformance.ts
|
|
12
|
+
/**
|
|
13
|
+
* Provider conformance for TAKEOVER: a second driver picking up a run whose
|
|
14
|
+
* first driver died, against a REAL sandbox.
|
|
15
|
+
*
|
|
16
|
+
* WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS. Every takeover unit test in
|
|
17
|
+
* this package drives fakes — a scripted `spawn`, a `test -f` that answers from
|
|
18
|
+
* a boolean, a log that is an array. Fakes model what we believe the shell and
|
|
19
|
+
* the filesystem do, and on this feature that belief has been wrong three times:
|
|
20
|
+
* `base64` delivers zero bytes on a live pipe, `tail -f` on a missing file exits
|
|
21
|
+
* instead of waiting, and a provider's `kill` does not always reap a grandchild.
|
|
22
|
+
* Each one passed every fake. So the four properties a takeover actually rests
|
|
23
|
+
* on are asserted here through a provider's real `spawn`/`exec` against a real
|
|
24
|
+
* journal file:
|
|
25
|
+
*
|
|
26
|
+
* 1. **The delivered sequence is the run's sequence, with no duplicated
|
|
27
|
+
* prefix.** Asserted as a TRANSCRIPT, never as "chunks arrived": a takeover
|
|
28
|
+
* that replays the whole journal and re-appends everything satisfies the weak
|
|
29
|
+
* assertion while showing the user the entire run twice. That is the exact
|
|
30
|
+
* failure `alignToStoredLog` exists to prevent, and the only assertion that
|
|
31
|
+
* can see it is one that compares the stored log to the expected sequence
|
|
32
|
+
* element for element.
|
|
33
|
+
* 2. **The attach preflight decides, or fails, but never hangs.** It probes with
|
|
34
|
+
* the provider's real `exec` (`test -f`), which is the layer where a fake's
|
|
35
|
+
* assumptions break, and its three verdicts (`unknown-run`, `terminal-run`,
|
|
36
|
+
* `journal-timeout`) plus the legitimate late-journal race are all timing
|
|
37
|
+
* against a real filesystem.
|
|
38
|
+
* 3. **The epoch fence and its latch hold under real concurrency.** Two drivers
|
|
39
|
+
* reading one real journal at once: the second wins, the first appends
|
|
40
|
+
* NOTHING — not even `pipeToRunLog`'s recovery `RUN_ERROR` — and cannot
|
|
41
|
+
* terminalize the record out from under the live successor.
|
|
42
|
+
* 4. **A terminal run's journal is deleted, and a later attach says so.** The
|
|
43
|
+
* deletion is a real `rm` of real files, and the follow-up attach must report
|
|
44
|
+
* `terminal-run` rather than tailing the file that `journalFollowCommand`
|
|
45
|
+
* would helpfully re-create.
|
|
46
|
+
*
|
|
47
|
+
* WHAT IS REAL HERE. The provider (its `spawn`, `exec`, and shell), the journal
|
|
48
|
+
* (a real NDJSON file the agent's stdout is redirected into), the agent (a real
|
|
49
|
+
* process writing real lines with a real pause in the middle), the reader
|
|
50
|
+
* (`readJournalNdjson`, including the follow/poll strategy split and the attach
|
|
51
|
+
* preflight), the alignment (`alignedIfAttaching` over the real
|
|
52
|
+
* `resolveSandboxDurability` output), the claim and BOTH fences
|
|
53
|
+
* (`sandboxRunDriver`), and the run record (`InMemoryRunStore`). The event log is
|
|
54
|
+
* in-process, exactly as the recommended `memoryStream` backend is.
|
|
55
|
+
*
|
|
56
|
+
* A provider that cannot satisfy the contract MUST declare `unsupported.reason`.
|
|
57
|
+
* As in the journal suite there is deliberately no silent-skip path: a
|
|
58
|
+
* conformance case that quietly returns prints as a pass, which is how an
|
|
59
|
+
* unimplemented capability ships green.
|
|
60
|
+
*
|
|
61
|
+
* FOUND BY THIS SUITE, FIXED IN THE PROVIDER, STILL NOT ASSERTED HERE. On
|
|
62
|
+
* local-process under Windows (git-bash `sh`), the follow read's `tail`
|
|
63
|
+
* grandchild used to SURVIVE `proc.kill()`: `LocalProcessHandle.killTree` ran
|
|
64
|
+
* `taskkill /PID <sh> /T /F` and returned as soon as `spawnSync` reported no
|
|
65
|
+
* `error`. Two things were wrong. It never checked taskkill's exit status — and
|
|
66
|
+
* that alone would not have caught it, because MSYS's fork emulation leaves the
|
|
67
|
+
* `tail.exe` pointing at an intermediate shell that has already exited, so
|
|
68
|
+
* `taskkill /T` (live parent links only) cannot reach it and still exits `0`.
|
|
69
|
+
* Measured by counting `tail.exe` before and after a run: this suite leaked 4 per
|
|
70
|
+
* run and the shipped journal suite 2, accumulating for the life of the machine.
|
|
71
|
+
* It was a provider defect, not a takeover defect — every case here still
|
|
72
|
+
* delivered the right transcript, because `untilAborted` (see
|
|
73
|
+
* `journal-reader.ts`) stops honoring the pipe once the signal fires rather than
|
|
74
|
+
* waiting for the kill, which is exactly why it never failed a test.
|
|
75
|
+
* `killTree` now resolves the tree through MSYS's own process table and verifies
|
|
76
|
+
* the survivors are gone (0 per run), covered in
|
|
77
|
+
* `ai-sandbox-local-process/tests/kill-tree.test.ts`.
|
|
78
|
+
* Deliberately still NOT asserted in this suite: a per-provider process census is
|
|
79
|
+
* not portable (Docker's `tail` dies with its container), and a conformance case
|
|
80
|
+
* that counted host processes would fail for reasons unrelated to takeover.
|
|
81
|
+
*
|
|
82
|
+
* EVERY WAIT IN THIS FILE IS BOUNDED. A hang stalls CI instead of failing it, so
|
|
83
|
+
* each journal read carries a timeout signal, each poll loop carries a deadline
|
|
84
|
+
* and a message naming what never happened, and each case carries an explicit
|
|
85
|
+
* per-test timeout.
|
|
86
|
+
*
|
|
87
|
+
* Vitest is an OPTIONAL peer dependency: this module is imported only from test
|
|
88
|
+
* files, which already run under Vitest.
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* Journal directory for this suite, deliberately NOT
|
|
92
|
+
* {@link DEFAULT_JOURNAL_DIR}: on local-process the sandbox shell shares the
|
|
93
|
+
* host's real `/tmp`, so conformance runs must not write where an application's
|
|
94
|
+
* runs live.
|
|
95
|
+
*/
|
|
96
|
+
var CONFORMANCE_JOURNAL_DIR = "/tmp/tanstack-takeover-conformance";
|
|
97
|
+
/** Poll interval handed to providers that cannot follow a growing file. */
|
|
98
|
+
var POLL_INTERVAL_MS = 50;
|
|
99
|
+
/**
|
|
100
|
+
* Quiescence window for the successor's first append. Short because the
|
|
101
|
+
* predecessor in these cases has provably stopped (the suite sequenced it) —
|
|
102
|
+
* the gate still runs, it just does not need to wait 5s to observe nothing.
|
|
103
|
+
*/
|
|
104
|
+
var FENCE_QUIET_MS = 25;
|
|
105
|
+
/**
|
|
106
|
+
* Bound on a real journal read, so a reader that delivers nothing FAILS instead
|
|
107
|
+
* of parking CI.
|
|
108
|
+
*
|
|
109
|
+
* Never an assertion, and deliberately far above anything a healthy read needs
|
|
110
|
+
* (measured: 10–18s for the follow cases on both providers). Every use site
|
|
111
|
+
* pairs it with a `backstopped: false` witness, so a read the CLOCK ended fails
|
|
112
|
+
* naming this backstop rather than as a downstream transcript mismatch — which
|
|
113
|
+
* means this number can be raised freely and must never be the thing a case is
|
|
114
|
+
* tuned against.
|
|
115
|
+
*/
|
|
116
|
+
var READ_BACKSTOP_MS = 9e4;
|
|
117
|
+
/**
|
|
118
|
+
* Unique per case, and it must be: `journalPaths` derives the file name from the
|
|
119
|
+
* `runId` and the journal is append-only, so a reused id appends BEHIND the
|
|
120
|
+
* previous run's `{"__exit":N}` sentinel and the new run appears to emit nothing
|
|
121
|
+
* at all (see `journal.ts`). The counter covers two cases created inside the
|
|
122
|
+
* same millisecond; the random suffix covers two suites sharing one `/tmp`.
|
|
123
|
+
*/
|
|
124
|
+
var caseCounter = 0;
|
|
125
|
+
function uniqueRunId(label) {
|
|
126
|
+
caseCounter += 1;
|
|
127
|
+
const suffix = Math.random().toString(36).slice(2, 8);
|
|
128
|
+
return `tko-${label}-${Date.now()}-${caseCounter}-${suffix}`;
|
|
129
|
+
}
|
|
130
|
+
function conformanceLog() {
|
|
131
|
+
const entries = [];
|
|
132
|
+
let closes = 0;
|
|
133
|
+
return {
|
|
134
|
+
log: {
|
|
135
|
+
resumeFrom: () => null,
|
|
136
|
+
append: (chunks) => Promise.resolve(chunks.map((chunk) => {
|
|
137
|
+
const offset = `conf:${entries.length}`;
|
|
138
|
+
entries.push({
|
|
139
|
+
offset,
|
|
140
|
+
chunk
|
|
141
|
+
});
|
|
142
|
+
return offset;
|
|
143
|
+
})),
|
|
144
|
+
read: () => void 0,
|
|
145
|
+
close: () => {
|
|
146
|
+
closes += 1;
|
|
147
|
+
return Promise.resolve();
|
|
148
|
+
},
|
|
149
|
+
snapshot: () => Promise.resolve(entries.map((entry) => ({ ...entry })))
|
|
150
|
+
},
|
|
151
|
+
stored: () => entries.map((entry) => entry.chunk),
|
|
152
|
+
closes: () => closes
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A lock that grants every request immediately and never reports a loss.
|
|
157
|
+
*
|
|
158
|
+
* `InMemoryLockStore` SERIALIZES claims within one process, so a second attach
|
|
159
|
+
* waits for the first to finish and the two drivers are never concurrent — which
|
|
160
|
+
* means the epoch fence can never be observed there. `claim.ts` says exactly
|
|
161
|
+
* that: in one process only layer 2, the `driverEpoch` fence, is provable. This
|
|
162
|
+
* models a lease-less lock so the two drives overlap and layer 2 does the work.
|
|
163
|
+
*/
|
|
164
|
+
var permissiveLocks = { withLock: (_key, fn) => fn(new AbortController().signal) };
|
|
165
|
+
/** The event a journal line translates into. `timestamp` is excluded from `chunkFingerprint`. */
|
|
166
|
+
function contentChunk(messageId, delta) {
|
|
167
|
+
return {
|
|
168
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
169
|
+
messageId,
|
|
170
|
+
delta,
|
|
171
|
+
timestamp: Date.now()
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Narrow one parsed journal line into its chunk.
|
|
176
|
+
*
|
|
177
|
+
* Fields are validated and the chunk is REBUILT from them rather than asserted
|
|
178
|
+
* into shape: a cast would let a provider that mangles the bytes (a folded
|
|
179
|
+
* stderr diagnostic, a truncated line) reach `chunkFingerprint` as a
|
|
180
|
+
* structurally invalid chunk and fail somewhere unrelated.
|
|
181
|
+
*/
|
|
182
|
+
function toChunk(runId, messageId, value) {
|
|
183
|
+
if (typeof value !== "object" || value === null || !("delta" in value)) throw new Error(`takeover conformance: run ${runId} journal line is not an agent event: ${JSON.stringify(value)}`);
|
|
184
|
+
const delta = value.delta;
|
|
185
|
+
if (typeof delta !== "string") throw new Error(`takeover conformance: run ${runId} journal line has a non-string delta: ${JSON.stringify(value)}`);
|
|
186
|
+
return contentChunk(messageId, delta);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The translator. Deterministic by construction, which is what makes alignment
|
|
190
|
+
* possible at all: the message id comes from {@link createRunScopedIdGen}, so
|
|
191
|
+
* re-translating the same journal from byte 0 reproduces byte-identical chunks
|
|
192
|
+
* (modulo `timestamp`, the one field `chunkFingerprint` excludes).
|
|
193
|
+
*/
|
|
194
|
+
async function* translate(runId, lines) {
|
|
195
|
+
const messageId = createRunScopedIdGen(runId)();
|
|
196
|
+
for await (const line of lines) yield toChunk(runId, messageId, line);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* A comparable transcript: each chunk reduced to its {@link chunkFingerprint}.
|
|
200
|
+
*
|
|
201
|
+
* The fingerprint, not the chunk object, and for the same reason alignment uses
|
|
202
|
+
* it — `timestamp` is wall-clock and unreproducible, so a raw `toEqual` on
|
|
203
|
+
* chunks would fail on the one field the feature deliberately ignores. Every
|
|
204
|
+
* other field participates, so a duplicated prefix, a dropped chunk, or a
|
|
205
|
+
* reordered one still fails.
|
|
206
|
+
*/
|
|
207
|
+
function transcript(chunks) {
|
|
208
|
+
return chunks.map(chunkFingerprint);
|
|
209
|
+
}
|
|
210
|
+
/** The chunks a run over `deltas` must deliver, exactly once and in order. */
|
|
211
|
+
function expectedTranscript(runId, deltas) {
|
|
212
|
+
const messageId = createRunScopedIdGen(runId)();
|
|
213
|
+
return deltas.map((delta) => contentChunk(messageId, delta));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* A real agent: a shell command that prints one NDJSON line per delta, with an
|
|
217
|
+
* optional real pause partway through, then exits.
|
|
218
|
+
*
|
|
219
|
+
* `printf '%s\n' a b c` reuses the format for every operand on GNU coreutils and
|
|
220
|
+
* on busybox alike, so this needs no loop. The JSON contains only double quotes,
|
|
221
|
+
* so it is safe inside the POSIX single-quoted words this builds.
|
|
222
|
+
*/
|
|
223
|
+
function agentCommand(deltas, pauseAfter) {
|
|
224
|
+
const line = (delta) => `'{"delta":"${delta}"}'`;
|
|
225
|
+
const head = deltas.slice(0, pauseAfter);
|
|
226
|
+
const tail = deltas.slice(pauseAfter);
|
|
227
|
+
const parts = [`printf '%s\\n' ${head.map(line).join(" ")}`];
|
|
228
|
+
if (tail.length > 0) parts.push("sleep 2", `printf '%s\\n' ${tail.map(line).join(" ")}`);
|
|
229
|
+
return parts.join("; ");
|
|
230
|
+
}
|
|
231
|
+
/** Resolve durability through the production resolver, fresh or attaching. */
|
|
232
|
+
function durabilityFor(runs, log, attach) {
|
|
233
|
+
const resolved = resolveSandboxDurability({
|
|
234
|
+
runs,
|
|
235
|
+
durability: {
|
|
236
|
+
adapter: log,
|
|
237
|
+
journal: CONFORMANCE_JOURNAL_DIR,
|
|
238
|
+
attach,
|
|
239
|
+
pollIntervalMs: POLL_INTERVAL_MS
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
if (resolved === void 0) throw new Error("takeover conformance: resolveSandboxDurability returned undefined for a fully wired run");
|
|
243
|
+
return resolved;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The reader's journal options for a resolved durability.
|
|
247
|
+
*
|
|
248
|
+
* `journalOptionsFor` answers `undefined` for a NON-durable run, which cannot
|
|
249
|
+
* happen here — every run in this suite is fully wired. Narrowing it with a
|
|
250
|
+
* thrown error rather than a non-null assertion keeps the impossible case loud
|
|
251
|
+
* if the resolver's contract ever changes.
|
|
252
|
+
*/
|
|
253
|
+
function journalOptions(durability, runId) {
|
|
254
|
+
const options = journalOptionsFor(durability, runId);
|
|
255
|
+
if (options === void 0) throw new Error(`takeover conformance: journalOptionsFor answered undefined for durable run ${runId}`);
|
|
256
|
+
return options;
|
|
257
|
+
}
|
|
258
|
+
/** A `'running'` record for `runId`, ready to be claimed. */
|
|
259
|
+
async function runningRun(runId, threadId) {
|
|
260
|
+
const runs = new InMemoryRunStore();
|
|
261
|
+
await runs.createOrResume({
|
|
262
|
+
runId,
|
|
263
|
+
threadId,
|
|
264
|
+
startedAt: Date.now()
|
|
265
|
+
});
|
|
266
|
+
return runs;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Wrap a handle so the `process.exec` calls ONE operation makes can be counted.
|
|
270
|
+
*
|
|
271
|
+
* This is how the attach preflight's fail-fast cases are anchored, and the reason
|
|
272
|
+
* they are not anchored on elapsed time. `awaitAttachableJournal` runs exactly one
|
|
273
|
+
* `test -f` before it consults the run store, so a decision made from the record
|
|
274
|
+
* costs one `exec` and a decision made by waiting costs one per
|
|
275
|
+
* `probeIntervalMs`. The count separates those two behaviors exactly; elapsed time
|
|
276
|
+
* does not, because a single `exec` is a provider round-trip whose latency the
|
|
277
|
+
* suite does not control — a `docker exec` on a loaded daemon has been measured at
|
|
278
|
+
* 9.6s, which fails a `< 4_000ms` bound while the preflight under test did
|
|
279
|
+
* precisely the right thing. A timing bound that goes red on a busy machine
|
|
280
|
+
* teaches people to ignore the suite.
|
|
281
|
+
*
|
|
282
|
+
* The spread copies the handle's own methods, so everything except `exec` is the
|
|
283
|
+
* provider's; the wrapper delegates rather than reimplementing.
|
|
284
|
+
*/
|
|
285
|
+
function countingExec(handle) {
|
|
286
|
+
let execs = 0;
|
|
287
|
+
return {
|
|
288
|
+
handle: {
|
|
289
|
+
...handle,
|
|
290
|
+
process: {
|
|
291
|
+
...handle.process,
|
|
292
|
+
exec: (command, options) => {
|
|
293
|
+
execs += 1;
|
|
294
|
+
return handle.process.exec(command, options);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
execs: () => execs
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/** Poll `check` until it answers true, or fail with a message naming what never happened. */
|
|
302
|
+
async function waitUntil(check, options) {
|
|
303
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
304
|
+
for (;;) {
|
|
305
|
+
if (await check()) return;
|
|
306
|
+
if (Date.now() > deadline) throw new Error(`takeover conformance: ${options.message} within ${options.timeoutMs}ms`);
|
|
307
|
+
await sleep(25);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function sleep(ms) {
|
|
311
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
312
|
+
}
|
|
313
|
+
/** A one-shot gate, for sequencing two concurrent drivers deterministically. */
|
|
314
|
+
function gate() {
|
|
315
|
+
let open = () => {};
|
|
316
|
+
return {
|
|
317
|
+
promise: new Promise((resolve) => {
|
|
318
|
+
open = () => resolve();
|
|
319
|
+
}),
|
|
320
|
+
open
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Build the driver a host would build for one run.
|
|
325
|
+
*
|
|
326
|
+
* `drive` is the real journal path: read the run's journal from byte 0 (through
|
|
327
|
+
* the attach preflight when attaching), translate, and align against the stored
|
|
328
|
+
* log — `alignedIfAttaching`, so alignment runs on an attach and only on an
|
|
329
|
+
* attach.
|
|
330
|
+
*
|
|
331
|
+
* Returns the driver alongside `backstopped()`, the causal witness for
|
|
332
|
+
* {@link READ_BACKSTOP_MS}: every case that drives this must assert it is
|
|
333
|
+
* `false` before its transcript assertions, so a read the CLOCK ended fails
|
|
334
|
+
* naming the backstop instead of as a truncated-transcript diff.
|
|
335
|
+
*/
|
|
336
|
+
function driverFor(input) {
|
|
337
|
+
const durability = durabilityFor(input.runs, input.log, input.attach);
|
|
338
|
+
const backstops = [];
|
|
339
|
+
return {
|
|
340
|
+
driver: sandboxRunDriver({
|
|
341
|
+
request: new Request(`http://takeover.local/attach?runId=${encodeURIComponent(input.runId)}&offset=-1`),
|
|
342
|
+
runs: input.runs,
|
|
343
|
+
locks: input.locks,
|
|
344
|
+
durability: () => input.log,
|
|
345
|
+
fenceQuietMs: FENCE_QUIET_MS,
|
|
346
|
+
drive: ({ runId, signal }) => {
|
|
347
|
+
const backstop = AbortSignal.timeout(READ_BACKSTOP_MS);
|
|
348
|
+
backstops.push(backstop);
|
|
349
|
+
const bounded = AbortSignal.any([signal, backstop]);
|
|
350
|
+
const lines = readJournalNdjson(input.handle, {
|
|
351
|
+
signal: bounded,
|
|
352
|
+
journal: journalOptions(durability, runId)
|
|
353
|
+
});
|
|
354
|
+
const gated = input.beforeFirstChunk;
|
|
355
|
+
return alignedIfAttaching(translate(runId, gated === void 0 ? lines : (async function* afterGate() {
|
|
356
|
+
let first = true;
|
|
357
|
+
for await (const value of lines) {
|
|
358
|
+
if (first) {
|
|
359
|
+
first = false;
|
|
360
|
+
await gated();
|
|
361
|
+
}
|
|
362
|
+
yield value;
|
|
363
|
+
}
|
|
364
|
+
})()), durability);
|
|
365
|
+
}
|
|
366
|
+
}),
|
|
367
|
+
backstopped: () => backstops.some((s) => s.aborted)
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/** Exactly what core's `startRunDriver` does: claim, then pipe the drive. */
|
|
371
|
+
function takeOver(driver, input) {
|
|
372
|
+
const { runs, runId, threadId } = input;
|
|
373
|
+
return driver.claim({
|
|
374
|
+
runs,
|
|
375
|
+
locks: driver.locks,
|
|
376
|
+
runId
|
|
377
|
+
}, (claim) => driver.pipe(driver.drive({
|
|
378
|
+
runId,
|
|
379
|
+
threadId,
|
|
380
|
+
signal: claim.signal
|
|
381
|
+
}), {
|
|
382
|
+
runId,
|
|
383
|
+
threadId,
|
|
384
|
+
signal: claim.signal
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
/** Best-effort removal of a case's journal files, through the shell (rule 3). */
|
|
388
|
+
async function cleanup(handle, runId) {
|
|
389
|
+
try {
|
|
390
|
+
await handle.process.exec(journalCleanupCommand(journalPaths(runId, CONFORMANCE_JOURNAL_DIR)));
|
|
391
|
+
} catch {}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Assert `createHandle` satisfies the takeover conformance contract. Each `it`
|
|
395
|
+
* gets a fresh sandbox via `createHandle`/`dispose`, and a unique `runId`, so no
|
|
396
|
+
* case can observe another's journal.
|
|
397
|
+
*/
|
|
398
|
+
function runTakeoverConformance(config) {
|
|
399
|
+
describe(`takeover conformance — ${config.name}`, () => {
|
|
400
|
+
if (config.unsupported) {
|
|
401
|
+
it.skip(`unsupported: ${config.unsupported.reason}`, () => {
|
|
402
|
+
expect(true).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
it("delivers the run sequence exactly once when a second driver takes over mid-stream", { timeout: 18e4 }, async () => {
|
|
407
|
+
const { handle, dispose } = await config.createHandle();
|
|
408
|
+
const runId = uniqueRunId("e2e");
|
|
409
|
+
const threadId = `${runId}-t`;
|
|
410
|
+
const deltas = [
|
|
411
|
+
"1",
|
|
412
|
+
"2",
|
|
413
|
+
"3",
|
|
414
|
+
"4",
|
|
415
|
+
"5",
|
|
416
|
+
"6"
|
|
417
|
+
];
|
|
418
|
+
const prefixLength = 3;
|
|
419
|
+
const expected = expectedTranscript(runId, deltas);
|
|
420
|
+
const runs = await runningRun(runId, threadId);
|
|
421
|
+
const log = conformanceLog();
|
|
422
|
+
try {
|
|
423
|
+
const fresh = durabilityFor(runs, log.log, false);
|
|
424
|
+
const deliveredByFirst = [];
|
|
425
|
+
const firstBackstop = AbortSignal.timeout(READ_BACKSTOP_MS);
|
|
426
|
+
await withRunClaim({
|
|
427
|
+
runs,
|
|
428
|
+
locks: new InMemoryLockStore(),
|
|
429
|
+
runId
|
|
430
|
+
}, async (claim) => {
|
|
431
|
+
const fenced = fenceDurability(log.log, claim, { runs });
|
|
432
|
+
await startJournaledAgent(handle, agentCommand(deltas, prefixLength), { journal: journalOptions(fresh, runId) });
|
|
433
|
+
const lines = readJournalNdjson(handle, {
|
|
434
|
+
signal: firstBackstop,
|
|
435
|
+
journal: journalOptions(fresh, runId)
|
|
436
|
+
});
|
|
437
|
+
for await (const chunk of translate(runId, lines)) {
|
|
438
|
+
await fenced.append([chunk]);
|
|
439
|
+
deliveredByFirst.push(chunk);
|
|
440
|
+
if (deliveredByFirst.length === prefixLength) break;
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
expect({ backstopped: firstBackstop.aborted }).toEqual({ backstopped: false });
|
|
444
|
+
expect(transcript(deliveredByFirst)).toEqual(transcript(expected.slice(0, prefixLength)));
|
|
445
|
+
const successor = driverFor({
|
|
446
|
+
handle,
|
|
447
|
+
runs,
|
|
448
|
+
locks: new InMemoryLockStore(),
|
|
449
|
+
log: log.log,
|
|
450
|
+
runId,
|
|
451
|
+
attach: true
|
|
452
|
+
});
|
|
453
|
+
const record = await takeOver(successor.driver, {
|
|
454
|
+
runs,
|
|
455
|
+
runId,
|
|
456
|
+
threadId
|
|
457
|
+
});
|
|
458
|
+
expect({ backstopped: successor.backstopped() }).toEqual({ backstopped: false });
|
|
459
|
+
expect(transcript(log.stored())).toEqual(transcript(expected));
|
|
460
|
+
expect(log.stored()).toHaveLength(deltas.length);
|
|
461
|
+
expect(transcript(log.stored().slice(prefixLength))).toEqual(transcript(expected.slice(prefixLength)));
|
|
462
|
+
const finalRecord = await runs.get(runId);
|
|
463
|
+
expect(finalRecord?.status).toBe("completed");
|
|
464
|
+
expect(finalRecord?.driverEpoch).toBe(2);
|
|
465
|
+
expect(record).not.toBeUndefined();
|
|
466
|
+
} finally {
|
|
467
|
+
await cleanup(handle, runId);
|
|
468
|
+
await dispose();
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
it("fails an attach to an unknown runId with unknown-run, without waiting it out", { timeout: 12e4 }, async () => {
|
|
472
|
+
const { handle, dispose } = await config.createHandle();
|
|
473
|
+
const runId = uniqueRunId("unknown");
|
|
474
|
+
try {
|
|
475
|
+
expect.hasAssertions();
|
|
476
|
+
const probes = countingExec(handle);
|
|
477
|
+
const error = await awaitAttachableJournal(probes.handle, {
|
|
478
|
+
paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR),
|
|
479
|
+
runId,
|
|
480
|
+
runs: new InMemoryRunStore(),
|
|
481
|
+
waitMs: 8e3,
|
|
482
|
+
probeIntervalMs: POLL_INTERVAL_MS
|
|
483
|
+
}).then(() => null, (reason) => reason);
|
|
484
|
+
expect(error).toBeInstanceOf(JournalAttachUnavailableError);
|
|
485
|
+
if (!(error instanceof JournalAttachUnavailableError)) return;
|
|
486
|
+
expect(error.reason).toBe("unknown-run");
|
|
487
|
+
expect(probes.execs()).toBe(1);
|
|
488
|
+
} finally {
|
|
489
|
+
await dispose();
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
it("fails an attach to a terminal run whose journal is gone with terminal-run", { timeout: 12e4 }, async () => {
|
|
493
|
+
const { handle, dispose } = await config.createHandle();
|
|
494
|
+
const runId = uniqueRunId("terminal");
|
|
495
|
+
const threadId = `${runId}-t`;
|
|
496
|
+
try {
|
|
497
|
+
expect.hasAssertions();
|
|
498
|
+
const runs = await runningRun(runId, threadId);
|
|
499
|
+
await runs.update(runId, {
|
|
500
|
+
status: "completed",
|
|
501
|
+
finishedAt: 2
|
|
502
|
+
});
|
|
503
|
+
const probes = countingExec(handle);
|
|
504
|
+
const error = await awaitAttachableJournal(probes.handle, {
|
|
505
|
+
paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR),
|
|
506
|
+
runId,
|
|
507
|
+
runs,
|
|
508
|
+
waitMs: 8e3,
|
|
509
|
+
probeIntervalMs: POLL_INTERVAL_MS
|
|
510
|
+
}).then(() => null, (reason) => reason);
|
|
511
|
+
expect(error).toBeInstanceOf(JournalAttachUnavailableError);
|
|
512
|
+
if (!(error instanceof JournalAttachUnavailableError)) return;
|
|
513
|
+
expect(error.reason).toBe("terminal-run");
|
|
514
|
+
expect(probes.execs()).toBe(1);
|
|
515
|
+
} finally {
|
|
516
|
+
await dispose();
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
it("waits for a live run whose journal appears late — the legitimate race", { timeout: 12e4 }, async () => {
|
|
520
|
+
const { handle, dispose } = await config.createHandle();
|
|
521
|
+
const runId = uniqueRunId("race");
|
|
522
|
+
const threadId = `${runId}-t`;
|
|
523
|
+
const paths = journalPaths(runId, CONFORMANCE_JOURNAL_DIR);
|
|
524
|
+
try {
|
|
525
|
+
const runs = await runningRun(runId, threadId);
|
|
526
|
+
const writer = sleep(400).then(() => handle.process.exec(journaledCommand(`printf '{"delta":"1"}\\n'`, paths)));
|
|
527
|
+
try {
|
|
528
|
+
await awaitAttachableJournal(handle, {
|
|
529
|
+
paths,
|
|
530
|
+
runId,
|
|
531
|
+
runs,
|
|
532
|
+
waitMs: 2e4,
|
|
533
|
+
probeIntervalMs: POLL_INTERVAL_MS
|
|
534
|
+
});
|
|
535
|
+
} finally {
|
|
536
|
+
await writer;
|
|
537
|
+
}
|
|
538
|
+
expect((await handle.process.exec(journalExistsCommand(paths))).exitCode).toBe(0);
|
|
539
|
+
} finally {
|
|
540
|
+
await cleanup(handle, runId);
|
|
541
|
+
await dispose();
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
it("bounds the wait for a live run whose journal never appears, with journal-timeout", { timeout: 12e4 }, async () => {
|
|
545
|
+
const { handle, dispose } = await config.createHandle();
|
|
546
|
+
const runId = uniqueRunId("timeout");
|
|
547
|
+
const threadId = `${runId}-t`;
|
|
548
|
+
try {
|
|
549
|
+
expect.hasAssertions();
|
|
550
|
+
const runs = await runningRun(runId, threadId);
|
|
551
|
+
const error = await awaitAttachableJournal(handle, {
|
|
552
|
+
paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR),
|
|
553
|
+
runId,
|
|
554
|
+
runs,
|
|
555
|
+
waitMs: 600,
|
|
556
|
+
probeIntervalMs: POLL_INTERVAL_MS
|
|
557
|
+
}).then(() => null, (reason) => reason);
|
|
558
|
+
expect(error).toBeInstanceOf(JournalAttachUnavailableError);
|
|
559
|
+
if (!(error instanceof JournalAttachUnavailableError)) return;
|
|
560
|
+
expect(error.reason).toBe("journal-timeout");
|
|
561
|
+
expect(error.message).toContain("600ms");
|
|
562
|
+
} finally {
|
|
563
|
+
await dispose();
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
it("lets the second of two concurrent drivers win, and the loser appends nothing at all", { timeout: 18e4 }, async () => {
|
|
567
|
+
const { handle, dispose } = await config.createHandle();
|
|
568
|
+
const runId = uniqueRunId("fence");
|
|
569
|
+
const threadId = `${runId}-t`;
|
|
570
|
+
const deltas = [
|
|
571
|
+
"1",
|
|
572
|
+
"2",
|
|
573
|
+
"3"
|
|
574
|
+
];
|
|
575
|
+
const expected = expectedTranscript(runId, deltas);
|
|
576
|
+
try {
|
|
577
|
+
const runs = await runningRun(runId, threadId);
|
|
578
|
+
const log = conformanceLog();
|
|
579
|
+
await startJournaledAgent(handle, agentCommand(deltas, deltas.length), { journal: journalOptions(durabilityFor(runs, log.log, false), runId) });
|
|
580
|
+
const released = gate();
|
|
581
|
+
const losingDriver = driverFor({
|
|
582
|
+
handle,
|
|
583
|
+
runs,
|
|
584
|
+
locks: permissiveLocks,
|
|
585
|
+
log: log.log,
|
|
586
|
+
runId,
|
|
587
|
+
attach: false,
|
|
588
|
+
beforeFirstChunk: () => released.promise
|
|
589
|
+
});
|
|
590
|
+
const loser = takeOver(losingDriver.driver, {
|
|
591
|
+
runs,
|
|
592
|
+
runId,
|
|
593
|
+
threadId
|
|
594
|
+
});
|
|
595
|
+
await waitUntil(async () => ((await runs.get(runId))?.driverEpoch ?? 0) >= 1, {
|
|
596
|
+
timeoutMs: 3e4,
|
|
597
|
+
message: `the first driver never claimed run ${runId}`
|
|
598
|
+
});
|
|
599
|
+
const winner = driverFor({
|
|
600
|
+
handle,
|
|
601
|
+
runs,
|
|
602
|
+
locks: permissiveLocks,
|
|
603
|
+
log: log.log,
|
|
604
|
+
runId,
|
|
605
|
+
attach: true
|
|
606
|
+
});
|
|
607
|
+
await takeOver(winner.driver, {
|
|
608
|
+
runs,
|
|
609
|
+
runId,
|
|
610
|
+
threadId
|
|
611
|
+
});
|
|
612
|
+
expect({ backstopped: winner.backstopped() }).toEqual({ backstopped: false });
|
|
613
|
+
expect(transcript(log.stored())).toEqual(transcript(expected));
|
|
614
|
+
released.open();
|
|
615
|
+
await loser;
|
|
616
|
+
expect({ backstopped: losingDriver.backstopped() }).toEqual({ backstopped: false });
|
|
617
|
+
expect(transcript(log.stored())).toEqual(transcript(expected));
|
|
618
|
+
expect(log.stored().some((chunk) => chunk.type === EventType.RUN_ERROR)).toBe(false);
|
|
619
|
+
const record = await runs.get(runId);
|
|
620
|
+
expect(record?.status).toBe("completed");
|
|
621
|
+
expect(record?.error).toBeUndefined();
|
|
622
|
+
expect(record?.driverEpoch).toBe(2);
|
|
623
|
+
expect(log.closes()).toBe(2);
|
|
624
|
+
} finally {
|
|
625
|
+
await cleanup(handle, runId);
|
|
626
|
+
await dispose();
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
it("deletes a terminal run's journal, and a later attach reports terminal-run instead of hanging", { timeout: 18e4 }, async () => {
|
|
630
|
+
const { handle, dispose } = await config.createHandle();
|
|
631
|
+
const runId = uniqueRunId("cleanup");
|
|
632
|
+
const threadId = `${runId}-t`;
|
|
633
|
+
const deltas = ["1", "2"];
|
|
634
|
+
const paths = journalPaths(runId, CONFORMANCE_JOURNAL_DIR);
|
|
635
|
+
try {
|
|
636
|
+
const runs = await runningRun(runId, threadId);
|
|
637
|
+
const fresh = durabilityFor(runs, conformanceLog().log, false);
|
|
638
|
+
await startJournaledAgent(handle, agentCommand(deltas, deltas.length), { journal: journalOptions(fresh, runId) });
|
|
639
|
+
const seen = [];
|
|
640
|
+
const backstop = AbortSignal.timeout(READ_BACKSTOP_MS);
|
|
641
|
+
for await (const chunk of translate(runId, readJournalNdjson(handle, {
|
|
642
|
+
signal: backstop,
|
|
643
|
+
journal: journalOptions(fresh, runId)
|
|
644
|
+
}))) seen.push(chunk);
|
|
645
|
+
expect({ backstopped: backstop.aborted }).toEqual({ backstopped: false });
|
|
646
|
+
expect(transcript(seen)).toEqual(transcript(expectedTranscript(runId, deltas)));
|
|
647
|
+
const journalProbe = await handle.process.exec(journalExistsCommand(paths));
|
|
648
|
+
const stderrProbe = await handle.process.exec(journalExistsCommand({
|
|
649
|
+
...paths,
|
|
650
|
+
journal: paths.stderr
|
|
651
|
+
}));
|
|
652
|
+
expect({
|
|
653
|
+
journalDeleted: journalProbe.exitCode !== 0,
|
|
654
|
+
stderrSidecarDeleted: stderrProbe.exitCode !== 0
|
|
655
|
+
}).toEqual({
|
|
656
|
+
journalDeleted: true,
|
|
657
|
+
stderrSidecarDeleted: true
|
|
658
|
+
});
|
|
659
|
+
await runs.update(runId, {
|
|
660
|
+
status: "completed",
|
|
661
|
+
finishedAt: Date.now()
|
|
662
|
+
});
|
|
663
|
+
const probes = countingExec(handle);
|
|
664
|
+
const error = await awaitAttachableJournal(probes.handle, {
|
|
665
|
+
paths,
|
|
666
|
+
runId,
|
|
667
|
+
runs,
|
|
668
|
+
waitMs: 8e3,
|
|
669
|
+
probeIntervalMs: POLL_INTERVAL_MS
|
|
670
|
+
}).then(() => null, (reason) => reason);
|
|
671
|
+
expect(error).toBeInstanceOf(JournalAttachUnavailableError);
|
|
672
|
+
if (!(error instanceof JournalAttachUnavailableError)) return;
|
|
673
|
+
expect(error.reason).toBe("terminal-run");
|
|
674
|
+
expect(probes.execs()).toBe(1);
|
|
675
|
+
} finally {
|
|
676
|
+
await cleanup(handle, runId);
|
|
677
|
+
await dispose();
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
//#endregion
|
|
683
|
+
export { runTakeoverConformance };
|
|
684
|
+
|
|
685
|
+
//# sourceMappingURL=takeover-conformance.js.map
|