| 1 | import assert from "node:assert/strict"; |
| 2 | import { |
| 3 | normalizeRecoveryLineageView, |
| 4 | pendingRecoveryMatchesRoots, |
| 5 | pendingSessionRecovery, |
| 6 | recoveryLineageResolution, |
| 7 | sanitizedRecoveryReason, |
| 8 | SessionRecoveryDivergenceTracker, |
| 9 | userVisibleRecoveryVersions, |
| 10 | } from "../lib/sessionRecoveryVersions"; |
| 11 | import type { RecoveryLineageView, SessionRecoveryEvent } from "../lib/types"; |
| 12 | |
| 13 | const view = (overrides: Partial<RecoveryLineageView> = {}): RecoveryLineageView => ({ |
| 14 | groupId: "group", |
| 15 | state: "diverged", |
| 16 | branchCount: 3, |
| 17 | unresolved: 1, |
| 18 | cleanupEligible: 1, |
| 19 | members: [ |
| 20 | { path: "/s/root.jsonl", role: "normal", canonical: true, turns: 3, open: true, running: false }, |
| 21 | { path: "/s/copy.jsonl", role: "covered_copy", canonical: false, turns: 3, open: false, running: false }, |
| 22 | { path: "/s/fork.jsonl", role: "diverged", canonical: false, turns: 4, open: false, running: false }, |
| 23 | ], |
| 24 | ...overrides, |
| 25 | }); |
| 26 | |
| 27 | assert.deepEqual(normalizeRecoveryLineageView({ members: null }).members, [], "null bridge arrays normalize to []"); |
| 28 | assert.equal(userVisibleRecoveryVersions(view()).length, 2, "covered copies never enter the user-facing version list"); |
| 29 | assert.equal(userVisibleRecoveryVersions(view({ members: [ |
| 30 | { path: "/s/root.jsonl", role: "normal", versionKind: "normal", canonical: true, turns: 3, open: true, running: false }, |
| 31 | { path: "/s/child.jsonl", role: "diverged", versionKind: "subagent", canonical: false, turns: 1, open: false, running: false }, |
| 32 | ] })).length, 1, "subagent transcripts never appear as recovery versions"); |
| 33 | assert.equal(recoveryLineageResolution(view()), "notify", "confirmed unique divergence notifies"); |
| 34 | assert.equal(recoveryLineageResolution(view({ state: "repairing" })), "wait", "catalog rebuilding waits for a later revision"); |
| 35 | assert.equal(recoveryLineageResolution(view({ state: "covered", unresolved: 0 })), "clear", "proven covered copies clear silently"); |
| 36 | assert.equal(pendingRecoveryMatchesRoots({ eventKey: "e", topic: { scope: "project", workspaceRoot: "/a", topicId: "t" } }, ["/b"]), false, "unrelated catalog roots do not trigger lineage reads"); |
| 37 | assert.equal(pendingRecoveryMatchesRoots({ eventKey: "e", topic: { scope: "global", topicId: "t" } }, [""]), true, "global catalog revisions match the global pending item"); |
| 38 | assert.equal(pendingRecoveryMatchesRoots({ eventKey: "e", topic: { scope: "project", workspaceRoot: "/a", topicId: "t" } }, []), true, "rootless rebuild revisions recheck every pending item"); |
| 39 | assert.equal(recoveryLineageResolution(view({ state: "adopted", unresolved: 0 })), "clear", "adopted lineages stay silent"); |
| 40 | assert.equal(recoveryLineageResolution(view({ state: "preferred", unresolved: 0 })), "clear", "preferred lineages stay silent"); |
| 41 | assert.equal(recoveryLineageResolution(view({ state: "", unresolved: 0 })), "clear", "classified covered lineages stay silent"); |
| 42 | assert.equal(recoveryLineageResolution(view({ |
| 43 | branchCount: 1, |
| 44 | unresolved: 1, |
| 45 | cleanupEligible: 0, |
| 46 | members: [{ path: "/s/only.jsonl", role: "diverged", canonical: true, turns: 4, open: false, running: false }], |
| 47 | })), "clear", "a unique rootless recovery version clears instead of leaking a pending check"); |
| 48 | |
| 49 | const event: SessionRecoveryEvent = { |
| 50 | recoveryPath: "/s/fork.jsonl", |
| 51 | scope: "project", |
| 52 | workspaceRoot: "/work", |
| 53 | topicId: "topic", |
| 54 | recoveryParentId: "root", |
| 55 | recoveryReason: "snapshot_conflict", |
| 56 | }; |
| 57 | const pending = pendingSessionRecovery(event); |
| 58 | assert.ok(pending, "valid recovery events produce a pending catalog check"); |
| 59 | assert.equal(pending?.topic.path, event.recoveryPath, "pending checks bind lineage reads to the recovered physical group"); |
| 60 | assert.equal(pendingSessionRecovery({ recoveryPath: "/s/fork.jsonl" }), null, "legacy events without a topic cannot be misrouted"); |
| 61 | assert.equal(sanitizedRecoveryReason("snapshot_conflict"), "snapshot_conflict"); |
| 62 | assert.equal(sanitizedRecoveryReason("shutdown file lock"), "shutdown_lock"); |
| 63 | assert.equal(sanitizedRecoveryReason("private arbitrary reason"), "other", "diagnostics do not retain arbitrary backend text"); |
| 64 | |
| 65 | const tracker = new SessionRecoveryDivergenceTracker(); |
| 66 | const first = tracker.register(event); |
| 67 | assert.equal(first.isNew, true); |
| 68 | assert.equal(first.occurrence, 1); |
| 69 | assert.equal(tracker.resolve(first.pending!.eventKey, view({ state: "repairing" })), "wait", "out-of-order recovery remains pending"); |
| 70 | assert.equal(tracker.entries().length, 1); |
| 71 | assert.equal(tracker.resolve(first.pending!.eventKey, view()), "notify", "later catalog revision confirms the divergence"); |
| 72 | assert.equal(tracker.entries().length, 0); |
| 73 | assert.equal(tracker.register(event).isNew, false, "duplicate delivery after notification is suppressed"); |
| 74 | |
| 75 | const second = tracker.register({ ...event, recoveryPath: "/s/fork-2.jsonl" }); |
| 76 | assert.equal(second.occurrence, 2, "a later distinct recovery on the same topic is counted separately"); |
| 77 | assert.equal(tracker.resolve(second.pending!.eventKey, view({ state: "adopted", unresolved: 0 })), "clear"); |
| 78 | |
| 79 | console.log(" PASS session recovery divergence state machine"); |
| 80 |