@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/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 { diffWritePaths } from "./contracts.ts";
4
- import { isCurrent } from "./file-snapshot.ts";
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 cancellationFailure(): { ok: false; error: string } {
62
- return { ok: false, error: "candidate application cancelled" };
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.get(input.id);
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
- return { ok: false, error: "retained result root mismatch" };
305
+ throw new Error("retained result root mismatch");
79
306
  if (!retained.verification && !isCurrent(input.root, retained.snapshot))
80
- return { ok: false, error: "stale file snapshot" };
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 (error) {
86
- return { ok: false, error: (error as Error).message };
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
- return {
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
- return {
120
- ok: false,
121
- error: `candidate preflight rejected: ${preflight.class}:${preflight.code}`,
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 { ok: false, error: "stale file snapshot after preflight" };
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 { ok: false, error: "git summary screening rejected the patch" };
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 { ok: true, targets, checkExitCode: 0, applyExitCode: 0 };
407
+ return {
408
+ ok: true,
409
+ result: { targets, checkExitCode: 0, applyExitCode: 0 },
410
+ };
183
411
  }
@@ -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
- diff: string;
27
- writeSet: string[];
28
- root: string;
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
- this.results.set(id, {
136
+ const retained: RetainedResult = {
38
137
  id,
39
138
  diff: Buffer.from(input.diff, "utf8"),
40
- writeSet: [...new Set(input.writeSet)],
139
+ ...(hasCompleteIdentity(input) ? cloneIdentity(input) : {}),
140
+ writeSet: [...input.writeSet],
141
+ approvedDependencies: [...(input.approvedDependencies ?? [])],
41
142
  root: input.root,
42
- snapshot: input.snapshot ?? snapshotFiles(input.root, input.writeSet),
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
- return this.results.get(id);
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;