@tea-agent/loop-agent 0.35.1-beta.3 → 0.35.1

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.
Files changed (48) hide show
  1. package/AGENTS.md +0 -2
  2. package/CHANGELOG.md +25 -24
  3. package/bin/loop-agent.js +1 -37
  4. package/dist/application/dag/generate-task-dag.js +4 -1
  5. package/dist/application/task-lifecycle/advance.js +14 -0
  6. package/dist/cli/program.js +2 -2
  7. package/dist/commands/task-advance.js +1 -0
  8. package/dist/executors/dag-pi-executor.js +0 -44
  9. package/dist/shared/package-metadata.js +0 -42
  10. package/dist/task/config-types.js +2 -0
  11. package/dist/task/contract/project.js +3 -0
  12. package/dist/task/contract/schema.js +1 -0
  13. package/dist/task/source-prepare/build-draft.js +7 -0
  14. package/dist/task/source-prepare/semantic-intake.js +37 -10
  15. package/dist/task/task-demand-routing.js +10 -0
  16. package/dist/worker/console/operator-actions.js +72 -6
  17. package/dist/worker/console/prd-intake-bridge.js +10 -3
  18. package/dist/worker/console/prd-reference-discovery.js +124 -0
  19. package/dist/worker/console/static/assets/{index-hJqCPs_g.css → index-HX1pbOyl.css} +1 -1
  20. package/dist/worker/console/static/assets/{index-CvsQgALl.js → index-M0BLEBfh.js} +25 -25
  21. package/dist/worker/console/static/index.html +2 -2
  22. package/dist/worker/console/static-src/app/useOperatorActions.js +19 -1
  23. package/dist/worker/console/static-src/app/useRecoveryConsole.js +0 -5
  24. package/dist/worker/console/static-src/app/useTaskWizard.js +12 -0
  25. package/dist/worker/loop-agent/loop-agent-client.js +3 -17
  26. package/dist/worker/observability/read-model.js +0 -20
  27. package/dist/worker/preflight.js +1 -2
  28. package/dist/workflows/dag/backend-test-scenario-param.js +23 -33
  29. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -9
  30. package/dist/workflows/dag/frontend-implementation-contract.js +39 -233
  31. package/dist/workflows/dag/frontend-prewrite-gate.js +61 -364
  32. package/dist/workflows/dag/frontend-repair.js +18 -219
  33. package/dist/workflows/dag/frontend-verification-trace.js +32 -47
  34. package/dist/workflows/dag/init-hybrid.js +26 -49
  35. package/dist/workflows/dag/node-execution.js +0 -89
  36. package/dist/workflows/dag/recovery-recommendation.js +0 -58
  37. package/dist/workflows/dag/runner.js +11 -245
  38. package/dist/workflows/dag/scheduler.js +3 -257
  39. package/dist/workflows/dag/types.js +2 -130
  40. package/package.json +2 -2
  41. package/dist/build-stamp.json +0 -6
  42. package/dist/workflows/dag/contract-output-registry.js +0 -14
  43. package/dist/workflows/dag/contract-validator-registrations.js +0 -8
  44. package/dist/workflows/dag/frontend-recovery-plan.js +0 -73
  45. package/dist/workflows/dag/frontend-recovery-root-manifest.js +0 -123
  46. package/dist/workflows/dag/frontend-recovery-run.js +0 -539
  47. package/dist/workflows/dag/frontend-writer-recovery.js +0 -106
  48. package/dist/workflows/dag/frontend-writer-rollback.js +0 -821
