@apexops/kernel 0.10.0-next.4 → 0.11.0-next.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/README.md +28 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/directory-lock.d.ts +118 -0
- package/dist/directory-lock.d.ts.map +1 -0
- package/dist/directory-lock.js +587 -0
- package/dist/directory-lock.js.map +1 -0
- package/dist/files.d.ts +63 -0
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +158 -2
- package/dist/files.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/object-store.js +2 -2
- package/dist/object-store.js.map +1 -1
- package/dist/project-store.d.ts +2 -1
- package/dist/project-store.d.ts.map +1 -1
- package/dist/project-store.js +1 -0
- package/dist/project-store.js.map +1 -1
- package/dist/repeat-files.d.ts +66 -0
- package/dist/repeat-files.d.ts.map +1 -0
- package/dist/repeat-files.js +214 -0
- package/dist/repeat-files.js.map +1 -0
- package/dist/repeat-guard.d.ts +197 -0
- package/dist/repeat-guard.d.ts.map +1 -0
- package/dist/repeat-guard.js +462 -0
- package/dist/repeat-guard.js.map +1 -0
- package/dist/review-capture.d.ts +170 -0
- package/dist/review-capture.d.ts.map +1 -0
- package/dist/review-capture.js +570 -0
- package/dist/review-capture.js.map +1 -0
- package/dist/run-repository.d.ts +52 -2
- package/dist/run-repository.d.ts.map +1 -1
- package/dist/run-repository.js +198 -52
- package/dist/run-repository.js.map +1 -1
- package/dist/tasks.d.ts.map +1 -1
- package/dist/tasks.js +24 -2
- package/dist/tasks.js.map +1 -1
- package/dist/test/files.test.d.ts +2 -0
- package/dist/test/files.test.d.ts.map +1 -0
- package/dist/test/files.test.js +140 -0
- package/dist/test/files.test.js.map +1 -0
- package/dist/test/policies.test.js +1 -1
- package/dist/test/policies.test.js.map +1 -1
- package/dist/test/repeat-guard.test.d.ts +2 -0
- package/dist/test/repeat-guard.test.d.ts.map +1 -0
- package/dist/test/repeat-guard.test.js +1106 -0
- package/dist/test/repeat-guard.test.js.map +1 -0
- package/dist/test/review-capture.test.d.ts +2 -0
- package/dist/test/review-capture.test.d.ts.map +1 -0
- package/dist/test/review-capture.test.js +291 -0
- package/dist/test/review-capture.test.js.map +1 -0
- package/dist/test/run-repository.test.js +451 -7
- package/dist/test/run-repository.test.js.map +1 -1
- package/dist/test/workflow.test.js +32 -1
- package/dist/test/workflow.test.js.map +1 -1
- package/dist/test/writer-transfer.test.js +6 -1
- package/dist/test/writer-transfer.test.js.map +1 -1
- package/dist/validator-registry.d.ts.map +1 -1
- package/dist/validator-registry.js +10 -1
- package/dist/validator-registry.js.map +1 -1
- package/dist/workflow-validator-ownership.d.ts.map +1 -1
- package/dist/workflow-validator-ownership.js +2 -1
- package/dist/workflow-validator-ownership.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, utimes, writeFile } from "node:fs/promises";
|
|
4
|
+
import { hostname, tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { DEFAULT_REPEAT_WINDOW_MS, MAX_REPEAT_RECORDS, MAX_REPEAT_RESULT_BYTES, ProjectStore, REPEAT_EVENT_TYPE, REPEAT_GUARD_FILE, REPEAT_LOCK_FILE, RepeatFileSnapshotError, RepeatFileWrites, RepeatGuardBusyError, RepeatGuardCancelledError, RepeatGuardShareCloseError, RepeatGuardSnapshotUnavailableError, RepeatGuardStaleError, RunRepository, RunWriterConflictError, atomicWriteJson, executeRepeatSafe, withRepeatGuardLock, withRepeatGuardReadLock, sha256Json, readRepeatEvents, readRepeatRecords, repeatCallHash, repeatFingerprint, repeatStateToken, repeatFilesDigest, repeatFilesHeld, sha256Bytes, sha256Text, snapshotRepeatFiles, } from "../index.js";
|
|
8
|
+
import { CONTRACT_VERSION, RepeatGuardRecordsV1Schema, registerContractFormats } from "@apexops/contracts";
|
|
9
|
+
import { Value } from "@sinclair/typebox/value";
|
|
10
|
+
async function fixture(start = "2026-01-01T00:00:00.000Z") {
|
|
11
|
+
const root = await mkdtemp(join(tmpdir(), "apex-repeat-guard-"));
|
|
12
|
+
let now = new Date(start);
|
|
13
|
+
const clock = () => now;
|
|
14
|
+
let id = 0;
|
|
15
|
+
const idSource = () => `id-${++id}`;
|
|
16
|
+
const store = new ProjectStore(root, clock, () => "run-1");
|
|
17
|
+
await store.initializeProject({
|
|
18
|
+
projectId: "demo",
|
|
19
|
+
displayName: "Demo",
|
|
20
|
+
defaultIacTool: "bicep",
|
|
21
|
+
riskOwner: "partner",
|
|
22
|
+
});
|
|
23
|
+
await store.createRun("demo", { environment: "dev", targetScope: "local", runtimeLockHash: "a".repeat(64) });
|
|
24
|
+
const runDirectory = store.runDirectory("demo", "run-1");
|
|
25
|
+
const lockDirectory = join(root, ".apex", "local");
|
|
26
|
+
const repository = new RunRepository(runDirectory, { clock, idSource });
|
|
27
|
+
const worktree = await mkdtemp(join(root, "worktree-"));
|
|
28
|
+
const other = await mkdtemp(join(root, "worktree-other-"));
|
|
29
|
+
const scope = async () => {
|
|
30
|
+
const state = await repository.repeatState();
|
|
31
|
+
return {
|
|
32
|
+
runDirectory,
|
|
33
|
+
projectId: "demo",
|
|
34
|
+
runId: "run-1",
|
|
35
|
+
ownerEpoch: state.ownerEpoch,
|
|
36
|
+
state: repeatStateToken({ selection: { projectId: "demo", runId: "run-1" }, projects: ["demo"], ...state }),
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
const appendRunEvent = async (type = "test.changed") => repository.journal.append({
|
|
40
|
+
eventId: idSource(),
|
|
41
|
+
projectId: "demo",
|
|
42
|
+
runId: "run-1",
|
|
43
|
+
type,
|
|
44
|
+
timestamp: clock().toISOString(),
|
|
45
|
+
ownerEpoch: 1,
|
|
46
|
+
expectedHead: await repository.journal.head(),
|
|
47
|
+
payload: { type },
|
|
48
|
+
});
|
|
49
|
+
let executions = 0;
|
|
50
|
+
const call = (args = { taskId: "task-1", value: { b: 1, a: [1, 2] } }, options = {}) => executeRepeatSafe({ operation: "stageArtifact", workspace: options.workspace ?? worktree, arguments: args }, {
|
|
51
|
+
lockDirectory,
|
|
52
|
+
scope,
|
|
53
|
+
assertReplayAllowed: options.assertReplayAllowed ??
|
|
54
|
+
(async () => repository.assertWriterAvailable({ workspacePath: options.workspace ?? worktree })),
|
|
55
|
+
execute: async () => {
|
|
56
|
+
executions += 1;
|
|
57
|
+
await repository.acquireWriterLease({ workspacePath: options.workspace ?? worktree });
|
|
58
|
+
if (options.fail !== undefined)
|
|
59
|
+
throw options.fail;
|
|
60
|
+
await (options.effect ?? (() => appendRunEvent("artifact.staged")))();
|
|
61
|
+
return options.result ?? { z: executions, a: { nested: [3, 1] }, staged: true };
|
|
62
|
+
},
|
|
63
|
+
...(options.validUntil === undefined ? {} : { validUntil: () => options.validUntil }),
|
|
64
|
+
}, {
|
|
65
|
+
clock,
|
|
66
|
+
idSource,
|
|
67
|
+
...(options.windowMs === undefined ? {} : { windowMs: options.windowMs }),
|
|
68
|
+
...(options.maxRecords === undefined ? {} : { maxRecords: options.maxRecords }),
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
root,
|
|
72
|
+
store,
|
|
73
|
+
lockDirectory,
|
|
74
|
+
runDirectory,
|
|
75
|
+
repository,
|
|
76
|
+
worktree,
|
|
77
|
+
other,
|
|
78
|
+
scope,
|
|
79
|
+
appendRunEvent,
|
|
80
|
+
call,
|
|
81
|
+
executions: () => executions,
|
|
82
|
+
advance(milliseconds) {
|
|
83
|
+
now = new Date(now.getTime() + milliseconds);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
test("call hashes are canonical over key order, undefined members, and worktree spelling", async () => {
|
|
88
|
+
const root = await mkdtemp(join(tmpdir(), "apex-repeat-hash-"));
|
|
89
|
+
const real = join(root, "real");
|
|
90
|
+
await mkdir(real);
|
|
91
|
+
const link = join(root, "link");
|
|
92
|
+
await symlink(real, link);
|
|
93
|
+
const base = { operation: "recordInput", workspace: real, arguments: { b: [1, { y: 2, x: 1 }], a: "v" } };
|
|
94
|
+
const hash = repeatCallHash(base);
|
|
95
|
+
assert.match(hash, /^[0-9a-f]{64}$/u);
|
|
96
|
+
assert.equal(repeatCallHash({ ...base, arguments: { a: "v", b: [1, { x: 1, y: 2 }], c: undefined } }), hash);
|
|
97
|
+
assert.equal(repeatCallHash({ ...base, workspace: link }), hash);
|
|
98
|
+
assert.equal(repeatCallHash({ ...base, workspace: `${real}/` }), hash);
|
|
99
|
+
assert.notEqual(repeatCallHash({ ...base, operation: "nextTask" }), hash);
|
|
100
|
+
assert.notEqual(repeatCallHash({ ...base, workspace: root }), hash);
|
|
101
|
+
assert.notEqual(repeatCallHash({ ...base, arguments: { a: "v", b: [{ x: 1, y: 2 }, 1] } }), hash);
|
|
102
|
+
assert.equal(repeatCallHash({ ...base, arguments: undefined }), repeatCallHash({ ...base, arguments: null }));
|
|
103
|
+
assert.throws(() => repeatCallHash({ ...base, operation: "../x" }), /operation is invalid/u);
|
|
104
|
+
assert.throws(() => repeatCallHash({ ...base, arguments: { value: 1n } }), TypeError);
|
|
105
|
+
assert.equal(repeatFingerprint(hash, null), repeatFingerprint(hash, null));
|
|
106
|
+
assert.notEqual(repeatFingerprint(hash, "b".repeat(64)), repeatFingerprint(hash, null));
|
|
107
|
+
});
|
|
108
|
+
test("state tokens change with selection, projects, run, journal head, and writer but not lease renewal", async () => {
|
|
109
|
+
const state = {
|
|
110
|
+
selection: { projectId: "demo", runId: "run-1" },
|
|
111
|
+
projects: ["b", "a"],
|
|
112
|
+
runHash: "a".repeat(64),
|
|
113
|
+
journalHead: null,
|
|
114
|
+
writer: "/w1",
|
|
115
|
+
};
|
|
116
|
+
const token = repeatStateToken(state);
|
|
117
|
+
assert.equal(repeatStateToken({ ...state, projects: ["a", "b"] }), token);
|
|
118
|
+
for (const variant of [
|
|
119
|
+
{ selection: { projectId: "demo", runId: "run-2" } },
|
|
120
|
+
{ projects: ["a"] },
|
|
121
|
+
{ runHash: "b".repeat(64) },
|
|
122
|
+
{ journalHead: "c".repeat(64) },
|
|
123
|
+
{ writer: null },
|
|
124
|
+
]) {
|
|
125
|
+
assert.notEqual(repeatStateToken({ ...state, ...variant }), token, JSON.stringify(variant));
|
|
126
|
+
}
|
|
127
|
+
const { repository, worktree, scope, advance } = await fixture();
|
|
128
|
+
await repository.acquireWriterLease({ workspacePath: worktree });
|
|
129
|
+
const before = (await scope()).state;
|
|
130
|
+
advance(1_000);
|
|
131
|
+
await repository.acquireWriterLease({ workspacePath: worktree });
|
|
132
|
+
assert.equal((await scope()).state, before);
|
|
133
|
+
assert.equal((await repository.repeatState()).writer !== null, true);
|
|
134
|
+
await repository.releaseWriterLease({ workspacePath: worktree });
|
|
135
|
+
assert.notEqual((await scope()).state, before);
|
|
136
|
+
});
|
|
137
|
+
test("an identical repeat returns the original result byte-for-byte and is audited without a state change", async () => {
|
|
138
|
+
const { runDirectory, repository, call, executions, scope } = await fixture();
|
|
139
|
+
const first = await call();
|
|
140
|
+
assert.equal(first.repeated, false);
|
|
141
|
+
const headAfter = await repository.journal.head();
|
|
142
|
+
const stateAfter = (await scope()).state;
|
|
143
|
+
const repeated = await call({ value: { a: [1, 2], b: 1 }, taskId: "task-1" });
|
|
144
|
+
assert.equal(repeated.repeated, true);
|
|
145
|
+
assert.equal(executions(), 1);
|
|
146
|
+
assert.equal(JSON.stringify(repeated.value), JSON.stringify(first.value));
|
|
147
|
+
assert.equal(await repository.journal.head(), headAfter);
|
|
148
|
+
assert.equal((await scope()).state, stateAfter);
|
|
149
|
+
const third = await call();
|
|
150
|
+
assert.equal(third.repeated, true);
|
|
151
|
+
assert.equal(executions(), 1);
|
|
152
|
+
const events = await readRepeatEvents(runDirectory);
|
|
153
|
+
assert.equal(events.length, 2);
|
|
154
|
+
assert.deepEqual(events.map(({ type, sequence }) => [type, sequence]), [
|
|
155
|
+
[REPEAT_EVENT_TYPE, 1],
|
|
156
|
+
[REPEAT_EVENT_TYPE, 2],
|
|
157
|
+
]);
|
|
158
|
+
const payload = events[0].payload;
|
|
159
|
+
assert.equal(payload.operation, "stageArtifact");
|
|
160
|
+
assert.equal(payload.originalFingerprint, first.fingerprint);
|
|
161
|
+
assert.equal(payload.fingerprint, repeated.fingerprint);
|
|
162
|
+
assert.notEqual(payload.fingerprint, payload.originalFingerprint);
|
|
163
|
+
assert.equal(payload.state, stateAfter);
|
|
164
|
+
});
|
|
165
|
+
test("an identical call after an intervening state change is a new request", async () => {
|
|
166
|
+
const { call, executions, appendRunEvent, runDirectory } = await fixture();
|
|
167
|
+
await call();
|
|
168
|
+
await appendRunEvent("other.change");
|
|
169
|
+
const next = await call();
|
|
170
|
+
assert.equal(next.repeated, false);
|
|
171
|
+
assert.equal(executions(), 2);
|
|
172
|
+
assert.equal((await readRepeatEvents(runDirectory)).length, 0);
|
|
173
|
+
assert.equal((await call()).repeated, true);
|
|
174
|
+
assert.equal(executions(), 2);
|
|
175
|
+
});
|
|
176
|
+
test("different arguments or worktrees are new requests", async () => {
|
|
177
|
+
const { call, executions, other } = await fixture();
|
|
178
|
+
await call();
|
|
179
|
+
assert.equal((await call({ taskId: "task-2" }, { effect: async () => undefined })).repeated, false);
|
|
180
|
+
assert.equal(executions(), 2);
|
|
181
|
+
await assert.rejects(call(undefined, { workspace: other }), RunWriterConflictError);
|
|
182
|
+
assert.equal(executions(), 3);
|
|
183
|
+
});
|
|
184
|
+
test("the repeat window and result validity bound replay", async () => {
|
|
185
|
+
const { call, executions, advance } = await fixture();
|
|
186
|
+
await call(undefined, { effect: async () => undefined });
|
|
187
|
+
advance(DEFAULT_REPEAT_WINDOW_MS - 1);
|
|
188
|
+
assert.equal((await call()).repeated, true);
|
|
189
|
+
advance(1);
|
|
190
|
+
assert.equal((await call(undefined, { effect: async () => undefined })).repeated, false);
|
|
191
|
+
assert.equal(executions(), 2);
|
|
192
|
+
await call({ task: 1 }, { effect: async () => undefined, validUntil: "2026-01-01T00:10:00.100Z" });
|
|
193
|
+
assert.equal((await call({ task: 1 })).repeated, true);
|
|
194
|
+
advance(200);
|
|
195
|
+
assert.equal((await call({ task: 1 }, { effect: async () => undefined })).repeated, false);
|
|
196
|
+
await call({ short: 1 }, { effect: async () => undefined, windowMs: 10 });
|
|
197
|
+
advance(10);
|
|
198
|
+
assert.equal((await call({ short: 1 }, { effect: async () => undefined })).repeated, false);
|
|
199
|
+
});
|
|
200
|
+
test("failed calls are not recorded and a repeat re-runs normal checks", async () => {
|
|
201
|
+
const { call, executions, runDirectory } = await fixture();
|
|
202
|
+
await assert.rejects(call(undefined, { fail: new Error("validation failed") }), /validation failed/u);
|
|
203
|
+
assert.deepEqual(await readRepeatRecords(runDirectory), []);
|
|
204
|
+
assert.equal((await call()).repeated, false);
|
|
205
|
+
assert.equal(executions(), 2);
|
|
206
|
+
});
|
|
207
|
+
test("repeat events keep the caller's worktree spelling while Windows identity is case-insensitive", async (context) => {
|
|
208
|
+
const { runDirectory, root, lockDirectory, scope } = await fixture();
|
|
209
|
+
const worktree = join(root, "WorkTree-Upper");
|
|
210
|
+
await mkdir(worktree);
|
|
211
|
+
// Simulate Windows: canonical worktree keys are case-folded, but audit records must not be.
|
|
212
|
+
const platform = Object.getOwnPropertyDescriptor(process, "platform");
|
|
213
|
+
Object.defineProperty(process, "platform", { ...platform, value: "win32" });
|
|
214
|
+
context.after(() => Object.defineProperty(process, "platform", platform));
|
|
215
|
+
let executions = 0;
|
|
216
|
+
const run = (workspace) => executeRepeatSafe({ operation: "projectUse", workspace, arguments: { projectId: "demo" } }, {
|
|
217
|
+
lockDirectory,
|
|
218
|
+
scope,
|
|
219
|
+
assertReplayAllowed: async () => undefined,
|
|
220
|
+
execute: async () => ({ executions: ++executions }),
|
|
221
|
+
});
|
|
222
|
+
assert.equal((await run(worktree)).repeated, false);
|
|
223
|
+
assert.equal((await run(worktree)).repeated, true);
|
|
224
|
+
assert.equal((await run(worktree.toLowerCase())).repeated, true);
|
|
225
|
+
assert.equal(executions, 1);
|
|
226
|
+
assert.deepEqual((await readRepeatEvents(runDirectory)).map(({ payload }) => payload.workspace), [worktree, worktree.toLowerCase()]);
|
|
227
|
+
});
|
|
228
|
+
test("concurrent identical calls in one workspace execute once", async () => {
|
|
229
|
+
const { lockDirectory, repository, worktree, scope, appendRunEvent } = await fixture();
|
|
230
|
+
let executions = 0;
|
|
231
|
+
const run = () => executeRepeatSafe({ operation: "projectUse", workspace: worktree, arguments: { projectId: "demo" } }, {
|
|
232
|
+
lockDirectory,
|
|
233
|
+
scope,
|
|
234
|
+
assertReplayAllowed: async () => repository.assertWriterAvailable({ workspacePath: worktree }),
|
|
235
|
+
execute: async () => {
|
|
236
|
+
executions += 1;
|
|
237
|
+
await new Promise((done) => setTimeout(done, 50));
|
|
238
|
+
await appendRunEvent("selection.changed");
|
|
239
|
+
return { selected: true };
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
const outcomes = await Promise.all([run(), run(), run()]);
|
|
243
|
+
assert.equal(executions, 1);
|
|
244
|
+
assert.deepEqual(outcomes.map(({ repeated }) => repeated).sort(), [false, true, true]);
|
|
245
|
+
assert.equal((await repository.journal.replay()).filter(({ type }) => type === "selection.changed").length, 1);
|
|
246
|
+
await assert.rejects(lstat(join(lockDirectory, REPEAT_LOCK_FILE)), { code: "ENOENT" });
|
|
247
|
+
assert.deepEqual(await readdir(join(lockDirectory, ".repeat-guard.retired")), []);
|
|
248
|
+
});
|
|
249
|
+
test("a call that changes the selected run still serializes identical calls", async () => {
|
|
250
|
+
const { store, lockDirectory, worktree, scope } = await fixture();
|
|
251
|
+
await store.initializeProject({
|
|
252
|
+
projectId: "next",
|
|
253
|
+
displayName: "Next",
|
|
254
|
+
defaultIacTool: "bicep",
|
|
255
|
+
riskOwner: "partner",
|
|
256
|
+
});
|
|
257
|
+
await store.createRun("next", { environment: "dev", targetScope: "local", runtimeLockHash: "a".repeat(64) });
|
|
258
|
+
const nextRepository = new RunRepository(store.runDirectory("next", "run-1"));
|
|
259
|
+
let selected = "demo";
|
|
260
|
+
const currentScope = async () => {
|
|
261
|
+
if (selected === "demo")
|
|
262
|
+
return scope();
|
|
263
|
+
const state = await nextRepository.repeatState();
|
|
264
|
+
return {
|
|
265
|
+
runDirectory: store.runDirectory("next", "run-1"),
|
|
266
|
+
projectId: "next",
|
|
267
|
+
runId: "run-1",
|
|
268
|
+
ownerEpoch: state.ownerEpoch,
|
|
269
|
+
state: repeatStateToken({
|
|
270
|
+
selection: { projectId: "next", runId: "run-1" },
|
|
271
|
+
projects: ["demo", "next"],
|
|
272
|
+
...state,
|
|
273
|
+
}),
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
let executions = 0;
|
|
277
|
+
let second;
|
|
278
|
+
const run = () => executeRepeatSafe({ operation: "projectUse", workspace: worktree, arguments: { projectId: "next" } }, {
|
|
279
|
+
lockDirectory,
|
|
280
|
+
scope: currentScope,
|
|
281
|
+
assertReplayAllowed: async () => undefined,
|
|
282
|
+
execute: async () => {
|
|
283
|
+
executions += 1;
|
|
284
|
+
selected = "next";
|
|
285
|
+
// An identical call that starts once the new selection is visible, before this call publishes its record.
|
|
286
|
+
second ??= run();
|
|
287
|
+
await new Promise((done) => setTimeout(done, 50));
|
|
288
|
+
return { projectId: "next" };
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
const first = await run();
|
|
292
|
+
const repeated = await second;
|
|
293
|
+
assert.equal(executions, 1);
|
|
294
|
+
assert.equal(first.repeated, false);
|
|
295
|
+
assert.equal(repeated.repeated, true);
|
|
296
|
+
});
|
|
297
|
+
test("the call that creates the first selection is answered as a repeat", async () => {
|
|
298
|
+
const root = await mkdtemp(join(tmpdir(), "apex-repeat-first-"));
|
|
299
|
+
const lockDirectory = join(root, ".apex", "local");
|
|
300
|
+
await mkdir(join(root, ".apex"));
|
|
301
|
+
const store = new ProjectStore(root, () => new Date(), () => "run-1");
|
|
302
|
+
let repository;
|
|
303
|
+
const scope = async () => {
|
|
304
|
+
if (repository === undefined)
|
|
305
|
+
return undefined;
|
|
306
|
+
const state = await repository.repeatState();
|
|
307
|
+
return {
|
|
308
|
+
runDirectory: store.runDirectory("demo", "run-1"),
|
|
309
|
+
projectId: "demo",
|
|
310
|
+
runId: "run-1",
|
|
311
|
+
ownerEpoch: state.ownerEpoch,
|
|
312
|
+
state: repeatStateToken({ selection: { projectId: "demo", runId: "run-1" }, projects: ["demo"], ...state }),
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
let executions = 0;
|
|
316
|
+
const run = () => executeRepeatSafe({ operation: "projectCreate", workspace: root, arguments: { projectId: "demo" } }, {
|
|
317
|
+
lockDirectory,
|
|
318
|
+
scope,
|
|
319
|
+
assertReplayAllowed: async () => undefined,
|
|
320
|
+
execute: async () => {
|
|
321
|
+
executions += 1;
|
|
322
|
+
if (repository !== undefined)
|
|
323
|
+
throw new Error("project exists");
|
|
324
|
+
await store.initializeProject({
|
|
325
|
+
projectId: "demo",
|
|
326
|
+
displayName: "Demo",
|
|
327
|
+
defaultIacTool: "bicep",
|
|
328
|
+
riskOwner: "partner",
|
|
329
|
+
});
|
|
330
|
+
await store.createRun("demo", { environment: "dev", targetScope: "local", runtimeLockHash: "a".repeat(64) });
|
|
331
|
+
repository = new RunRepository(store.runDirectory("demo", "run-1"));
|
|
332
|
+
return { created: "demo" };
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
const first = await run();
|
|
336
|
+
const again = await run();
|
|
337
|
+
assert.equal(first.repeated, false);
|
|
338
|
+
assert.equal(again.repeated, true);
|
|
339
|
+
assert.deepEqual(again.value, first.value);
|
|
340
|
+
assert.equal(executions, 1);
|
|
341
|
+
});
|
|
342
|
+
test("a same-workspace writer holding the lock cannot land between a guarded call's reads and replay", async () => {
|
|
343
|
+
const { lockDirectory, worktree, scope } = await fixture();
|
|
344
|
+
let selected = "demo";
|
|
345
|
+
const currentScope = async () => {
|
|
346
|
+
const current = await scope();
|
|
347
|
+
return { ...current, state: selected === "demo" ? current.state : sha256Json({ selected }) };
|
|
348
|
+
};
|
|
349
|
+
let executions = 0;
|
|
350
|
+
const run = () => executeRepeatSafe({ operation: "projectUse", workspace: worktree, arguments: { projectId: "demo" } }, {
|
|
351
|
+
lockDirectory,
|
|
352
|
+
scope: currentScope,
|
|
353
|
+
assertReplayAllowed: async () => undefined,
|
|
354
|
+
execute: async () => ({ executions: ++executions }),
|
|
355
|
+
});
|
|
356
|
+
assert.equal((await run()).repeated, false);
|
|
357
|
+
let repeat;
|
|
358
|
+
// An unguarded writer, such as a CLI command, changes the selection while it holds the workspace lock.
|
|
359
|
+
await withRepeatGuardLock(lockDirectory, async () => {
|
|
360
|
+
repeat = run();
|
|
361
|
+
await new Promise((done) => setTimeout(done, 50));
|
|
362
|
+
selected = "next";
|
|
363
|
+
});
|
|
364
|
+
const outcome = await repeat;
|
|
365
|
+
assert.equal(outcome.repeated, false);
|
|
366
|
+
assert.equal(executions, 2);
|
|
367
|
+
});
|
|
368
|
+
test("a call cancelled before or while it waits for the lock never executes", async () => {
|
|
369
|
+
const { lockDirectory, worktree, scope } = await fixture();
|
|
370
|
+
let executed = false;
|
|
371
|
+
const call = (signal) => executeRepeatSafe({ operation: "projectUse", workspace: worktree, arguments: {} }, {
|
|
372
|
+
lockDirectory,
|
|
373
|
+
scope,
|
|
374
|
+
assertReplayAllowed: async () => undefined,
|
|
375
|
+
execute: async () => ({ ok: (executed = true) }),
|
|
376
|
+
}, { lockWaitMs: 10_000, signal });
|
|
377
|
+
await assert.rejects(call(AbortSignal.abort()), RepeatGuardCancelledError);
|
|
378
|
+
const controller = new AbortController();
|
|
379
|
+
let waiting;
|
|
380
|
+
const started = Date.now();
|
|
381
|
+
await withRepeatGuardLock(lockDirectory, async () => {
|
|
382
|
+
waiting = call(controller.signal);
|
|
383
|
+
await new Promise((done) => setTimeout(done, 50));
|
|
384
|
+
controller.abort();
|
|
385
|
+
await assert.rejects(waiting, RepeatGuardCancelledError);
|
|
386
|
+
});
|
|
387
|
+
assert.ok(Date.now() - started < 5_000);
|
|
388
|
+
assert.equal(executed, false);
|
|
389
|
+
assert.deepEqual(await readRepeatRecords((await scope()).runDirectory), []);
|
|
390
|
+
});
|
|
391
|
+
test("a holder suspended at a wait frees the lock and resumes only on unchanged state", async () => {
|
|
392
|
+
const { lockDirectory } = await fixture();
|
|
393
|
+
const lockFree = () => withRepeatGuardLock(lockDirectory, async () => "acquired", { lockWaitMs: 0 });
|
|
394
|
+
let state = "s1";
|
|
395
|
+
const token = async () => state;
|
|
396
|
+
// While the holder waits, another caller takes and releases the lock; the unchanged state lets the holder continue
|
|
397
|
+
// with the lock held again.
|
|
398
|
+
const resumed = await withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
399
|
+
await assert.rejects(lockFree(), RepeatGuardBusyError);
|
|
400
|
+
const answer = await hold.suspend(async () => {
|
|
401
|
+
assert.equal(await lockFree(), "acquired");
|
|
402
|
+
return "yes";
|
|
403
|
+
}, token);
|
|
404
|
+
await assert.rejects(lockFree(), RepeatGuardBusyError);
|
|
405
|
+
return answer;
|
|
406
|
+
});
|
|
407
|
+
assert.equal(resumed, "yes");
|
|
408
|
+
assert.equal(await lockFree(), "acquired");
|
|
409
|
+
// A write during the wait makes the continuation stale; nothing after the wait runs and the lock is released.
|
|
410
|
+
let continued = false;
|
|
411
|
+
await assert.rejects(withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
412
|
+
await hold.suspend(async () => {
|
|
413
|
+
await withRepeatGuardLock(lockDirectory, async () => {
|
|
414
|
+
state = "s2";
|
|
415
|
+
});
|
|
416
|
+
}, token);
|
|
417
|
+
continued = true;
|
|
418
|
+
}), RepeatGuardStaleError);
|
|
419
|
+
assert.equal(continued, false);
|
|
420
|
+
assert.equal(await lockFree(), "acquired");
|
|
421
|
+
// A state that cannot be read after the wait cannot be proven unchanged.
|
|
422
|
+
await assert.rejects(withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
423
|
+
let reads = 0;
|
|
424
|
+
await hold.suspend(async () => undefined, async () => {
|
|
425
|
+
if (++reads > 1)
|
|
426
|
+
throw new Error("unreadable");
|
|
427
|
+
return state;
|
|
428
|
+
});
|
|
429
|
+
}), RepeatGuardStaleError);
|
|
430
|
+
// A failed wait ends the operation without taking the lock again.
|
|
431
|
+
await assert.rejects(withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
432
|
+
await hold.suspend(async () => {
|
|
433
|
+
throw new Error("terminal closed");
|
|
434
|
+
}, token);
|
|
435
|
+
}), /terminal closed/);
|
|
436
|
+
assert.equal(await lockFree(), "acquired");
|
|
437
|
+
});
|
|
438
|
+
test("a suspended holder keeps the lock when its state is unreadable and fails closed when it cannot resume", async () => {
|
|
439
|
+
const { lockDirectory } = await fixture();
|
|
440
|
+
const lockFree = () => withRepeatGuardLock(lockDirectory, async () => "acquired", { lockWaitMs: 0 });
|
|
441
|
+
// Without a state token there is nothing to compare after the wait, so the lock stays held through it.
|
|
442
|
+
await withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
443
|
+
await hold.suspend(async () => {
|
|
444
|
+
await assert.rejects(lockFree(), RepeatGuardBusyError);
|
|
445
|
+
}, async () => {
|
|
446
|
+
throw new Error("unreadable");
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
// A caller cancelled during the wait does not continue, even when the lock is free to take again.
|
|
450
|
+
const controller = new AbortController();
|
|
451
|
+
let resumed = false;
|
|
452
|
+
await assert.rejects(withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
453
|
+
await hold.suspend(async () => controller.abort(), async () => "s");
|
|
454
|
+
resumed = true;
|
|
455
|
+
}, { signal: controller.signal }), RepeatGuardCancelledError);
|
|
456
|
+
assert.equal(resumed, false);
|
|
457
|
+
assert.equal(await lockFree(), "acquired");
|
|
458
|
+
// Suspensions do not nest.
|
|
459
|
+
await withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
460
|
+
await hold.suspend(async () => {
|
|
461
|
+
await assert.rejects(hold.suspend(async () => undefined, async () => "s"), /already suspended/);
|
|
462
|
+
}, async () => "s");
|
|
463
|
+
});
|
|
464
|
+
// Another holder that keeps the lock past the wait budget makes the resume fail as busy, not continue unlocked.
|
|
465
|
+
let release;
|
|
466
|
+
const blocker = new Promise((done) => (release = done));
|
|
467
|
+
let blocking;
|
|
468
|
+
await assert.rejects(withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
469
|
+
await hold.suspend(async () => {
|
|
470
|
+
let acquired;
|
|
471
|
+
const held = new Promise((done) => (acquired = done));
|
|
472
|
+
blocking = withRepeatGuardLock(lockDirectory, async () => {
|
|
473
|
+
acquired();
|
|
474
|
+
await blocker;
|
|
475
|
+
});
|
|
476
|
+
await held;
|
|
477
|
+
}, async () => "s");
|
|
478
|
+
}, { lockWaitMs: 50 }), RepeatGuardBusyError);
|
|
479
|
+
release();
|
|
480
|
+
await blocking;
|
|
481
|
+
assert.equal(await lockFree(), "acquired");
|
|
482
|
+
});
|
|
483
|
+
test("a workspace created while an unserialized holder waits is locked when the holder resumes", async () => {
|
|
484
|
+
const root = await mkdtemp(join(tmpdir(), "apex-repeat-guard-"));
|
|
485
|
+
const lockDirectory = join(root, ".apex", "local");
|
|
486
|
+
await withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
487
|
+
await hold.suspend(async () => {
|
|
488
|
+
await mkdir(join(root, ".apex"));
|
|
489
|
+
}, async () => "s");
|
|
490
|
+
await assert.rejects(withRepeatGuardLock(lockDirectory, async () => undefined, { lockWaitMs: 0 }), RepeatGuardBusyError);
|
|
491
|
+
});
|
|
492
|
+
assert.equal(await withRepeatGuardLock(lockDirectory, async () => "acquired", { lockWaitMs: 0 }), "acquired");
|
|
493
|
+
});
|
|
494
|
+
test("a holder sharing the lock serves snapshot reads while writers still wait", async () => {
|
|
495
|
+
const { lockDirectory } = await fixture();
|
|
496
|
+
const read = (lockWaitMs = 10_000) => withRepeatGuardReadLock(lockDirectory, async (mode) => mode, { lockWaitMs });
|
|
497
|
+
const lockFree = () => withRepeatGuardLock(lockDirectory, async () => "acquired", { lockWaitMs: 0 });
|
|
498
|
+
// A free lock is taken as before.
|
|
499
|
+
assert.equal(await read(), "locked");
|
|
500
|
+
await withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
501
|
+
// Held but not shared: reads wait for the lock like writers.
|
|
502
|
+
await assert.rejects(read(50), RepeatGuardBusyError);
|
|
503
|
+
const value = await hold.share(async () => {
|
|
504
|
+
const started = Date.now();
|
|
505
|
+
assert.equal(await read(), "snapshot");
|
|
506
|
+
assert.ok(Date.now() - started < 5_000);
|
|
507
|
+
await assert.rejects(lockFree(), RepeatGuardBusyError);
|
|
508
|
+
// Nested shares keep one window open until the outermost settles.
|
|
509
|
+
await hold.share(async () => assert.equal(await read(), "snapshot"));
|
|
510
|
+
assert.equal(await read(), "snapshot");
|
|
511
|
+
return "provider result";
|
|
512
|
+
});
|
|
513
|
+
assert.equal(value, "provider result");
|
|
514
|
+
// The window closed before share returned, so the holder may write again.
|
|
515
|
+
await assert.rejects(read(50), RepeatGuardBusyError);
|
|
516
|
+
});
|
|
517
|
+
assert.equal(await lockFree(), "acquired");
|
|
518
|
+
// A failed operation closes the window too, and its error is the holder's.
|
|
519
|
+
await withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
520
|
+
await assert.rejects(hold.share(async () => {
|
|
521
|
+
throw new Error("provider failed");
|
|
522
|
+
}), /provider failed/);
|
|
523
|
+
await assert.rejects(read(50), RepeatGuardBusyError);
|
|
524
|
+
});
|
|
525
|
+
// A workspace without state has no lock to share.
|
|
526
|
+
const empty = join(await mkdtemp(join(tmpdir(), "apex-repeat-guard-")), "missing", "local");
|
|
527
|
+
assert.equal(await withRepeatGuardLock(empty, async (hold) => hold.share(async () => "unlocked")), "unlocked");
|
|
528
|
+
});
|
|
529
|
+
test("concurrent shares run in one window and none returns before it closed", async () => {
|
|
530
|
+
const { lockDirectory } = await fixture();
|
|
531
|
+
const read = (lockWaitMs = 10_000) => withRepeatGuardReadLock(lockDirectory, async (mode) => mode, { lockWaitMs });
|
|
532
|
+
await withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
533
|
+
const started = [];
|
|
534
|
+
const returned = [];
|
|
535
|
+
let finishSlow;
|
|
536
|
+
const slowFinished = new Promise((done) => (finishSlow = done));
|
|
537
|
+
const fast = hold
|
|
538
|
+
.share(async () => {
|
|
539
|
+
started.push("fast");
|
|
540
|
+
return "fast";
|
|
541
|
+
})
|
|
542
|
+
.then((value) => (returned.push(value), value));
|
|
543
|
+
const slow = hold
|
|
544
|
+
.share(async () => {
|
|
545
|
+
started.push("slow");
|
|
546
|
+
await slowFinished;
|
|
547
|
+
return "slow";
|
|
548
|
+
})
|
|
549
|
+
.then((value) => (returned.push(value), value));
|
|
550
|
+
await new Promise((done) => setTimeout(done, 50));
|
|
551
|
+
// Both operations run at once, but the settled one does not return into a holder that could write while the
|
|
552
|
+
// other keeps the window open.
|
|
553
|
+
assert.deepEqual(started.sort(), ["fast", "slow"]);
|
|
554
|
+
assert.deepEqual(returned, []);
|
|
555
|
+
assert.equal(await read(), "snapshot");
|
|
556
|
+
finishSlow();
|
|
557
|
+
assert.deepEqual(await Promise.all([fast, slow]), ["fast", "slow"]);
|
|
558
|
+
await assert.rejects(read(50), RepeatGuardBusyError);
|
|
559
|
+
// A share started after the window closed opens a new one.
|
|
560
|
+
assert.equal(await hold.share(async () => read()), "snapshot");
|
|
561
|
+
await assert.rejects(read(50), RepeatGuardBusyError);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
test("a holder write from another flow waits while a share window is open", async () => {
|
|
565
|
+
const { lockDirectory } = await fixture();
|
|
566
|
+
const state = join(lockDirectory, "..", "state.json");
|
|
567
|
+
const providerRecord = join(lockDirectory, "..", "provider-record.json");
|
|
568
|
+
await atomicWriteJson(state, { value: "before" });
|
|
569
|
+
const readState = () => withRepeatGuardReadLock(lockDirectory, async (mode) => ({ mode, value: JSON.parse(await readFile(state, "utf8")).value }), { lockWaitMs: 10_000 });
|
|
570
|
+
await withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
571
|
+
let finish;
|
|
572
|
+
const finished = new Promise((done) => (finish = done));
|
|
573
|
+
let opened;
|
|
574
|
+
const windowOpen = new Promise((done) => (opened = done));
|
|
575
|
+
const sharing = hold.share(async () => {
|
|
576
|
+
// The shared operation's own records, such as a provider's, are not held back.
|
|
577
|
+
await atomicWriteJson(providerRecord, { private: true });
|
|
578
|
+
opened();
|
|
579
|
+
await finished;
|
|
580
|
+
});
|
|
581
|
+
await windowOpen;
|
|
582
|
+
// A flow other than the shared operation, such as one whose own share already returned, writes meanwhile.
|
|
583
|
+
let written = false;
|
|
584
|
+
const writing = atomicWriteJson(state, { value: "written" }).then(() => (written = true));
|
|
585
|
+
await new Promise((done) => setTimeout(done, 50));
|
|
586
|
+
assert.equal(written, false, "a holder write must not land while readers trust the window");
|
|
587
|
+
assert.deepEqual(await readState(), { mode: "snapshot", value: "before" });
|
|
588
|
+
finish();
|
|
589
|
+
await Promise.all([sharing, writing]);
|
|
590
|
+
assert.equal(written, true);
|
|
591
|
+
assert.deepEqual(JSON.parse(await readFile(providerRecord, "utf8")), { private: true });
|
|
592
|
+
// A share waits for a write in flight before it opens a window.
|
|
593
|
+
let wrote = false;
|
|
594
|
+
const write = atomicWriteJson(state, { value: "again" }).then(() => (wrote = true));
|
|
595
|
+
assert.equal(await hold.share(async () => wrote), true);
|
|
596
|
+
await write;
|
|
597
|
+
});
|
|
598
|
+
assert.deepEqual(await readState(), { mode: "locked", value: "again" });
|
|
599
|
+
});
|
|
600
|
+
test("a share marker that is not a small regular file is not trusted", async () => {
|
|
601
|
+
const { lockDirectory } = await fixture();
|
|
602
|
+
const marker = join(lockDirectory, REPEAT_LOCK_FILE, "shared.json");
|
|
603
|
+
const read = (lockWaitMs = 10_000) => withRepeatGuardReadLock(lockDirectory, async (mode) => mode, { lockWaitMs });
|
|
604
|
+
await withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
605
|
+
await hold.share(async () => {
|
|
606
|
+
const valid = await readFile(marker);
|
|
607
|
+
const outside = join(lockDirectory, "..", "marker-copy.json");
|
|
608
|
+
await writeFile(outside, valid);
|
|
609
|
+
await rm(marker);
|
|
610
|
+
let linked = true;
|
|
611
|
+
try {
|
|
612
|
+
await symlink(outside, marker);
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
// Windows without symlink privileges cannot create the link; the size check below still runs.
|
|
616
|
+
linked = false;
|
|
617
|
+
}
|
|
618
|
+
if (linked) {
|
|
619
|
+
await assert.rejects(read(50), RepeatGuardBusyError);
|
|
620
|
+
await rm(marker);
|
|
621
|
+
}
|
|
622
|
+
// Still parseable as the marker, but over the size bound.
|
|
623
|
+
await writeFile(marker, Buffer.concat([valid, Buffer.alloc(8 * 1024, " ")]));
|
|
624
|
+
await assert.rejects(read(50), RepeatGuardBusyError);
|
|
625
|
+
await rm(marker);
|
|
626
|
+
await writeFile(marker, valid);
|
|
627
|
+
assert.equal(await read(), "snapshot");
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
test("a snapshot read that overlaps the end of a share window is discarded and answered under the lock", async () => {
|
|
632
|
+
const { lockDirectory } = await fixture();
|
|
633
|
+
let state = "before";
|
|
634
|
+
let closeWindow;
|
|
635
|
+
let windowOpen;
|
|
636
|
+
const opened = new Promise((done) => (windowOpen = done));
|
|
637
|
+
const holder = withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
638
|
+
await hold.share(() => new Promise((done) => {
|
|
639
|
+
closeWindow = done;
|
|
640
|
+
windowOpen();
|
|
641
|
+
}));
|
|
642
|
+
// The holder writes only after the window closed.
|
|
643
|
+
await new Promise((done) => setTimeout(done, 50));
|
|
644
|
+
state = "after";
|
|
645
|
+
});
|
|
646
|
+
await opened;
|
|
647
|
+
const modes = [];
|
|
648
|
+
const value = await withRepeatGuardReadLock(lockDirectory, async (mode) => {
|
|
649
|
+
modes.push(mode);
|
|
650
|
+
const seen = state;
|
|
651
|
+
if (modes.length === 1) {
|
|
652
|
+
closeWindow();
|
|
653
|
+
// Keep reading until the holder has written: this snapshot is torn and must not be answered.
|
|
654
|
+
while (state === "before")
|
|
655
|
+
await new Promise((done) => setTimeout(done, 5));
|
|
656
|
+
}
|
|
657
|
+
return seen;
|
|
658
|
+
}, { lockWaitMs: 10_000 });
|
|
659
|
+
await holder;
|
|
660
|
+
assert.equal(modes[0], "snapshot");
|
|
661
|
+
assert.equal(modes.at(-1), "locked");
|
|
662
|
+
assert.equal(value, "after");
|
|
663
|
+
});
|
|
664
|
+
test("a snapshot read that needs the lock waits for it; a snapshot failure is the read's own", async () => {
|
|
665
|
+
const { lockDirectory } = await fixture();
|
|
666
|
+
await withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
667
|
+
await hold.share(async () => {
|
|
668
|
+
// A read that would have to recover state is not served from the snapshot.
|
|
669
|
+
const modes = [];
|
|
670
|
+
await assert.rejects(withRepeatGuardReadLock(lockDirectory, async (mode) => {
|
|
671
|
+
modes.push(mode);
|
|
672
|
+
throw new RepeatGuardSnapshotUnavailableError();
|
|
673
|
+
}, { lockWaitMs: 100 }), RepeatGuardBusyError);
|
|
674
|
+
// It is tried once; only the lock holder can finish the recovery, so the read then just waits for the lock.
|
|
675
|
+
assert.deepEqual(modes, ["snapshot"]);
|
|
676
|
+
// Any other failure in an unchanged window is the read's result.
|
|
677
|
+
await assert.rejects(withRepeatGuardReadLock(lockDirectory, async () => {
|
|
678
|
+
throw new Error("not found");
|
|
679
|
+
}), /not found/);
|
|
680
|
+
// Cancellation stops the wait.
|
|
681
|
+
await assert.rejects(withRepeatGuardReadLock(lockDirectory, async () => "never", { signal: AbortSignal.abort() }), RepeatGuardCancelledError);
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
// The window marker leaves no residue in the lock directory once the lock is released.
|
|
685
|
+
assert.deepEqual((await readdir(lockDirectory)).filter((name) => !name.startsWith(".repeat-guard.retired")), []);
|
|
686
|
+
});
|
|
687
|
+
test("a share window that cannot be closed fences every later write of its holder until release", async () => {
|
|
688
|
+
const { lockDirectory } = await fixture();
|
|
689
|
+
const marker = join(lockDirectory, REPEAT_LOCK_FILE, "shared.json");
|
|
690
|
+
const state = join(lockDirectory, "..", "state.json");
|
|
691
|
+
const failures = [];
|
|
692
|
+
let snapshot;
|
|
693
|
+
await assert.rejects(withRepeatGuardLock(lockDirectory, async (hold) => {
|
|
694
|
+
try {
|
|
695
|
+
await hold.share(async () => {
|
|
696
|
+
snapshot = await withRepeatGuardReadLock(lockDirectory, async (mode) => mode);
|
|
697
|
+
// Something the holder cannot remove now stands where the window marker was.
|
|
698
|
+
await rm(marker);
|
|
699
|
+
await mkdir(join(marker, "held"), { recursive: true });
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
// A holder that handles the failure as an ordinary process error still cannot write or share again.
|
|
704
|
+
failures.push(error);
|
|
705
|
+
}
|
|
706
|
+
failures.push(await atomicWriteJson(state, { written: true }).catch((error) => error));
|
|
707
|
+
failures.push(await hold.share(async () => "shared").catch((error) => error));
|
|
708
|
+
return "continued";
|
|
709
|
+
}), RepeatGuardShareCloseError);
|
|
710
|
+
assert.equal(snapshot, "snapshot");
|
|
711
|
+
assert.equal(failures.length, 3);
|
|
712
|
+
for (const failure of failures)
|
|
713
|
+
assert.ok(failure instanceof RepeatGuardShareCloseError);
|
|
714
|
+
await assert.rejects(readFile(state), { code: "ENOENT" });
|
|
715
|
+
// Releasing the lock took the window with it.
|
|
716
|
+
assert.equal(await withRepeatGuardReadLock(lockDirectory, async (mode) => mode, { lockWaitMs: 0 }), "locked");
|
|
717
|
+
});
|
|
718
|
+
test("a live repeat lock makes callers fail closed; an expired dead holder's lock is taken over", async () => {
|
|
719
|
+
const { lockDirectory, worktree, scope } = await fixture();
|
|
720
|
+
const call = (lockWaitMs) => executeRepeatSafe({ operation: "projectUse", workspace: worktree, arguments: {} }, { lockDirectory, scope, assertReplayAllowed: async () => undefined, execute: async () => ({ ok: true }) }, { lockWaitMs });
|
|
721
|
+
const lock = join(lockDirectory, REPEAT_LOCK_FILE);
|
|
722
|
+
const publish = async (token, pid, expiresAt) => {
|
|
723
|
+
await mkdir(lock, { recursive: true });
|
|
724
|
+
await writeFile(join(lock, "metadata.json"), JSON.stringify({
|
|
725
|
+
token,
|
|
726
|
+
pid,
|
|
727
|
+
host: hostname(),
|
|
728
|
+
createdAt: new Date(expiresAt.getTime() - 30_000).toISOString(),
|
|
729
|
+
expiresAt: expiresAt.toISOString(),
|
|
730
|
+
}));
|
|
731
|
+
};
|
|
732
|
+
// A live holder is never taken over, even after its generation expired.
|
|
733
|
+
await publish("live", process.pid, new Date(Date.now() - 1_000));
|
|
734
|
+
await assert.rejects(call(60), RepeatGuardBusyError);
|
|
735
|
+
await rm(lock, { recursive: true });
|
|
736
|
+
await publish("dead", 2_147_483_647, new Date(Date.now() - 1_000));
|
|
737
|
+
assert.equal((await call(0)).repeated, false);
|
|
738
|
+
await assert.rejects(lstat(lock), { code: "ENOENT" });
|
|
739
|
+
const tombstones = await readdir(join(lockDirectory, ".repeat-guard.retired"));
|
|
740
|
+
assert.equal(tombstones.length, 1);
|
|
741
|
+
assert.equal(JSON.parse(await readFile(join(lockDirectory, ".repeat-guard.retired", tombstones[0], "metadata.json"), "utf8"))
|
|
742
|
+
.token, "dead");
|
|
743
|
+
});
|
|
744
|
+
test("a delayed stale repeat-lock contender cannot remove a replacement lock", async () => {
|
|
745
|
+
const { lockDirectory, worktree, scope } = await fixture();
|
|
746
|
+
const lock = join(lockDirectory, REPEAT_LOCK_FILE);
|
|
747
|
+
const deadPid = 2_147_483_647;
|
|
748
|
+
await mkdir(lock, { recursive: true });
|
|
749
|
+
await writeFile(join(lock, "metadata.json"), JSON.stringify({
|
|
750
|
+
token: "dead",
|
|
751
|
+
pid: deadPid,
|
|
752
|
+
host: hostname(),
|
|
753
|
+
createdAt: new Date(Date.now() - 60_000).toISOString(),
|
|
754
|
+
expiresAt: new Date(Date.now() - 30_000).toISOString(),
|
|
755
|
+
}));
|
|
756
|
+
const originalKill = process.kill;
|
|
757
|
+
let swapped = false;
|
|
758
|
+
// Between the contender's snapshot and its liveness check, another contender takes the stale lock over and a new
|
|
759
|
+
// owner publishes a replacement.
|
|
760
|
+
process.kill = ((pid, signal) => {
|
|
761
|
+
if (pid !== deadPid)
|
|
762
|
+
return originalKill.call(process, pid, signal);
|
|
763
|
+
if (!swapped) {
|
|
764
|
+
swapped = true;
|
|
765
|
+
fs.rmSync(lock, { recursive: true });
|
|
766
|
+
fs.mkdirSync(lock);
|
|
767
|
+
fs.writeFileSync(join(lock, "metadata.json"), JSON.stringify({
|
|
768
|
+
token: "replacement",
|
|
769
|
+
pid: process.pid,
|
|
770
|
+
host: hostname(),
|
|
771
|
+
createdAt: new Date().toISOString(),
|
|
772
|
+
expiresAt: new Date(Date.now() + 30_000).toISOString(),
|
|
773
|
+
}));
|
|
774
|
+
}
|
|
775
|
+
throw Object.assign(new Error("ESRCH: injected"), { code: "ESRCH" });
|
|
776
|
+
});
|
|
777
|
+
let executed = false;
|
|
778
|
+
try {
|
|
779
|
+
await assert.rejects(executeRepeatSafe({ operation: "projectUse", workspace: worktree, arguments: {} }, {
|
|
780
|
+
lockDirectory,
|
|
781
|
+
scope,
|
|
782
|
+
assertReplayAllowed: async () => undefined,
|
|
783
|
+
execute: async () => ({ ok: (executed = true) }),
|
|
784
|
+
}, { lockWaitMs: 60 }), RepeatGuardBusyError);
|
|
785
|
+
}
|
|
786
|
+
finally {
|
|
787
|
+
process.kill = originalKill;
|
|
788
|
+
}
|
|
789
|
+
assert.equal(swapped, true);
|
|
790
|
+
assert.equal(executed, false);
|
|
791
|
+
assert.equal(JSON.parse(await readFile(join(lock, "metadata.json"), "utf8")).token, "replacement");
|
|
792
|
+
});
|
|
793
|
+
test("a call whose identity changed is never replayed, and is returned but not stored", async () => {
|
|
794
|
+
const { runDirectory, repository, worktree, lockDirectory, scope } = await fixture();
|
|
795
|
+
let executions = 0;
|
|
796
|
+
const run = (stable) => executeRepeatSafe({ operation: "governanceImport", workspace: worktree, arguments: { path: "baseline.json" } }, {
|
|
797
|
+
lockDirectory,
|
|
798
|
+
scope,
|
|
799
|
+
assertReplayAllowed: async () => repository.assertWriterAvailable({ workspacePath: worktree }),
|
|
800
|
+
execute: async () => ({ executions: ++executions }),
|
|
801
|
+
identityStable: async () => stable,
|
|
802
|
+
});
|
|
803
|
+
assert.deepEqual((await run(false)).value, { executions: 1 });
|
|
804
|
+
assert.deepEqual(await readRepeatRecords(runDirectory), []);
|
|
805
|
+
assert.equal((await run(true)).repeated, false);
|
|
806
|
+
assert.equal((await run(true)).repeated, true);
|
|
807
|
+
assert.equal(executions, 2);
|
|
808
|
+
// A bound file that changed after the identity was taken, for example while waiting for the lock, is never answered
|
|
809
|
+
// from the stored result; the call executes against the current file instead.
|
|
810
|
+
const changed = await run(false);
|
|
811
|
+
assert.equal(changed.repeated, false);
|
|
812
|
+
assert.deepEqual(changed.value, { executions: 3 });
|
|
813
|
+
assert.equal(executions, 3);
|
|
814
|
+
});
|
|
815
|
+
const PINNED_SECONDS = 1_700_000_000;
|
|
816
|
+
/** Writes `content` with whole-second access and modification times, so a later edit can restore them exactly. */
|
|
817
|
+
async function writePinned(path, content) {
|
|
818
|
+
await writeFile(path, content);
|
|
819
|
+
await utimes(path, PINNED_SECONDS, PINNED_SECONDS);
|
|
820
|
+
}
|
|
821
|
+
/** Rewrites a pinned file with same-length content and restores its size and modification time exactly. */
|
|
822
|
+
async function sameStatEdit(path, content) {
|
|
823
|
+
const before = await stat(path, { bigint: true });
|
|
824
|
+
assert.equal(BigInt(Buffer.byteLength(content)), before.size);
|
|
825
|
+
await writePinned(path, content);
|
|
826
|
+
const after = await stat(path, { bigint: true });
|
|
827
|
+
assert.equal(after.size, before.size);
|
|
828
|
+
assert.equal(after.mtimeNs, before.mtimeNs);
|
|
829
|
+
}
|
|
830
|
+
test("bound files hold only through the operation's own writes, and a size- and mtime-preserving edit is detected", async () => {
|
|
831
|
+
const root = await mkdtemp(join(tmpdir(), "apex-repeat-files-"));
|
|
832
|
+
const work = join(root, "work");
|
|
833
|
+
const staged = join(work, "task-1", "code", "main.bicep");
|
|
834
|
+
await mkdir(dirname(staged), { recursive: true });
|
|
835
|
+
await writePinned(staged, "param a string\n");
|
|
836
|
+
const start = await snapshotRepeatFiles([work, join(work, "task-1", "code")]);
|
|
837
|
+
assert.deepEqual(start.roots, [work, join(work, "task-1", "code")].sort());
|
|
838
|
+
assert.equal(start.entries[staged]?.type, "file");
|
|
839
|
+
assert.equal(repeatFilesHeld(start, await snapshotRepeatFiles(start.roots)), true);
|
|
840
|
+
// A write the operation reports, including the directories it creates, holds; an unreported one does not.
|
|
841
|
+
const written = join(work, "task-2", "requirements.json");
|
|
842
|
+
await mkdir(dirname(written), { recursive: true });
|
|
843
|
+
await writeFile(written, "{}");
|
|
844
|
+
const writes = new RepeatFileWrites();
|
|
845
|
+
writes.file(written, sha256Text("{}"));
|
|
846
|
+
const end = await snapshotRepeatFiles(start.roots);
|
|
847
|
+
assert.equal(repeatFilesHeld(start, end, writes), true);
|
|
848
|
+
assert.equal(repeatFilesHeld(start, end), false);
|
|
849
|
+
const wrong = new RepeatFileWrites();
|
|
850
|
+
wrong.file(written, sha256Text("[]"));
|
|
851
|
+
assert.equal(repeatFilesHeld(start, end, wrong), false);
|
|
852
|
+
// A content change that keeps every stat field is still a change, because files are bound by content.
|
|
853
|
+
const forged = structuredClone(start);
|
|
854
|
+
forged.entries[staged] = { ...forged.entries[staged] };
|
|
855
|
+
forged.entries[staged].sha256 = sha256Text("param b string\n");
|
|
856
|
+
assert.equal(repeatFilesHeld(start, forged), false);
|
|
857
|
+
assert.notEqual(repeatFilesDigest(forged), repeatFilesDigest(start));
|
|
858
|
+
await sameStatEdit(staged, "param b string\n");
|
|
859
|
+
const edited = await snapshotRepeatFiles(start.roots);
|
|
860
|
+
assert.notEqual(edited.entries[staged].sha256, start.entries[staged].sha256);
|
|
861
|
+
assert.equal(repeatFilesHeld(start, edited, writes), false);
|
|
862
|
+
// A removal the operation reports holds for the whole removed tree.
|
|
863
|
+
const removing = await snapshotRepeatFiles([work]);
|
|
864
|
+
await rm(join(work, "task-2"), { recursive: true });
|
|
865
|
+
const removal = new RepeatFileWrites();
|
|
866
|
+
removal.removed(join(work, "task-2"));
|
|
867
|
+
assert.equal(repeatFilesHeld(removing, await snapshotRepeatFiles([work]), removal), true);
|
|
868
|
+
assert.equal(repeatFilesHeld(removing, await snapshotRepeatFiles([work])), false);
|
|
869
|
+
// An end snapshot that does not cover every start root cannot show the start roots unchanged.
|
|
870
|
+
assert.equal(repeatFilesHeld(removing, await snapshotRepeatFiles([join(root, "other")])), false);
|
|
871
|
+
});
|
|
872
|
+
test("a repeat file snapshot streams large files, fails closed over budget, and refuses a linked root", async () => {
|
|
873
|
+
const root = await mkdtemp(join(tmpdir(), "apex-repeat-budget-"));
|
|
874
|
+
const large = join(root, "large.bin");
|
|
875
|
+
const bytes = Buffer.alloc(300 * 1024, 7);
|
|
876
|
+
await writeFile(large, bytes);
|
|
877
|
+
const snapshot = await snapshotRepeatFiles([root]);
|
|
878
|
+
assert.equal(snapshot.entries[large].sha256, sha256Bytes(bytes));
|
|
879
|
+
await assert.rejects(snapshotRepeatFiles([root], { maxBytes: bytes.byteLength - 1 }), RepeatFileSnapshotError);
|
|
880
|
+
await assert.rejects(snapshotRepeatFiles([root], { maxEntries: 1 }), RepeatFileSnapshotError);
|
|
881
|
+
assert.deepEqual((await snapshotRepeatFiles([join(root, "missing")])).entries, {});
|
|
882
|
+
if (process.platform !== "win32") {
|
|
883
|
+
const linked = join(root, "linked");
|
|
884
|
+
await symlink(root, linked);
|
|
885
|
+
await assert.rejects(snapshotRepeatFiles([linked]), RepeatFileSnapshotError);
|
|
886
|
+
await assert.rejects(snapshotRepeatFiles([root, linked]), RepeatFileSnapshotError);
|
|
887
|
+
}
|
|
888
|
+
await assert.rejects(snapshotRepeatFiles([root], { attempts: 0 }), RangeError);
|
|
889
|
+
});
|
|
890
|
+
async function boundFixture() {
|
|
891
|
+
const base = await fixture();
|
|
892
|
+
const work = join(base.root, ".apex", "work", "run-1");
|
|
893
|
+
const staged = join(work, "task-1", "code", "main.bicep");
|
|
894
|
+
await mkdir(dirname(staged), { recursive: true });
|
|
895
|
+
await writePinned(staged, "param a string\n");
|
|
896
|
+
let roots = [work];
|
|
897
|
+
let failNextScope = false;
|
|
898
|
+
const scope = async () => {
|
|
899
|
+
if (failNextScope) {
|
|
900
|
+
failNextScope = false;
|
|
901
|
+
throw new Error("scope unavailable");
|
|
902
|
+
}
|
|
903
|
+
const state = await base.repository.repeatState();
|
|
904
|
+
const files = await snapshotRepeatFiles(roots);
|
|
905
|
+
return {
|
|
906
|
+
runDirectory: base.runDirectory,
|
|
907
|
+
projectId: "demo",
|
|
908
|
+
runId: "run-1",
|
|
909
|
+
ownerEpoch: state.ownerEpoch,
|
|
910
|
+
state: repeatStateToken({
|
|
911
|
+
selection: { projectId: "demo", runId: "run-1" },
|
|
912
|
+
projects: ["demo"],
|
|
913
|
+
...state,
|
|
914
|
+
files: repeatFilesDigest(files),
|
|
915
|
+
}),
|
|
916
|
+
files,
|
|
917
|
+
};
|
|
918
|
+
};
|
|
919
|
+
let executions = 0;
|
|
920
|
+
const run = (effect = async () => undefined) => {
|
|
921
|
+
const writes = new RepeatFileWrites();
|
|
922
|
+
return executeRepeatSafe({ operation: "validateTask", workspace: base.worktree, arguments: { taskId: "task-1" } }, {
|
|
923
|
+
lockDirectory: base.lockDirectory,
|
|
924
|
+
scope,
|
|
925
|
+
assertReplayAllowed: async () => undefined,
|
|
926
|
+
execute: async () => {
|
|
927
|
+
executions += 1;
|
|
928
|
+
await effect(writes);
|
|
929
|
+
return { executions };
|
|
930
|
+
},
|
|
931
|
+
writes,
|
|
932
|
+
});
|
|
933
|
+
};
|
|
934
|
+
return {
|
|
935
|
+
...base,
|
|
936
|
+
work,
|
|
937
|
+
staged,
|
|
938
|
+
run,
|
|
939
|
+
executions: () => executions,
|
|
940
|
+
setRoots(next) {
|
|
941
|
+
roots = next;
|
|
942
|
+
},
|
|
943
|
+
failNextScope() {
|
|
944
|
+
failNextScope = true;
|
|
945
|
+
},
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
test("a bound file edited while the operation runs never binds the result, and the repeat is a new request", async () => {
|
|
949
|
+
const { runDirectory, staged, run, executions } = await boundFixture();
|
|
950
|
+
// The operation read the staged file at its start; an editor changes it before the operation returns.
|
|
951
|
+
const raced = await run(async () => writePinned(staged, "param b string\n"));
|
|
952
|
+
assert.equal(raced.repeated, false);
|
|
953
|
+
assert.deepEqual(await readRepeatRecords(runDirectory), []);
|
|
954
|
+
assert.equal((await run()).repeated, false);
|
|
955
|
+
assert.equal(executions(), 2);
|
|
956
|
+
assert.equal((await readRepeatRecords(runDirectory)).length, 1);
|
|
957
|
+
assert.equal((await run()).repeated, true);
|
|
958
|
+
assert.equal(executions(), 2);
|
|
959
|
+
// The same holds for an edit that keeps the file's size and modification time.
|
|
960
|
+
const sameStat = await run(async () => undefined);
|
|
961
|
+
assert.equal(sameStat.repeated, true);
|
|
962
|
+
const stored = await readRepeatRecords(runDirectory);
|
|
963
|
+
await sameStatEdit(staged, "param c string\n");
|
|
964
|
+
// The stored record no longer matches the edited content, so the identical call executes.
|
|
965
|
+
const executed = await run(async () => sameStatEdit(staged, "param d string\n"));
|
|
966
|
+
assert.equal(executed.repeated, false);
|
|
967
|
+
assert.equal(executions(), 3);
|
|
968
|
+
assert.deepEqual(await readRepeatRecords(runDirectory), stored);
|
|
969
|
+
});
|
|
970
|
+
test("an operation's own writes to bound files are stored, an edit after them is not", async () => {
|
|
971
|
+
const { runDirectory, work, run, executions } = await boundFixture();
|
|
972
|
+
const output = join(work, "task-2", "requirements.json");
|
|
973
|
+
const write = async (writes) => {
|
|
974
|
+
await mkdir(dirname(output), { recursive: true });
|
|
975
|
+
await writeFile(output, "{}");
|
|
976
|
+
writes.file(output, sha256Text("{}"));
|
|
977
|
+
};
|
|
978
|
+
assert.equal((await run(write)).repeated, false);
|
|
979
|
+
assert.equal((await readRepeatRecords(runDirectory)).length, 1);
|
|
980
|
+
assert.equal((await run(write)).repeated, true);
|
|
981
|
+
assert.equal(executions(), 1);
|
|
982
|
+
// An editor that rewrites the operation's output before the call returns keeps the result from being stored.
|
|
983
|
+
await rm(join(work, "task-2"), { recursive: true });
|
|
984
|
+
const stored = await readRepeatRecords(runDirectory);
|
|
985
|
+
const edited = await run(async (writes) => {
|
|
986
|
+
await write(writes);
|
|
987
|
+
await writeFile(output, "[]");
|
|
988
|
+
});
|
|
989
|
+
assert.equal(edited.repeated, false);
|
|
990
|
+
assert.deepEqual(await readRepeatRecords(runDirectory), stored);
|
|
991
|
+
assert.equal((await run()).repeated, false);
|
|
992
|
+
assert.equal(executions(), 3);
|
|
993
|
+
});
|
|
994
|
+
test("bound roots a call leaves are rechecked, and an unreadable start state never stores a result", async () => {
|
|
995
|
+
const { runDirectory, root, work, staged, run, executions, setRoots, failNextScope } = await boundFixture();
|
|
996
|
+
const next = join(root, ".apex", "work", "run-2");
|
|
997
|
+
// A call that changes the bound roots, such as one that selects another run, is still checked against the roots it
|
|
998
|
+
// started with.
|
|
999
|
+
const switching = (edit) => run(async () => {
|
|
1000
|
+
setRoots([next]);
|
|
1001
|
+
if (edit)
|
|
1002
|
+
await writeFile(staged, "param b string\n");
|
|
1003
|
+
});
|
|
1004
|
+
assert.equal((await switching(true)).repeated, false);
|
|
1005
|
+
assert.deepEqual(await readRepeatRecords(runDirectory), []);
|
|
1006
|
+
setRoots([work]);
|
|
1007
|
+
assert.equal((await switching(false)).repeated, false);
|
|
1008
|
+
const stored = await readRepeatRecords(runDirectory);
|
|
1009
|
+
assert.equal(stored.length, 1);
|
|
1010
|
+
setRoots([work]);
|
|
1011
|
+
failNextScope();
|
|
1012
|
+
assert.equal((await run()).repeated, false);
|
|
1013
|
+
assert.deepEqual(await readRepeatRecords(runDirectory), stored);
|
|
1014
|
+
assert.equal(executions(), 3);
|
|
1015
|
+
});
|
|
1016
|
+
test("repeat records survive a restart because they are stored with the run", async () => {
|
|
1017
|
+
const { call, runDirectory, worktree, lockDirectory } = await fixture();
|
|
1018
|
+
const first = await call();
|
|
1019
|
+
const stored = JSON.parse(await readFile(join(runDirectory, REPEAT_GUARD_FILE), "utf8"));
|
|
1020
|
+
assert.equal(stored.schemaVersion, CONTRACT_VERSION);
|
|
1021
|
+
registerContractFormats();
|
|
1022
|
+
assert.equal(Value.Check(RepeatGuardRecordsV1Schema, stored), true);
|
|
1023
|
+
assert.equal(stored.records.length, 1);
|
|
1024
|
+
assert.equal(stored.records[0].result, JSON.stringify(first.value));
|
|
1025
|
+
assert.equal(stored.records[0].fingerprint, first.fingerprint);
|
|
1026
|
+
// A new process: fresh repository, hooks and clock; only the run directory is shared.
|
|
1027
|
+
const restarted = new RunRepository(runDirectory);
|
|
1028
|
+
let executed = false;
|
|
1029
|
+
const again = await executeRepeatSafe({ operation: "stageArtifact", workspace: worktree, arguments: { taskId: "task-1", value: { b: 1, a: [1, 2] } } }, {
|
|
1030
|
+
lockDirectory,
|
|
1031
|
+
scope: async () => {
|
|
1032
|
+
const state = await restarted.repeatState();
|
|
1033
|
+
return {
|
|
1034
|
+
runDirectory,
|
|
1035
|
+
projectId: "demo",
|
|
1036
|
+
runId: "run-1",
|
|
1037
|
+
ownerEpoch: state.ownerEpoch,
|
|
1038
|
+
state: repeatStateToken({ selection: { projectId: "demo", runId: "run-1" }, projects: ["demo"], ...state }),
|
|
1039
|
+
};
|
|
1040
|
+
},
|
|
1041
|
+
assertReplayAllowed: async () => restarted.assertWriterAvailable({ workspacePath: worktree }),
|
|
1042
|
+
execute: async () => {
|
|
1043
|
+
executed = true;
|
|
1044
|
+
return {};
|
|
1045
|
+
},
|
|
1046
|
+
}, { clock: () => new Date("2026-01-01T00:00:01.000Z") });
|
|
1047
|
+
assert.equal(again.repeated, true);
|
|
1048
|
+
assert.equal(executed, false);
|
|
1049
|
+
assert.equal(JSON.stringify(again.value), JSON.stringify(first.value));
|
|
1050
|
+
});
|
|
1051
|
+
test("replay never bypasses the writer lease of another worktree", async () => {
|
|
1052
|
+
const { call, executions, repository, worktree, other, runDirectory, advance } = await fixture();
|
|
1053
|
+
await call();
|
|
1054
|
+
await assert.rejects(call(undefined, {
|
|
1055
|
+
assertReplayAllowed: async () => repository.assertWriterAvailable({ workspacePath: other }),
|
|
1056
|
+
}), RunWriterConflictError);
|
|
1057
|
+
assert.equal(executions(), 1);
|
|
1058
|
+
assert.equal((await readRepeatEvents(runDirectory)).length, 0);
|
|
1059
|
+
await repository.assertWriterAvailable({ workspacePath: worktree });
|
|
1060
|
+
advance(120_001);
|
|
1061
|
+
await repository.assertWriterAvailable({ workspacePath: other });
|
|
1062
|
+
});
|
|
1063
|
+
test("records are bounded, pruned on state change, and oversize results are not stored", async () => {
|
|
1064
|
+
const { call, runDirectory, appendRunEvent } = await fixture();
|
|
1065
|
+
for (let index = 0; index < MAX_REPEAT_RECORDS + 4; index += 1)
|
|
1066
|
+
await call({ index }, { effect: async () => undefined });
|
|
1067
|
+
const records = await readRepeatRecords(runDirectory);
|
|
1068
|
+
assert.equal(records.length, MAX_REPEAT_RECORDS);
|
|
1069
|
+
assert.equal(JSON.parse(records.at(-1).result).z, MAX_REPEAT_RECORDS + 4);
|
|
1070
|
+
assert.equal((await call({ index: 0 }, { effect: async () => undefined })).repeated, false);
|
|
1071
|
+
assert.equal((await call({ index: MAX_REPEAT_RECORDS + 3 })).repeated, true);
|
|
1072
|
+
await appendRunEvent();
|
|
1073
|
+
await call({ fresh: true }, { effect: async () => undefined });
|
|
1074
|
+
assert.deepEqual((await readRepeatRecords(runDirectory)).map(({ operation }) => operation), ["stageArtifact"]);
|
|
1075
|
+
await call({ big: true }, { effect: async () => undefined, result: { blob: "x".repeat(MAX_REPEAT_RESULT_BYTES) } });
|
|
1076
|
+
assert.equal((await call({ big: true }, { effect: async () => undefined })).repeated, false);
|
|
1077
|
+
await call({ few: true }, { effect: async () => undefined, maxRecords: 1 });
|
|
1078
|
+
assert.equal((await readRepeatRecords(runDirectory)).length, 1);
|
|
1079
|
+
await assert.rejects(call({}, { maxRecords: MAX_REPEAT_RECORDS + 1 }), RangeError);
|
|
1080
|
+
});
|
|
1081
|
+
test("a corrupt or unsafe record file disables replay instead of forging a result", async () => {
|
|
1082
|
+
const { call, executions, runDirectory } = await fixture();
|
|
1083
|
+
await call();
|
|
1084
|
+
await writeFile(join(runDirectory, REPEAT_GUARD_FILE), "{not json");
|
|
1085
|
+
assert.deepEqual(await readRepeatRecords(runDirectory), []);
|
|
1086
|
+
assert.equal((await call(undefined, { effect: async () => undefined })).repeated, false);
|
|
1087
|
+
const stored = JSON.parse(await readFile(join(runDirectory, REPEAT_GUARD_FILE), "utf8"));
|
|
1088
|
+
const valid = structuredClone(stored);
|
|
1089
|
+
stored.records[0].callHash = "not-a-hash";
|
|
1090
|
+
await writeFile(join(runDirectory, REPEAT_GUARD_FILE), JSON.stringify(stored));
|
|
1091
|
+
assert.deepEqual(await readRepeatRecords(runDirectory), []);
|
|
1092
|
+
assert.equal((await call(undefined, { effect: async () => undefined })).repeated, false);
|
|
1093
|
+
assert.equal(executions(), 3);
|
|
1094
|
+
// The file must satisfy the strict repeat-guard-records-v1 contract: unknown members disable replay too.
|
|
1095
|
+
for (const tampered of [
|
|
1096
|
+
{ ...valid, unexpected: true },
|
|
1097
|
+
{ ...valid, records: [{ ...valid.records[0], unexpected: true }] },
|
|
1098
|
+
{ ...valid, schemaVersion: "2.0.0" },
|
|
1099
|
+
]) {
|
|
1100
|
+
await writeFile(join(runDirectory, REPEAT_GUARD_FILE), JSON.stringify(tampered));
|
|
1101
|
+
assert.deepEqual(await readRepeatRecords(runDirectory), []);
|
|
1102
|
+
}
|
|
1103
|
+
await writeFile(join(runDirectory, REPEAT_GUARD_FILE), JSON.stringify(valid));
|
|
1104
|
+
assert.equal((await readRepeatRecords(runDirectory)).length, 1);
|
|
1105
|
+
});
|
|
1106
|
+
//# sourceMappingURL=repeat-guard.test.js.map
|