@abelxiaoxing/cadence 1.0.2 → 1.0.4
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/agents/contract-reviewer.md +2 -2
- package/agents/diagnosis-worker.md +3 -2
- package/agents/implementation-worker.md +3 -2
- package/config/.env.example +16 -1
- package/package.json +1 -1
- package/prompts/abel-design.md +13 -3
- package/prompts/abel-implement.md +50 -21
- package/skills/_shared/load-config.d.mts +7 -0
- package/skills/abel-workflow/SKILL.md +57 -25
- package/src/candidate-preflight.ts +361 -56
- package/src/child-session.ts +92 -27
- package/src/contracts.ts +994 -38
- package/src/index.ts +50 -24
- package/src/parent-provider.ts +206 -23
- package/src/patch.ts +269 -41
- package/src/result-store.ts +254 -16
- package/src/runtime.ts +1336 -560
- package/src/scheduler.ts +59 -16
- package/src/subagent-activity.ts +56 -25
- package/src/subagent-endpoint.ts +158 -0
- package/src/submit-tool.ts +12 -2
- package/src/worker.ts +121 -195
package/src/patch.ts
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
2
12
|
import { preflightCandidate } from "./candidate-preflight.ts";
|
|
3
|
-
import {
|
|
4
|
-
|
|
13
|
+
import {
|
|
14
|
+
type AgentsCheckpointRequest,
|
|
15
|
+
type ApplyCandidateResult,
|
|
16
|
+
type CandidateFailure,
|
|
17
|
+
diffWritePaths,
|
|
18
|
+
validateAgentsCheckpointRequest,
|
|
19
|
+
} from "./contracts.ts";
|
|
20
|
+
import { type Bound, isCurrent } from "./file-snapshot.ts";
|
|
5
21
|
import type { ResultStore } from "./result-store.ts";
|
|
6
22
|
|
|
7
23
|
interface CommandResult {
|
|
@@ -58,8 +74,223 @@ async function git(
|
|
|
58
74
|
});
|
|
59
75
|
}
|
|
60
76
|
|
|
61
|
-
function
|
|
62
|
-
|
|
77
|
+
function failure(value: CandidateFailure): {
|
|
78
|
+
ok: false;
|
|
79
|
+
failure: CandidateFailure;
|
|
80
|
+
} {
|
|
81
|
+
return { ok: false, failure: value };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cancellationFailure(): {
|
|
85
|
+
ok: false;
|
|
86
|
+
failure: CandidateFailure;
|
|
87
|
+
} {
|
|
88
|
+
return failure({ kind: "cancelled", code: "cancelled" });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type AgentsCheckpointResult =
|
|
92
|
+
| {
|
|
93
|
+
ok: true;
|
|
94
|
+
target: string;
|
|
95
|
+
agentsImpact: AgentsCheckpointRequest["agentsImpact"];
|
|
96
|
+
checkExitCode: 0;
|
|
97
|
+
applyExitCode: 0;
|
|
98
|
+
}
|
|
99
|
+
| {
|
|
100
|
+
ok: false;
|
|
101
|
+
failure: CandidateFailure;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const AGENTS_START = "<!-- ABEL:AGENTS-INDEX:START -->";
|
|
105
|
+
const AGENTS_END = "<!-- ABEL:AGENTS-INDEX:END -->";
|
|
106
|
+
const NONREGULAR_AGENTS_TARGET = Symbol("nonregular AGENTS target");
|
|
107
|
+
|
|
108
|
+
function managedParts(text: string): { prefix: string; suffix: string } | null {
|
|
109
|
+
const lines = text.split("\n");
|
|
110
|
+
const starts: Array<{ line: number; start: number; end: number }> = [];
|
|
111
|
+
const ends: Array<{ line: number; start: number; end: number }> = [];
|
|
112
|
+
let offset = 0;
|
|
113
|
+
for (const [line, value] of lines.entries()) {
|
|
114
|
+
const range = { line, start: offset, end: offset + value.length };
|
|
115
|
+
if (value === AGENTS_START) starts.push(range);
|
|
116
|
+
if (value === AGENTS_END) ends.push(range);
|
|
117
|
+
offset = range.end + 1;
|
|
118
|
+
}
|
|
119
|
+
if (
|
|
120
|
+
starts.length !== 1 ||
|
|
121
|
+
ends.length !== 1 ||
|
|
122
|
+
starts[0].line >= ends[0].line
|
|
123
|
+
) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
prefix: text.slice(0, starts[0].start),
|
|
128
|
+
suffix: text.slice(ends[0].end),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function checkpointContentIsScoped(
|
|
133
|
+
impact: AgentsCheckpointRequest["agentsImpact"],
|
|
134
|
+
before: string | null,
|
|
135
|
+
after: string | null,
|
|
136
|
+
): boolean {
|
|
137
|
+
const beforeParts = before === null ? null : managedParts(before);
|
|
138
|
+
const afterParts = after === null ? null : managedParts(after);
|
|
139
|
+
if (impact === "update-existing") {
|
|
140
|
+
return (
|
|
141
|
+
beforeParts !== null &&
|
|
142
|
+
afterParts !== null &&
|
|
143
|
+
beforeParts.prefix === afterParts.prefix &&
|
|
144
|
+
beforeParts.suffix === afterParts.suffix
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (impact === "create-index") {
|
|
148
|
+
return (
|
|
149
|
+
before === null &&
|
|
150
|
+
afterParts !== null &&
|
|
151
|
+
afterParts.prefix.trim() === "" &&
|
|
152
|
+
afterParts.suffix.trim() === ""
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (beforeParts === null) return false;
|
|
156
|
+
if (after === null) {
|
|
157
|
+
return beforeParts.prefix.trim() === "" && beforeParts.suffix.trim() === "";
|
|
158
|
+
}
|
|
159
|
+
return (
|
|
160
|
+
managedParts(after) === null &&
|
|
161
|
+
after === `${beforeParts.prefix}${beforeParts.suffix}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function regularText(
|
|
166
|
+
root: string,
|
|
167
|
+
target: string,
|
|
168
|
+
): string | null | typeof NONREGULAR_AGENTS_TARGET {
|
|
169
|
+
const resolvedRoot = path.resolve(root);
|
|
170
|
+
const absolute = path.resolve(resolvedRoot, target);
|
|
171
|
+
if (
|
|
172
|
+
absolute === resolvedRoot ||
|
|
173
|
+
!absolute.startsWith(`${resolvedRoot}${path.sep}`)
|
|
174
|
+
) {
|
|
175
|
+
throw new Error("AGENTS target escapes the root");
|
|
176
|
+
}
|
|
177
|
+
const stat = lstatSync(absolute, { throwIfNoEntry: false });
|
|
178
|
+
if (!stat) return null;
|
|
179
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
180
|
+
return NONREGULAR_AGENTS_TARGET;
|
|
181
|
+
}
|
|
182
|
+
return readFileSync(absolute, "utf8");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function applyAgentsCheckpoint(
|
|
186
|
+
root: string,
|
|
187
|
+
value: unknown,
|
|
188
|
+
signal?: AbortSignal,
|
|
189
|
+
): Promise<AgentsCheckpointResult> {
|
|
190
|
+
const validation = validateAgentsCheckpointRequest(value);
|
|
191
|
+
if (!validation.ok) {
|
|
192
|
+
return failure({ kind: "artifact", code: "invalid-checkpoint-contract" });
|
|
193
|
+
}
|
|
194
|
+
const request = validation.value;
|
|
195
|
+
if (signal?.aborted) {
|
|
196
|
+
return cancellationFailure();
|
|
197
|
+
}
|
|
198
|
+
const before = regularText(root, request.agentsTarget);
|
|
199
|
+
if (before === NONREGULAR_AGENTS_TARGET) {
|
|
200
|
+
return failure({ kind: "artifact", code: "nonregular-mode" });
|
|
201
|
+
}
|
|
202
|
+
if (!isCurrent(root, request.snapshot as Bound)) {
|
|
203
|
+
return failure({ kind: "stale", code: "stale-snapshot" });
|
|
204
|
+
}
|
|
205
|
+
let targets: string[];
|
|
206
|
+
try {
|
|
207
|
+
targets = diffWritePaths(request.diff).paths;
|
|
208
|
+
} catch {
|
|
209
|
+
return failure({ kind: "artifact", code: "invalid-diff" });
|
|
210
|
+
}
|
|
211
|
+
if (targets.length !== 1 || targets[0] !== request.agentsTarget) {
|
|
212
|
+
return failure({ kind: "artifact", code: "agents-target-mismatch" });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let temp: string | undefined;
|
|
216
|
+
try {
|
|
217
|
+
if (
|
|
218
|
+
(request.agentsImpact === "create-index" && before !== null) ||
|
|
219
|
+
(request.agentsImpact !== "create-index" && before === null)
|
|
220
|
+
) {
|
|
221
|
+
return failure({ kind: "artifact", code: "agents-impact-mismatch" });
|
|
222
|
+
}
|
|
223
|
+
temp = mkdtempSync(path.join(tmpdir(), "cadence-agents-checkpoint-"));
|
|
224
|
+
if (before !== null) {
|
|
225
|
+
const temporaryTarget = path.join(temp, request.agentsTarget);
|
|
226
|
+
mkdirSync(path.dirname(temporaryTarget), { recursive: true });
|
|
227
|
+
copyFileSync(path.join(root, request.agentsTarget), temporaryTarget);
|
|
228
|
+
}
|
|
229
|
+
const bytes = Buffer.from(request.diff, "utf8");
|
|
230
|
+
const candidateCheck = await git(
|
|
231
|
+
temp,
|
|
232
|
+
["apply", "--check", "--recount", "--whitespace=nowarn", "-"],
|
|
233
|
+
bytes,
|
|
234
|
+
signal,
|
|
235
|
+
);
|
|
236
|
+
if (candidateCheck.cancelled || signal?.aborted) {
|
|
237
|
+
return cancellationFailure();
|
|
238
|
+
}
|
|
239
|
+
if (candidateCheck.code !== 0) {
|
|
240
|
+
return failure({ kind: "artifact", code: "git-apply-check-failed" });
|
|
241
|
+
}
|
|
242
|
+
const candidateApply = await git(
|
|
243
|
+
temp,
|
|
244
|
+
["apply", "--recount", "--whitespace=nowarn", "-"],
|
|
245
|
+
bytes,
|
|
246
|
+
signal,
|
|
247
|
+
);
|
|
248
|
+
if (candidateApply.cancelled || signal?.aborted) {
|
|
249
|
+
return cancellationFailure();
|
|
250
|
+
}
|
|
251
|
+
if (candidateApply.code !== 0) {
|
|
252
|
+
return failure({ kind: "artifact", code: "git-apply-failed" });
|
|
253
|
+
}
|
|
254
|
+
const after = regularText(temp, request.agentsTarget);
|
|
255
|
+
if (after === NONREGULAR_AGENTS_TARGET) {
|
|
256
|
+
return failure({ kind: "artifact", code: "nonregular-mode" });
|
|
257
|
+
}
|
|
258
|
+
if (!checkpointContentIsScoped(request.agentsImpact, before, after)) {
|
|
259
|
+
return failure({ kind: "artifact", code: "outside-managed-region" });
|
|
260
|
+
}
|
|
261
|
+
if (!isCurrent(root, request.snapshot as Bound)) {
|
|
262
|
+
return failure({ kind: "stale", code: "stale-snapshot" });
|
|
263
|
+
}
|
|
264
|
+
const check = await git(
|
|
265
|
+
root,
|
|
266
|
+
["apply", "--check", "--recount", "--whitespace=nowarn", "-"],
|
|
267
|
+
bytes,
|
|
268
|
+
signal,
|
|
269
|
+
);
|
|
270
|
+
if (check.cancelled || signal?.aborted) {
|
|
271
|
+
return cancellationFailure();
|
|
272
|
+
}
|
|
273
|
+
if (check.code !== 0) {
|
|
274
|
+
return failure({ kind: "stale", code: "git-apply-check-failed" });
|
|
275
|
+
}
|
|
276
|
+
const apply = await git(
|
|
277
|
+
root,
|
|
278
|
+
["apply", "--recount", "--whitespace=nowarn", "-"],
|
|
279
|
+
bytes,
|
|
280
|
+
);
|
|
281
|
+
if (apply.code !== 0) {
|
|
282
|
+
return failure({ kind: "environment", code: "git-apply-failed" });
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
ok: true,
|
|
286
|
+
target: request.agentsTarget,
|
|
287
|
+
agentsImpact: request.agentsImpact,
|
|
288
|
+
checkExitCode: 0,
|
|
289
|
+
applyExitCode: 0,
|
|
290
|
+
};
|
|
291
|
+
} finally {
|
|
292
|
+
if (temp) rmSync(temp, { recursive: true, force: true });
|
|
293
|
+
}
|
|
63
294
|
}
|
|
64
295
|
|
|
65
296
|
export async function applyRetainedPatch(input: {
|
|
@@ -67,29 +298,22 @@ export async function applyRetainedPatch(input: {
|
|
|
67
298
|
id: string;
|
|
68
299
|
store: ResultStore;
|
|
69
300
|
signal?: AbortSignal;
|
|
70
|
-
}): Promise<
|
|
71
|
-
| { ok: true; targets: string[]; checkExitCode: 0; applyExitCode: 0 }
|
|
72
|
-
| { ok: false; error: string }
|
|
73
|
-
> {
|
|
301
|
+
}): Promise<ApplyCandidateResult> {
|
|
74
302
|
if (input.signal?.aborted) return cancellationFailure();
|
|
75
|
-
const retained = input.store.
|
|
76
|
-
if (!retained) return { ok: false, error: "retained result not found" };
|
|
303
|
+
const retained = input.store.resolveForApply(input.id);
|
|
77
304
|
if (retained.root !== input.root)
|
|
78
|
-
|
|
305
|
+
throw new Error("retained result root mismatch");
|
|
79
306
|
if (!retained.verification && !isCurrent(input.root, retained.snapshot))
|
|
80
|
-
return {
|
|
307
|
+
return failure({ kind: "stale", code: "stale-snapshot" });
|
|
81
308
|
|
|
82
309
|
let targets: string[];
|
|
83
310
|
try {
|
|
84
311
|
targets = diffWritePaths(retained.diff.toString("utf8")).paths;
|
|
85
|
-
} catch
|
|
86
|
-
return {
|
|
312
|
+
} catch {
|
|
313
|
+
return failure({ kind: "artifact", code: "invalid-diff" });
|
|
87
314
|
}
|
|
88
315
|
if (targets.some((path) => !retained.writeSet.includes(path))) {
|
|
89
|
-
return {
|
|
90
|
-
ok: false,
|
|
91
|
-
error: "patch target is outside the declared write set",
|
|
92
|
-
};
|
|
316
|
+
return failure({ kind: "artifact", code: "write-set-mismatch" });
|
|
93
317
|
}
|
|
94
318
|
|
|
95
319
|
if (retained.verification) {
|
|
@@ -98,15 +322,13 @@ export async function applyRetainedPatch(input: {
|
|
|
98
322
|
!retained.lockfile ||
|
|
99
323
|
!retained.dependencyTarget
|
|
100
324
|
) {
|
|
101
|
-
|
|
102
|
-
ok: false,
|
|
103
|
-
error: "retained candidate preflight inputs are incomplete",
|
|
104
|
-
};
|
|
325
|
+
throw new Error("retained candidate preflight inputs are incomplete");
|
|
105
326
|
}
|
|
106
327
|
const preflight = await preflightCandidate({
|
|
107
328
|
root: input.root,
|
|
108
329
|
diff: retained.diff,
|
|
109
330
|
writeSet: retained.writeSet,
|
|
331
|
+
approvedDependencies: retained.approvedDependencies,
|
|
110
332
|
snapshot: retained.snapshot,
|
|
111
333
|
baseline: retained.baseline,
|
|
112
334
|
verification: retained.verification,
|
|
@@ -116,13 +338,25 @@ export async function applyRetainedPatch(input: {
|
|
|
116
338
|
signal: input.signal,
|
|
117
339
|
});
|
|
118
340
|
if (!preflight.ok) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
341
|
+
switch (preflight.kind) {
|
|
342
|
+
case "artifact":
|
|
343
|
+
return failure({
|
|
344
|
+
kind: preflight.kind,
|
|
345
|
+
code: preflight.code,
|
|
346
|
+
...(preflight.excerpt ? { evidence: [preflight.excerpt] } : {}),
|
|
347
|
+
});
|
|
348
|
+
case "stale":
|
|
349
|
+
return failure({ kind: preflight.kind, code: preflight.code });
|
|
350
|
+
case "environment":
|
|
351
|
+
return failure({ kind: preflight.kind, code: preflight.code });
|
|
352
|
+
case "approval-boundary":
|
|
353
|
+
return failure({ kind: preflight.kind, code: preflight.code });
|
|
354
|
+
case "cancelled":
|
|
355
|
+
return failure({ kind: preflight.kind, code: preflight.code });
|
|
356
|
+
}
|
|
123
357
|
}
|
|
124
358
|
if (!isCurrent(input.root, retained.snapshot)) {
|
|
125
|
-
return {
|
|
359
|
+
return failure({ kind: "stale", code: "stale-snapshot" });
|
|
126
360
|
}
|
|
127
361
|
}
|
|
128
362
|
if (input.signal?.aborted) return cancellationFailure();
|
|
@@ -135,10 +369,7 @@ export async function applyRetainedPatch(input: {
|
|
|
135
369
|
);
|
|
136
370
|
if (numstat.cancelled || input.signal?.aborted) return cancellationFailure();
|
|
137
371
|
if (numstat.code !== 0 || numstat.stdout.includes(Buffer.from("-\t-\t"))) {
|
|
138
|
-
return {
|
|
139
|
-
ok: false,
|
|
140
|
-
error: `git numstat screening failed: ${numstat.stderr.toString("utf8")}`,
|
|
141
|
-
};
|
|
372
|
+
return failure({ kind: "artifact", code: "invalid-diff" });
|
|
142
373
|
}
|
|
143
374
|
const summary = await git(
|
|
144
375
|
input.root,
|
|
@@ -153,7 +384,7 @@ export async function applyRetainedPatch(input: {
|
|
|
153
384
|
summary.stdout.toString("utf8"),
|
|
154
385
|
)
|
|
155
386
|
) {
|
|
156
|
-
return {
|
|
387
|
+
return failure({ kind: "artifact", code: "invalid-diff" });
|
|
157
388
|
}
|
|
158
389
|
const check = await git(
|
|
159
390
|
input.root,
|
|
@@ -163,10 +394,7 @@ export async function applyRetainedPatch(input: {
|
|
|
163
394
|
);
|
|
164
395
|
if (check.cancelled || input.signal?.aborted) return cancellationFailure();
|
|
165
396
|
if (check.code !== 0)
|
|
166
|
-
return {
|
|
167
|
-
ok: false,
|
|
168
|
-
error: `git apply --check failed: ${check.stderr.toString("utf8")}`,
|
|
169
|
-
};
|
|
397
|
+
return failure({ kind: "artifact", code: "git-apply-check-failed" });
|
|
170
398
|
if (input.signal?.aborted) return cancellationFailure();
|
|
171
399
|
const apply = await git(
|
|
172
400
|
input.root,
|
|
@@ -174,10 +402,10 @@ export async function applyRetainedPatch(input: {
|
|
|
174
402
|
retained.diff,
|
|
175
403
|
);
|
|
176
404
|
if (apply.code !== 0)
|
|
177
|
-
return {
|
|
178
|
-
ok: false,
|
|
179
|
-
error: `git apply failed: ${apply.stderr.toString("utf8")}`,
|
|
180
|
-
};
|
|
405
|
+
return failure({ kind: "environment", code: "git-apply-failed" });
|
|
181
406
|
input.store.discard(input.id);
|
|
182
|
-
return {
|
|
407
|
+
return {
|
|
408
|
+
ok: true,
|
|
409
|
+
result: { targets, checkExitCode: 0, applyExitCode: 0 },
|
|
410
|
+
};
|
|
183
411
|
}
|
package/src/result-store.ts
CHANGED
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
3
|
import type {
|
|
3
4
|
BaselineEntry,
|
|
4
5
|
VerificationContract,
|
|
5
6
|
} from "./candidate-preflight.ts";
|
|
7
|
+
import type { ImplementationPhase } from "./contracts.ts";
|
|
6
8
|
import type { Bound, DirBound, FileBound } from "./file-snapshot.ts";
|
|
7
9
|
import { snapshotFiles } from "./file-snapshot.ts";
|
|
8
10
|
|
|
11
|
+
export interface RetainedCandidateIdentity {
|
|
12
|
+
stage: "abel-implement";
|
|
13
|
+
canonicalRoot: string;
|
|
14
|
+
root: string;
|
|
15
|
+
changeId: string;
|
|
16
|
+
taskId: string;
|
|
17
|
+
originRequestId: string;
|
|
18
|
+
phase: ImplementationPhase;
|
|
19
|
+
launchIndex: 0 | 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RetainedCandidateFacts extends RetainedCandidateIdentity {
|
|
23
|
+
writeSet: string[];
|
|
24
|
+
approvedDependencies: string[];
|
|
25
|
+
snapshot: Bound;
|
|
26
|
+
}
|
|
27
|
+
|
|
9
28
|
export interface RetainedResult {
|
|
10
29
|
id: string;
|
|
11
30
|
diff: Buffer;
|
|
31
|
+
stage?: RetainedCandidateIdentity["stage"];
|
|
32
|
+
canonicalRoot?: string;
|
|
33
|
+
changeId?: string;
|
|
34
|
+
taskId?: string;
|
|
35
|
+
originRequestId?: string;
|
|
36
|
+
phase?: ImplementationPhase;
|
|
37
|
+
launchIndex?: 0 | 1;
|
|
12
38
|
writeSet: string[];
|
|
39
|
+
approvedDependencies: string[];
|
|
13
40
|
root: string;
|
|
14
41
|
snapshot: Bound;
|
|
15
42
|
baseline: BaselineEntry[];
|
|
@@ -19,27 +46,103 @@ export interface RetainedResult {
|
|
|
19
46
|
dependencyTarget?: FileBound | DirBound;
|
|
20
47
|
}
|
|
21
48
|
|
|
49
|
+
export type BoundRetainedResult = RetainedResult & RetainedCandidateIdentity;
|
|
50
|
+
|
|
51
|
+
interface RetainCandidateData {
|
|
52
|
+
diff: string;
|
|
53
|
+
writeSet: string[];
|
|
54
|
+
approvedDependencies?: string[];
|
|
55
|
+
root: string;
|
|
56
|
+
snapshot?: Bound;
|
|
57
|
+
baseline?: BaselineEntry[];
|
|
58
|
+
verification?: VerificationContract;
|
|
59
|
+
packageManifest?: FileBound;
|
|
60
|
+
lockfile?: FileBound;
|
|
61
|
+
dependencyTarget?: FileBound | DirBound;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type RetainInput = RetainCandidateData & Partial<RetainedCandidateIdentity>;
|
|
65
|
+
|
|
66
|
+
const IDENTITY_KEYS = [
|
|
67
|
+
"stage",
|
|
68
|
+
"canonicalRoot",
|
|
69
|
+
"changeId",
|
|
70
|
+
"taskId",
|
|
71
|
+
"originRequestId",
|
|
72
|
+
"phase",
|
|
73
|
+
"launchIndex",
|
|
74
|
+
] as const;
|
|
75
|
+
|
|
76
|
+
function hasCompleteIdentity(
|
|
77
|
+
value: RetainedResult | RetainInput,
|
|
78
|
+
): value is (RetainedResult | RetainInput) & RetainedCandidateIdentity {
|
|
79
|
+
return IDENTITY_KEYS.every((key) => value[key] !== undefined);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasAnyIdentity(value: RetainedResult | RetainInput): boolean {
|
|
83
|
+
return IDENTITY_KEYS.some((key) => value[key] !== undefined);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cloneIdentity(identity: RetainedCandidateIdentity) {
|
|
87
|
+
return {
|
|
88
|
+
stage: identity.stage,
|
|
89
|
+
canonicalRoot: identity.canonicalRoot,
|
|
90
|
+
changeId: identity.changeId,
|
|
91
|
+
taskId: identity.taskId,
|
|
92
|
+
originRequestId: identity.originRequestId,
|
|
93
|
+
phase: identity.phase,
|
|
94
|
+
launchIndex: identity.launchIndex,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cloneRetained(retained: RetainedResult): RetainedResult {
|
|
99
|
+
const clone: RetainedResult = {
|
|
100
|
+
...retained,
|
|
101
|
+
diff: Buffer.from(retained.diff),
|
|
102
|
+
writeSet: [...retained.writeSet],
|
|
103
|
+
approvedDependencies: [...retained.approvedDependencies],
|
|
104
|
+
snapshot: structuredClone(retained.snapshot),
|
|
105
|
+
baseline: retained.baseline.map((entry) => ({ ...entry })),
|
|
106
|
+
};
|
|
107
|
+
if (retained.verification) {
|
|
108
|
+
clone.verification = {
|
|
109
|
+
...retained.verification,
|
|
110
|
+
argv: [...retained.verification.argv],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (retained.packageManifest) {
|
|
114
|
+
clone.packageManifest = { ...retained.packageManifest };
|
|
115
|
+
}
|
|
116
|
+
if (retained.lockfile) clone.lockfile = { ...retained.lockfile };
|
|
117
|
+
if (retained.dependencyTarget) {
|
|
118
|
+
clone.dependencyTarget = { ...retained.dependencyTarget };
|
|
119
|
+
}
|
|
120
|
+
return clone;
|
|
121
|
+
}
|
|
122
|
+
|
|
22
123
|
export class ResultStore {
|
|
23
124
|
private readonly results = new Map<string, RetainedResult>();
|
|
125
|
+
private readonly sealedResults = new Map<string, RetainedResult>();
|
|
126
|
+
private readonly sealedCandidateFacts = new Map<
|
|
127
|
+
string,
|
|
128
|
+
RetainedCandidateFacts
|
|
129
|
+
>();
|
|
24
130
|
|
|
25
|
-
retain(input: {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
snapshot?: Bound;
|
|
30
|
-
baseline?: BaselineEntry[];
|
|
31
|
-
verification?: VerificationContract;
|
|
32
|
-
packageManifest?: FileBound;
|
|
33
|
-
lockfile?: FileBound;
|
|
34
|
-
dependencyTarget?: FileBound | DirBound;
|
|
35
|
-
}): string {
|
|
131
|
+
retain(input: RetainInput): string {
|
|
132
|
+
if (hasAnyIdentity(input) && !hasCompleteIdentity(input)) {
|
|
133
|
+
throw new Error("retained candidate identity is incomplete");
|
|
134
|
+
}
|
|
36
135
|
const id = randomUUID();
|
|
37
|
-
|
|
136
|
+
const retained: RetainedResult = {
|
|
38
137
|
id,
|
|
39
138
|
diff: Buffer.from(input.diff, "utf8"),
|
|
40
|
-
|
|
139
|
+
...(hasCompleteIdentity(input) ? cloneIdentity(input) : {}),
|
|
140
|
+
writeSet: [...input.writeSet],
|
|
141
|
+
approvedDependencies: [...(input.approvedDependencies ?? [])],
|
|
41
142
|
root: input.root,
|
|
42
|
-
snapshot:
|
|
143
|
+
snapshot: structuredClone(
|
|
144
|
+
input.snapshot ?? snapshotFiles(input.root, input.writeSet),
|
|
145
|
+
),
|
|
43
146
|
baseline: (input.baseline ?? []).map((entry) => ({ ...entry })),
|
|
44
147
|
...(input.verification === undefined
|
|
45
148
|
? {}
|
|
@@ -58,18 +161,153 @@ export class ResultStore {
|
|
|
58
161
|
...(input.dependencyTarget === undefined
|
|
59
162
|
? {}
|
|
60
163
|
: { dependencyTarget: { ...input.dependencyTarget } }),
|
|
61
|
-
}
|
|
164
|
+
};
|
|
165
|
+
this.results.set(id, retained);
|
|
166
|
+
this.sealedResults.set(id, cloneRetained(retained));
|
|
167
|
+
if (hasCompleteIdentity(retained)) {
|
|
168
|
+
this.sealedCandidateFacts.set(id, this.cloneFacts(retained));
|
|
169
|
+
}
|
|
62
170
|
return id;
|
|
63
171
|
}
|
|
64
172
|
|
|
65
173
|
get(id: string): RetainedResult | undefined {
|
|
66
|
-
|
|
174
|
+
const retained = this.results.get(id);
|
|
175
|
+
return retained ? cloneRetained(retained) : undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
hasCandidateIdentity(id: string): boolean {
|
|
179
|
+
const retained = this.results.get(id);
|
|
180
|
+
if (!retained) return false;
|
|
181
|
+
this.assertIntegrity(id, retained);
|
|
182
|
+
return hasAnyIdentity(retained);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
bindIdentity(
|
|
186
|
+
id: string,
|
|
187
|
+
identity: RetainedCandidateIdentity,
|
|
188
|
+
): BoundRetainedResult {
|
|
189
|
+
const retained = this.results.get(id);
|
|
190
|
+
if (!retained) throw new Error("retained result not found");
|
|
191
|
+
this.assertIntegrity(id, retained);
|
|
192
|
+
if (hasAnyIdentity(retained)) {
|
|
193
|
+
if (
|
|
194
|
+
!hasCompleteIdentity(retained) ||
|
|
195
|
+
!this.matchesIdentity(retained, identity)
|
|
196
|
+
) {
|
|
197
|
+
throw new Error("retained candidate identity mismatch");
|
|
198
|
+
}
|
|
199
|
+
return cloneRetained(retained) as BoundRetainedResult;
|
|
200
|
+
}
|
|
201
|
+
if (retained.root !== identity.root) {
|
|
202
|
+
throw new Error("retained candidate identity mismatch");
|
|
203
|
+
}
|
|
204
|
+
Object.assign(retained, cloneIdentity(identity));
|
|
205
|
+
this.sealedResults.set(id, cloneRetained(retained));
|
|
206
|
+
return cloneRetained(retained) as BoundRetainedResult;
|
|
67
207
|
}
|
|
208
|
+
|
|
209
|
+
resolveIdentity(
|
|
210
|
+
id: string,
|
|
211
|
+
expected: RetainedCandidateFacts,
|
|
212
|
+
): BoundRetainedResult {
|
|
213
|
+
const retained = this.results.get(id);
|
|
214
|
+
if (!retained) throw new Error("retained result not found");
|
|
215
|
+
this.assertIntegrity(id, retained);
|
|
216
|
+
if (
|
|
217
|
+
!hasCompleteIdentity(retained) ||
|
|
218
|
+
!this.matchesIdentity(retained, expected) ||
|
|
219
|
+
!isDeepStrictEqual(retained.writeSet, expected.writeSet) ||
|
|
220
|
+
!isDeepStrictEqual(
|
|
221
|
+
retained.approvedDependencies,
|
|
222
|
+
expected.approvedDependencies,
|
|
223
|
+
)
|
|
224
|
+
) {
|
|
225
|
+
throw new Error("retained candidate identity mismatch");
|
|
226
|
+
}
|
|
227
|
+
const sealed = this.sealedCandidateFacts.get(id);
|
|
228
|
+
if (sealed) {
|
|
229
|
+
if (
|
|
230
|
+
!this.matchesIdentity(retained, sealed) ||
|
|
231
|
+
!isDeepStrictEqual(retained.writeSet, sealed.writeSet) ||
|
|
232
|
+
!isDeepStrictEqual(
|
|
233
|
+
retained.approvedDependencies,
|
|
234
|
+
sealed.approvedDependencies,
|
|
235
|
+
) ||
|
|
236
|
+
!isDeepStrictEqual(retained.snapshot, sealed.snapshot)
|
|
237
|
+
) {
|
|
238
|
+
throw new Error("retained candidate identity mismatch");
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
if (!isDeepStrictEqual(retained.snapshot, expected.snapshot)) {
|
|
242
|
+
throw new Error("retained candidate identity mismatch");
|
|
243
|
+
}
|
|
244
|
+
this.sealedCandidateFacts.set(id, this.cloneFacts(retained));
|
|
245
|
+
}
|
|
246
|
+
return cloneRetained(retained) as BoundRetainedResult;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
resolveForApply(id: string): RetainedResult {
|
|
250
|
+
const retained = this.results.get(id);
|
|
251
|
+
if (!retained) throw new Error("retained result not found");
|
|
252
|
+
this.assertIntegrity(id, retained);
|
|
253
|
+
return cloneRetained(retained);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private assertIntegrity(id: string, retained: RetainedResult): void {
|
|
257
|
+
const sealed = this.sealedResults.get(id);
|
|
258
|
+
if (sealed && isDeepStrictEqual(retained, sealed)) return;
|
|
259
|
+
const identityChanged =
|
|
260
|
+
sealed !== undefined &&
|
|
261
|
+
(IDENTITY_KEYS.some((key) => retained[key] !== sealed[key]) ||
|
|
262
|
+
retained.root !== sealed.root ||
|
|
263
|
+
!isDeepStrictEqual(retained.writeSet, sealed.writeSet) ||
|
|
264
|
+
!isDeepStrictEqual(
|
|
265
|
+
retained.approvedDependencies,
|
|
266
|
+
sealed.approvedDependencies,
|
|
267
|
+
) ||
|
|
268
|
+
!isDeepStrictEqual(retained.snapshot, sealed.snapshot));
|
|
269
|
+
throw new Error(
|
|
270
|
+
identityChanged
|
|
271
|
+
? "retained candidate identity mismatch"
|
|
272
|
+
: "retained candidate integrity mismatch",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private cloneFacts(retained: BoundRetainedResult): RetainedCandidateFacts {
|
|
277
|
+
return {
|
|
278
|
+
...cloneIdentity(retained),
|
|
279
|
+
root: retained.root,
|
|
280
|
+
writeSet: [...retained.writeSet],
|
|
281
|
+
approvedDependencies: [...retained.approvedDependencies],
|
|
282
|
+
snapshot: structuredClone(retained.snapshot),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private matchesIdentity(
|
|
287
|
+
retained: BoundRetainedResult,
|
|
288
|
+
expected: RetainedCandidateIdentity,
|
|
289
|
+
): boolean {
|
|
290
|
+
return (
|
|
291
|
+
retained.stage === expected.stage &&
|
|
292
|
+
retained.canonicalRoot === expected.canonicalRoot &&
|
|
293
|
+
retained.root === expected.root &&
|
|
294
|
+
retained.changeId === expected.changeId &&
|
|
295
|
+
retained.taskId === expected.taskId &&
|
|
296
|
+
retained.originRequestId === expected.originRequestId &&
|
|
297
|
+
retained.phase === expected.phase &&
|
|
298
|
+
retained.launchIndex === expected.launchIndex
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
68
302
|
discard(id: string): boolean {
|
|
303
|
+
this.sealedResults.delete(id);
|
|
304
|
+
this.sealedCandidateFacts.delete(id);
|
|
69
305
|
return this.results.delete(id);
|
|
70
306
|
}
|
|
71
307
|
clear(): void {
|
|
72
308
|
this.results.clear();
|
|
309
|
+
this.sealedResults.clear();
|
|
310
|
+
this.sealedCandidateFacts.clear();
|
|
73
311
|
}
|
|
74
312
|
get size(): number {
|
|
75
313
|
return this.results.size;
|