@@ -1,821 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { chmod, lstat, mkdir, readFile, readdir, readlink, realpath, symlink, unlink, writeFile, } from "node:fs/promises";
3
- import path from "node:path";
4
- import { z } from "zod";
5
- import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
6
- import { pathMatchesPattern } from "../../shared/git-progress.js";
7
- /**
8
- * Writer transient-failure rollback primitive (phase 4).
9
- *
10
- * Captures a content snapshot of every writeSet-matched file before an attempt,
11
- * then restores the baseline using per-path CAS (compare-and-swap on the
12
- * content hash) so a concurrent/external edit is never overwritten or deleted.
13
- * A journal state machine (`pending → restoring → completed | blocked`) plus a
14
- * crash-recovery runner make the primitive resumable and fail-closed.
15
- *
16
- * Non-goals (phase 5/6): candidate continuation, child creation, recovery
17
- * coordinator wiring, report/Console consumption. No git command is invoked,
18
- * and `git reset --hard` is intentionally absent.
19
- */
20
- export const FRONTEND_WRITER_ROLLBACK_SCHEMA_ID = "frontend-writer-rollback-v1";
21
- export const frontendWriterFileStateSchema = z.enum([
22
- "absent", // baseline does not exist (allowed new file)
23
- "present", // regular file
24
- "symlink", // symbolic link
25
- "untracked", // untracked file
26
- "deleted", // attempt deleted the baseline
27
- "renamed", // attempt renamed the baseline
28
- ]);
29
- export const frontendWriterJournalStateSchema = z.enum([
30
- "pending",
31
- "restoring",
32
- "completed",
33
- "blocked",
34
- ]);
35
- export const frontendWriterPathStateSchema = z.enum([
36
- "pending",
37
- "restoring",
38
- "verified",
39
- "failed",
40
- ]);
41
- const safePath = z
42
- .string()
43
- .min(1)
44
- .refine((value) => !value.startsWith("/") &&
45
- !value.includes("\\") &&
46
- !value.split("/").includes(".."), "must be a relative POSIX path without traversal");
47
- const hash64 = z.string().regex(/^[a-f0-9]{64}$/);
48
- /**
49
- * Buffer-safe SHA-256. Unlike `frontend-implementation-contract.ts`'s
50
- * `sha256Hex` (utf8-only), this accepts a raw Buffer so non-UTF-8 files are
51
- * fingerprinted by their exact bytes.
52
- */
53
- export function sha256HexBytes(input) {
54
- return createHash("sha256").update(input).digest("hex");
55
- }
56
- /** Baseline hash sentinel for a path that had no baseline content. */
57
- export function absentBaselineSha256(relPath) {
58
- return sha256HexBytes(`absent:${relPath}`);
59
- }
60
- export const frontendWriterExistingFileSchema = z
61
- .object({
62
- path: safePath,
63
- state: frontendWriterFileStateSchema,
64
- mode: z.string().regex(/^[0-7]+$/).optional(),
65
- baselineSha256: hash64,
66
- baselineContentBase64: z.string().nullable(),
67
- symlinkTarget: z.string().optional(),
68
- renameTarget: safePath.optional(),
69
- })
70
- .strict();
71
- export const frontendWriterJournalFileSchema = z
72
- .object({
73
- state: frontendWriterPathStateSchema,
74
- baselineSha256: hash64,
75
- attemptSha256: hash64.nullable(),
76
- restoreSha256: hash64.nullable(),
77
- })
78
- .strict();
79
- export const frontendWriterRollbackV1Schema = z
80
- .object({
81
- schemaVersion: z.literal(1),
82
- schemaId: z.literal(FRONTEND_WRITER_ROLLBACK_SCHEMA_ID),
83
- capturedAt: z.string().min(1),
84
- writeSet: z.array(safePath).min(1),
85
- existingFiles: z.array(frontendWriterExistingFileSchema),
86
- allowedNewFiles: z.array(z.object({ pathPattern: safePath }).strict()),
87
- attemptChangedPaths: z.array(safePath),
88
- attemptFileHashes: z.record(safePath, z.union([hash64, z.literal("absent")])),
89
- journal: z
90
- .object({
91
- state: frontendWriterJournalStateSchema,
92
- blockedReason: z.string().nullable(),
93
- files: z.record(safePath, frontendWriterJournalFileSchema),
94
- })
95
- .strict(),
96
- })
97
- .strict()
98
- .superRefine((value, ctx) => {
99
- // completed requires every journal path to be verified.
100
- if (value.journal.state === "completed") {
101
- for (const [filePath, file] of Object.entries(value.journal.files)) {
102
- if (file.state !== "verified") {
103
- ctx.addIssue({
104
- code: z.ZodIssueCode.custom,
105
- message: `journal completed but ${filePath} is not verified`,
106
- path: ["journal", "files", filePath],
107
- });
108
- }
109
- }
110
- }
111
- // blocked requires an explicit reason.
112
- if (value.journal.state === "blocked" && !value.journal.blockedReason) {
113
- ctx.addIssue({
114
- code: z.ZodIssueCode.custom,
115
- message: "blocked journal requires a non-empty blockedReason",
116
- path: ["journal", "blockedReason"],
117
- });
118
- }
119
- // deleted baselines must be recorded absent after the attempt.
120
- for (const file of value.existingFiles) {
121
- if (file.state === "deleted" && value.attemptFileHashes[file.path] !== "absent") {
122
- ctx.addIssue({
123
- code: z.ZodIssueCode.custom,
124
- message: `deleted baseline ${file.path} must be absent in attemptFileHashes`,
125
- path: ["attemptFileHashes", file.path],
126
- });
127
- }
128
- }
129
- // rename boundaries must be provable: old path absent, new path hashed,
130
- // and the new path is recorded as an attempt-changed path.
131
- const existingByPath = new Map(value.existingFiles.map((file) => [file.path, file]));
132
- for (const file of value.existingFiles) {
133
- if (file.state !== "renamed")
134
- continue;
135
- if (!file.renameTarget) {
136
- ctx.addIssue({
137
- code: z.ZodIssueCode.custom,
138
- message: `renamed file ${file.path} requires renameTarget`,
139
- path: ["existingFiles", file.path],
140
- });
141
- continue;
142
- }
143
- if (!value.attemptChangedPaths.includes(file.renameTarget)) {
144
- ctx.addIssue({
145
- code: z.ZodIssueCode.custom,
146
- message: `rename target ${file.renameTarget} missing from attemptChangedPaths`,
147
- path: ["existingFiles", file.path],
148
- });
149
- }
150
- if (value.attemptFileHashes[file.path] !== "absent") {
151
- ctx.addIssue({
152
- code: z.ZodIssueCode.custom,
153
- message: `renamed old path ${file.path} must be absent after attempt`,
154
- path: ["attemptFileHashes", file.path],
155
- });
156
- }
157
- const targetHash = value.attemptFileHashes[file.renameTarget];
158
- if (targetHash === undefined || targetHash === "absent") {
159
- ctx.addIssue({
160
- code: z.ZodIssueCode.custom,
161
- message: `rename target ${file.renameTarget} requires a content hash`,
162
- path: ["attemptFileHashes", file.renameTarget],
163
- });
164
- }
165
- const colliding = existingByPath.get(file.renameTarget);
166
- if (colliding &&
167
- colliding.path !== file.path &&
168
- colliding.state !== "deleted" &&
169
- colliding.state !== "renamed") {
170
- ctx.addIssue({
171
- code: z.ZodIssueCode.custom,
172
- message: `rename target ${file.renameTarget} collides with a baseline file`,
173
- path: ["existingFiles", file.path],
174
- });
175
- }
176
- }
177
- });
178
- function resolveDeps(deps) {
179
- return {
180
- lstat: deps?.lstat ?? lstat,
181
- readFile: deps?.readFile ?? readFile,
182
- writeFile: deps?.writeFile ?? ((p, d) => writeFile(p, d)),
183
- unlink: deps?.unlink ?? unlink,
184
- chmod: deps?.chmod ?? chmod,
185
- mkdir: deps?.mkdir ?? mkdir,
186
- readdir: deps?.readdir ?? ((p, o) => readdir(p, o)),
187
- readlink: deps?.readlink ?? readlink,
188
- realpath: deps?.realpath ?? realpath,
189
- symlink: deps?.symlink ?? symlink,
190
- now: deps?.now ?? (() => new Date()),
191
- };
192
- }
193
- const SKIPPED_DIR_NAMES = new Set([".git", "node_modules", ".harness", "artifacts"]);
194
- function toAttemptSha256(attemptHash) {
195
- return attemptHash !== undefined && attemptHash !== "absent"
196
- ? attemptHash
197
- : null;
198
- }
199
- async function isSubmoduleDirectory(absDir, deps) {
200
- try {
201
- const gitFile = await deps.lstat(path.join(absDir, ".git"));
202
- return gitFile.isFile();
203
- }
204
- catch {
205
- return false;
206
- }
207
- }
208
- async function walkMatchingEntries(absDir, relDir, writeSet, deps, onMatch) {
209
- const entries = await deps.readdir(absDir, { withFileTypes: true });
210
- for (const entry of entries) {
211
- if (entry.isDirectory()) {
212
- if (SKIPPED_DIR_NAMES.has(entry.name))
213
- continue;
214
- const childRel = relDir ? `${relDir}/${entry.name}` : entry.name;
215
- const childAbs = path.join(absDir, entry.name);
216
- if (writeSet.some((pattern) => pathMatchesPattern(childRel, pattern)) &&
217
- (await isSubmoduleDirectory(childAbs, deps))) {
218
- onMatch({ relPath: childRel, kind: "dir" });
219
- }
220
- await walkMatchingEntries(childAbs, childRel, writeSet, deps, onMatch);
221
- continue;
222
- }
223
- const childRel = relDir ? `${relDir}/${entry.name}` : entry.name;
224
- if (!writeSet.some((pattern) => pathMatchesPattern(childRel, pattern))) {
225
- continue;
226
- }
227
- onMatch({
228
- relPath: childRel,
229
- kind: entry.isSymbolicLink() ? "symlink" : "file",
230
- });
231
- }
232
- }
233
- /**
234
- * AC-2: two-segment baseline inventory. The first segment snapshots every
235
- * existing file/symlink/submodule matched by the writeSet globs (content +
236
- * mode + state); the second records concrete (non-glob) writeSet entries that
237
- * do not yet exist as allowed-new rules.
238
- */
239
- export async function captureWriterBaseline(repoRoot, writeSet, options) {
240
- if (writeSet.length === 0) {
241
- throw new Error("captureWriterBaseline requires a non-empty writeSet");
242
- }
243
- const deps = resolveDeps(options?.dependencies);
244
- const capturedAt = (options?.now ?? deps.now()).toISOString();
245
- const matches = [];
246
- await walkMatchingEntries(repoRoot, "", writeSet, deps, (match) => matches.push(match));
247
- matches.sort((a, b) => a.relPath.localeCompare(b.relPath));
248
- const existingFiles = [];
249
- for (const match of matches) {
250
- const abs = path.join(repoRoot, match.relPath);
251
- const st = await deps.lstat(abs);
252
- const mode = (st.mode & 0o777777).toString(8);
253
- if (match.kind === "symlink") {
254
- const target = await deps
255
- .realpath(abs)
256
- .catch(async () => deps.readlink(abs));
257
- existingFiles.push({
258
- path: match.relPath,
259
- state: "symlink",
260
- mode,
261
- baselineSha256: sha256HexBytes(`symlink:${match.relPath}->${target}`),
262
- baselineContentBase64: null,
263
- symlinkTarget: target,
264
- });
265
- continue;
266
- }
267
- if (match.kind === "dir") {
268
- existingFiles.push({
269
- path: match.relPath,
270
- state: "present",
271
- mode,
272
- baselineSha256: sha256HexBytes(`dir:${match.relPath}`),
273
- baselineContentBase64: null,
274
- });
275
- continue;
276
- }
277
- const bytes = await deps.readFile(abs);
278
- existingFiles.push({
279
- path: match.relPath,
280
- state: "present",
281
- mode,
282
- baselineSha256: sha256HexBytes(bytes),
283
- baselineContentBase64: bytes.toString("base64"),
284
- });
285
- }
286
- const existingPaths = new Set(existingFiles.map((file) => file.path));
287
- const allowedNewFiles = [];
288
- for (const pattern of writeSet) {
289
- if (pattern.includes("*"))
290
- continue;
291
- const normalized = pattern.replace(/\/+$/, "");
292
- if (!normalized || existingPaths.has(normalized))
293
- continue;
294
- try {
295
- await deps.lstat(path.join(repoRoot, normalized));
296
- // Concrete path already exists (file or directory): not an allowed-new rule.
297
- continue;
298
- }
299
- catch {
300
- // Missing concrete path → allowed-new rule.
301
- }
302
- if (!allowedNewFiles.some((entry) => entry.pathPattern === normalized)) {
303
- allowedNewFiles.push({ pathPattern: normalized });
304
- }
305
- }
306
- allowedNewFiles.sort((a, b) => a.pathPattern.localeCompare(b.pathPattern));
307
- const journalFiles = {};
308
- for (const file of existingFiles) {
309
- journalFiles[file.path] = {
310
- state: "pending",
311
- baselineSha256: file.baselineSha256,
312
- attemptSha256: null,
313
- restoreSha256: null,
314
- };
315
- }
316
- return {
317
- schemaVersion: 1,
318
- schemaId: FRONTEND_WRITER_ROLLBACK_SCHEMA_ID,
319
- capturedAt,
320
- writeSet: [...writeSet].sort((a, b) => a.localeCompare(b)),
321
- existingFiles,
322
- allowedNewFiles,
323
- attemptChangedPaths: [],
324
- attemptFileHashes: {},
325
- journal: { state: "pending", blockedReason: null, files: journalFiles },
326
- };
327
- }
328
- export function isJournalTerminal(journal) {
329
- return (journal.journal.state === "completed" ||
330
- journal.journal.state === "blocked");
331
- }
332
- export async function writeJournalFile(filePath, journal) {
333
- await writeJsonAtomic(filePath, journal);
334
- }
335
- export async function readJournalFile(filePath) {
336
- try {
337
- const raw = JSON.parse(await readFile(filePath, "utf8"));
338
- const parsed = frontendWriterRollbackV1Schema.safeParse(raw);
339
- return parsed.success ? parsed.data : undefined;
340
- }
341
- catch {
342
- return undefined;
343
- }
344
- }
345
- async function inspectPath(repoRoot, relPath, deps) {
346
- const abs = path.join(repoRoot, relPath);
347
- try {
348
- const st = await deps.lstat(abs);
349
- const mode = (st.mode & 0o777777).toString(8);
350
- if (st.isSymbolicLink()) {
351
- const target = await deps
352
- .realpath(abs)
353
- .catch(async () => deps.readlink(abs));
354
- return {
355
- kind: "symlink",
356
- hash: sha256HexBytes(`symlink:${relPath}->${target}`),
357
- mode,
358
- };
359
- }
360
- if (st.isDirectory()) {
361
- return { kind: "dir", hash: sha256HexBytes(`dir:${relPath}`), mode };
362
- }
363
- const bytes = await deps.readFile(abs);
364
- return { kind: "file", hash: sha256HexBytes(bytes), mode };
365
- }
366
- catch (error) {
367
- if (error.code === "ENOENT") {
368
- return { kind: "absent" };
369
- }
370
- throw error;
371
- }
372
- }
373
- function ensureJournalFile(working, relPath, baselineSha256, attemptSha256) {
374
- let file = working.journal.files[relPath];
375
- if (!file) {
376
- file = {
377
- state: "pending",
378
- baselineSha256,
379
- attemptSha256,
380
- restoreSha256: null,
381
- };
382
- working.journal.files[relPath] = file;
383
- }
384
- else if (attemptSha256 !== null && file.attemptSha256 === null) {
385
- file.attemptSha256 = attemptSha256;
386
- }
387
- return file;
388
- }
389
- function markVerified(working, relPath, baselineSha256, restoreSha256) {
390
- const file = ensureJournalFile(working, relPath, baselineSha256, null);
391
- file.state = "verified";
392
- file.restoreSha256 = restoreSha256;
393
- }
394
- function markFailed(working, relPath, baselineSha256) {
395
- const file = ensureJournalFile(working, relPath, baselineSha256, null);
396
- file.state = "failed";
397
- }
398
- function dedupe(values) {
399
- return [...new Set(values)].sort((a, b) => a.localeCompare(b));
400
- }
401
- function collectVerifiedPaths(working) {
402
- return Object.entries(working.journal.files)
403
- .filter(([, file]) => file.state === "verified")
404
- .map(([relPath]) => relPath)
405
- .sort((a, b) => a.localeCompare(b));
406
- }
407
- /** Writes baseline bytes (or re-creates the symlink) and returns the after hash. */
408
- async function restoreBaselineContent(repoRoot, existing, deps) {
409
- const abs = path.join(repoRoot, existing.path);
410
- await deps.mkdir(path.dirname(abs), { recursive: true });
411
- if (existing.state === "symlink" && existing.symlinkTarget !== undefined) {
412
- await deps.unlink(abs).catch(() => { });
413
- await deps.symlink(existing.symlinkTarget, abs);
414
- return sha256HexBytes(`symlink:${existing.path}->${existing.symlinkTarget}`);
415
- }
416
- const bytes = Buffer.from(existing.baselineContentBase64 ?? "", "base64");
417
- await deps.writeFile(abs, bytes);
418
- if (existing.mode !== undefined) {
419
- await deps.chmod(abs, parseInt(existing.mode, 8));
420
- }
421
- const reread = await deps.readFile(abs);
422
- return sha256HexBytes(reread);
423
- }
424
- async function restoreAndVerifyBaseline(input) {
425
- const { repoRoot, working, deps, existing } = input;
426
- const attemptSha256 = toAttemptSha256(working.attemptFileHashes[existing.path]);
427
- ensureJournalFile(working, existing.path, existing.baselineSha256, attemptSha256);
428
- working.journal.files[existing.path].state = "restoring";
429
- try {
430
- const afterHash = await restoreBaselineContent(repoRoot, existing, deps);
431
- if (afterHash !== existing.baselineSha256) {
432
- working.journal.files[existing.path].state = "failed";
433
- return false;
434
- }
435
- markVerified(working, existing.path, existing.baselineSha256, afterHash);
436
- return true;
437
- }
438
- catch {
439
- working.journal.files[existing.path].state = "failed";
440
- return false;
441
- }
442
- }
443
- async function deleteAndVerifyAttemptFile(input) {
444
- const { repoRoot, working, deps, relPath } = input;
445
- const baselineSha256 = absentBaselineSha256(relPath);
446
- const attemptSha256 = toAttemptSha256(working.attemptFileHashes[relPath]);
447
- ensureJournalFile(working, relPath, baselineSha256, attemptSha256);
448
- working.journal.files[relPath].state = "restoring";
449
- try {
450
- await deps.unlink(path.join(repoRoot, relPath));
451
- const after = await inspectPath(repoRoot, relPath, deps);
452
- if (after.kind !== "absent") {
453
- working.journal.files[relPath].state = "failed";
454
- return false;
455
- }
456
- markVerified(working, relPath, baselineSha256, null);
457
- return true;
458
- }
459
- catch {
460
- working.journal.files[relPath].state = "failed";
461
- return false;
462
- }
463
- }
464
- async function restoreRenamedPath(input) {
465
- const { repoRoot, working, deps, oldPath, existing, renameTarget, targetHash } = input;
466
- if (targetHash === undefined || targetHash === "absent") {
467
- return {
468
- ok: false,
469
- blockedReason: `rename target ${renameTarget} has no recorded attempt hash`,
470
- };
471
- }
472
- const newCurrent = await inspectPath(repoRoot, renameTarget, deps);
473
- if (newCurrent.kind !== "absent" && newCurrent.hash !== targetHash) {
474
- return {
475
- ok: false,
476
- blockedReason: `auto-recovery-blocked: rename target ${renameTarget} changed externally`,
477
- };
478
- }
479
- const oldCurrent = await inspectPath(repoRoot, oldPath, deps);
480
- if (oldCurrent.kind !== "absent") {
481
- return {
482
- ok: false,
483
- blockedReason: `cannot restore renamed baseline ${oldPath}: path is occupied`,
484
- };
485
- }
486
- const newBaseline = absentBaselineSha256(renameTarget);
487
- ensureJournalFile(working, renameTarget, newBaseline, toAttemptSha256(targetHash));
488
- working.journal.files[renameTarget].state = "restoring";
489
- try {
490
- if (newCurrent.kind !== "absent") {
491
- await deps.unlink(path.join(repoRoot, renameTarget));
492
- }
493
- markVerified(working, renameTarget, newBaseline, null);
494
- }
495
- catch {
496
- working.journal.files[renameTarget].state = "failed";
497
- return { ok: false, blockedReason: `failed to delete rename target ${renameTarget}` };
498
- }
499
- const restored = await restoreAndVerifyBaseline({
500
- repoRoot,
501
- working,
502
- deps,
503
- existing,
504
- });
505
- if (!restored) {
506
- return {
507
- ok: false,
508
- blockedReason: `failed to restore renamed baseline ${oldPath}`,
509
- };
510
- }
511
- return { ok: true, restoredPaths: [oldPath, renameTarget] };
512
- }
513
- function finishBlocked(working, blockedReason, restoredPaths) {
514
- working.journal.state = "blocked";
515
- working.journal.blockedReason = blockedReason;
516
- return {
517
- state: "blocked",
518
- blockedReason,
519
- restoredPaths: dedupe(restoredPaths),
520
- journal: working,
521
- };
522
- }
523
- /**
524
- * AC-3/AC-4: CAS restore matrix driven by `attemptChangedPaths` +
525
- * `attemptFileHashes`. Fail-closed: any path whose current hash is neither the
526
- * baseline nor the recorded attempt hash blocks the whole journal without
527
- * overwriting/deleting that file. `git reset --hard` is never used.
528
- */
529
- export async function restoreJournal(snapshot, options) {
530
- const deps = resolveDeps(options.dependencies);
531
- const repoRoot = options.repoRoot;
532
- const working = structuredClone(snapshot);
533
- const existingByPath = new Map(working.existingFiles.map((file) => [file.path, file]));
534
- // Fail-closed: every attempt-changed path must stay inside the writeSet.
535
- for (const changedPath of working.attemptChangedPaths) {
536
- if (!working.writeSet.some((pattern) => pathMatchesPattern(changedPath, pattern))) {
537
- return finishBlocked(working, `auto-recovery-blocked: attempt changed path outside writeSet: ${changedPath}`, []);
538
- }
539
- }
540
- if (working.journal.state === "completed" || working.journal.state === "blocked") {
541
- return {
542
- state: working.journal.state,
543
- blockedReason: working.journal.blockedReason,
544
- restoredPaths: collectVerifiedPaths(working),
545
- journal: working,
546
- };
547
- }
548
- working.journal.state = "restoring";
549
- // Paths requiring recovery action: attempt-changed paths plus baselines the
550
- // attempt deleted or renamed.
551
- const actionPaths = new Set(working.attemptChangedPaths);
552
- for (const file of working.existingFiles) {
553
- if (file.state === "deleted" || file.state === "renamed") {
554
- actionPaths.add(file.path);
555
- if (file.renameTarget)
556
- actionPaths.add(file.renameTarget);
557
- }
558
- }
559
- const restoredPaths = [];
560
- const handled = new Set();
561
- for (const relPath of [...actionPaths].sort((a, b) => a.localeCompare(b))) {
562
- if (handled.has(relPath))
563
- continue;
564
- const priorFile = working.journal.files[relPath];
565
- if (priorFile?.state === "verified") {
566
- handled.add(relPath);
567
- restoredPaths.push(relPath);
568
- continue;
569
- }
570
- const existing = existingByPath.get(relPath);
571
- const attemptHash = working.attemptFileHashes[relPath];
572
- if (existing?.state === "renamed") {
573
- const renameTarget = existing.renameTarget;
574
- if (!renameTarget) {
575
- return finishBlocked(working, `renamed baseline ${relPath} has no renameTarget`, restoredPaths);
576
- }
577
- const renameResult = await restoreRenamedPath({
578
- repoRoot,
579
- working,
580
- deps,
581
- oldPath: relPath,
582
- existing,
583
- renameTarget,
584
- targetHash: working.attemptFileHashes[renameTarget],
585
- });
586
- handled.add(relPath);
587
- handled.add(renameTarget);
588
- if (!renameResult.ok) {
589
- return finishBlocked(working, renameResult.blockedReason, restoredPaths);
590
- }
591
- restoredPaths.push(...renameResult.restoredPaths);
592
- continue;
593
- }
594
- if (attemptHash === undefined) {
595
- return finishBlocked(working, `attempt changed path ${relPath} has no recorded attempt hash`, restoredPaths);
596
- }
597
- const current = await inspectPath(repoRoot, relPath, deps);
598
- if (existing) {
599
- if (attemptHash === "absent") {
600
- // Attempt deleted the baseline → restore it (CAS: path must be absent).
601
- if (current.kind !== "absent") {
602
- return finishBlocked(working, `cannot restore deleted baseline ${relPath}: path exists`, restoredPaths);
603
- }
604
- const ok = await restoreAndVerifyBaseline({
605
- repoRoot,
606
- working,
607
- deps,
608
- existing,
609
- });
610
- if (!ok) {
611
- return finishBlocked(working, `failed to restore baseline content for ${relPath}`, restoredPaths);
612
- }
613
- handled.add(relPath);
614
- restoredPaths.push(relPath);
615
- continue;
616
- }
617
- // Attempt modified the baseline → restore only when current == attempt.
618
- if (current.kind !== "absent" && current.hash === existing.baselineSha256) {
619
- markVerified(working, relPath, existing.baselineSha256, existing.baselineSha256);
620
- handled.add(relPath);
621
- restoredPaths.push(relPath);
622
- continue;
623
- }
624
- if (current.kind === "absent" || current.hash !== attemptHash) {
625
- return finishBlocked(working, `auto-recovery-blocked: ${relPath} changed externally`, restoredPaths);
626
- }
627
- const ok = await restoreAndVerifyBaseline({
628
- repoRoot,
629
- working,
630
- deps,
631
- existing,
632
- });
633
- if (!ok) {
634
- return finishBlocked(working, `failed to restore baseline content for ${relPath}`, restoredPaths);
635
- }
636
- handled.add(relPath);
637
- restoredPaths.push(relPath);
638
- continue;
639
- }
640
- // Baseline absent → attempt-created file → delete only when current == attempt.
641
- if (attemptHash === "absent") {
642
- return finishBlocked(working, `inconsistent attempt state for new path ${relPath}`, restoredPaths);
643
- }
644
- if (current.kind === "absent") {
645
- markVerified(working, relPath, absentBaselineSha256(relPath), null);
646
- handled.add(relPath);
647
- restoredPaths.push(relPath);
648
- continue;
649
- }
650
- if (current.hash !== attemptHash) {
651
- return finishBlocked(working, `auto-recovery-blocked: new file ${relPath} changed externally`, restoredPaths);
652
- }
653
- const ok = await deleteAndVerifyAttemptFile({
654
- repoRoot,
655
- working,
656
- deps,
657
- relPath,
658
- });
659
- if (!ok) {
660
- return finishBlocked(working, `failed to delete attempt-created file ${relPath}`, restoredPaths);
661
- }
662
- handled.add(relPath);
663
- restoredPaths.push(relPath);
664
- }
665
- // Completion phase: every journal path must be verified (AC-4). Unchanged
666
- // baselines are hash-verified to still equal the baseline (fail-closed).
667
- for (const [relPath, jf] of Object.entries(working.journal.files)) {
668
- if (jf.state === "verified")
669
- continue;
670
- if (jf.state === "failed") {
671
- return finishBlocked(working, `path ${relPath} failed during restore`, restoredPaths);
672
- }
673
- const existing = existingByPath.get(relPath);
674
- if (existing &&
675
- existing.state !== "deleted" &&
676
- existing.state !== "renamed") {
677
- const current = await inspectPath(repoRoot, relPath, deps);
678
- if (current.kind !== "absent" && current.hash === existing.baselineSha256) {
679
- markVerified(working, relPath, existing.baselineSha256, existing.baselineSha256);
680
- continue;
681
- }
682
- return finishBlocked(working, `auto-recovery-blocked: baseline path ${relPath} changed unexpectedly`, restoredPaths);
683
- }
684
- return finishBlocked(working, `unresolved journal path ${relPath}`, restoredPaths);
685
- }
686
- working.journal.state = "completed";
687
- working.journal.blockedReason = null;
688
- return {
689
- state: "completed",
690
- blockedReason: null,
691
- restoredPaths: dedupe(restoredPaths),
692
- journal: working,
693
- };
694
- }
695
- /**
696
- * AC-5: crash-recovery decision pass over a journal stuck in `restoring`. It
697
- * never writes/overwrites a file; it only classifies each path by hash:
698
- * current == baseline → verified; current == recorded attempt hash (or the
699
- * recorded absent state) → continuable; anything else → blocked (unprovable
700
- * boundary, never overwritten).
701
- */
702
- export async function recoverRestoringJournal(snapshot, options) {
703
- const deps = resolveDeps(options.dependencies);
704
- const working = structuredClone(snapshot);
705
- const verifiedPaths = [];
706
- const continuablePaths = [];
707
- for (const [relPath, file] of Object.entries(working.journal.files).sort(([a], [b]) => a.localeCompare(b))) {
708
- if (file.state === "verified") {
709
- verifiedPaths.push(relPath);
710
- continue;
711
- }
712
- if (file.state === "failed") {
713
- return {
714
- state: "blocked",
715
- blockedReason: `path ${relPath} previously failed`,
716
- verifiedPaths,
717
- continuablePaths,
718
- journal: working,
719
- };
720
- }
721
- if (file.state !== "restoring") {
722
- continuablePaths.push(relPath);
723
- continue;
724
- }
725
- const current = await inspectPath(options.repoRoot, relPath, deps);
726
- if (current.kind !== "absent" && current.hash === file.baselineSha256) {
727
- file.state = "verified";
728
- file.restoreSha256 = file.baselineSha256;
729
- verifiedPaths.push(relPath);
730
- continue;
731
- }
732
- const attemptHash = working.attemptFileHashes[relPath];
733
- if (attemptHash !== undefined &&
734
- attemptHash !== "absent" &&
735
- current.kind !== "absent" &&
736
- current.hash === attemptHash) {
737
- file.state = "pending";
738
- continuablePaths.push(relPath);
739
- continue;
740
- }
741
- if (attemptHash === "absent" && current.kind === "absent") {
742
- file.state = "pending";
743
- continuablePaths.push(relPath);
744
- continue;
745
- }
746
- return {
747
- state: "blocked",
748
- blockedReason: `cannot prove restore boundary for ${relPath}`,
749
- verifiedPaths,
750
- continuablePaths,
751
- journal: working,
752
- };
753
- }
754
- return {
755
- state: "recovered",
756
- blockedReason: null,
757
- verifiedPaths,
758
- continuablePaths,
759
- journal: working,
760
- };
761
- }
762
- /**
763
- * AC-5: scans a journal directory for every non-terminal journal. Terminal
764
- * journals are never replayed. `pending` journals are restored; `restoring`
765
- * journals go through the read-only crash-recovery decision pass.
766
- */
767
- export async function recoverPendingJournals(journalRoot, options) {
768
- const deps = resolveDeps(options.dependencies);
769
- const outcomes = [];
770
- let entries;
771
- try {
772
- entries = await deps.readdir(journalRoot, { withFileTypes: true });
773
- }
774
- catch (error) {
775
- if (error.code === "ENOENT")
776
- return outcomes;
777
- throw error;
778
- }
779
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
780
- if (!entry.isFile() || !entry.name.endsWith(".json"))
781
- continue;
782
- const journalPath = path.join(journalRoot, entry.name);
783
- const snapshot = await readJournalFile(journalPath);
784
- if (!snapshot) {
785
- outcomes.push({
786
- journalPath,
787
- disposition: "blocked",
788
- blockedReason: "journal unreadable or invalid",
789
- result: null,
790
- });
791
- continue;
792
- }
793
- if (isJournalTerminal(snapshot)) {
794
- outcomes.push({
795
- journalPath,
796
- disposition: "skipped-terminal",
797
- blockedReason: null,
798
- result: null,
799
- });
800
- continue;
801
- }
802
- if (snapshot.journal.state === "pending") {
803
- const result = await restoreJournal(snapshot, options);
804
- outcomes.push({
805
- journalPath,
806
- disposition: result.state === "completed" ? "restored" : "blocked",
807
- blockedReason: result.blockedReason,
808
- result,
809
- });
810
- continue;
811
- }
812
- const result = await recoverRestoringJournal(snapshot, options);
813
- outcomes.push({
814
- journalPath,
815
- disposition: result.state === "recovered" ? "recovered" : "blocked",
816
- blockedReason: result.blockedReason,
817
- result,
818
- });
819
- }
820
- return outcomes;
821
- }