| 1 | import { asArray } from "./array"; |
| 2 | import type { ProjectTopicKey } from "./sessionCatalogTypes"; |
| 3 | import type { RecoveryLineageMember, RecoveryLineageView, SessionRecoveryEvent } from "./types"; |
| 4 | |
| 5 | export type PendingSessionRecovery = { |
| 6 | eventKey: string; |
| 7 | topic: ProjectTopicKey; |
| 8 | }; |
| 9 | |
| 10 | export type RecoveryLineageResolution = "notify" | "clear" | "wait"; |
| 11 | |
| 12 | export type RecoveryEventRegistration = { |
| 13 | pending: PendingSessionRecovery | null; |
| 14 | isNew: boolean; |
| 15 | occurrence: number; |
| 16 | }; |
| 17 | |
| 18 | function text(value: unknown): string { |
| 19 | return typeof value === "string" ? value.trim() : ""; |
| 20 | } |
| 21 | |
| 22 | function count(value: unknown): number { |
| 23 | return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; |
| 24 | } |
| 25 | |
| 26 | export function normalizeRecoveryLineageView(value: unknown): RecoveryLineageView { |
| 27 | const raw = value && typeof value === "object" ? value as Partial<RecoveryLineageView> : {}; |
| 28 | return { |
| 29 | groupId: text(raw.groupId), |
| 30 | state: text(raw.state), |
| 31 | branchCount: count(raw.branchCount), |
| 32 | unresolved: count(raw.unresolved), |
| 33 | cleanupEligible: count(raw.cleanupEligible), |
| 34 | members: asArray<RecoveryLineageMember>(raw.members).filter((member) => Boolean(member && text(member.path))), |
| 35 | }; |
| 36 | } |
| 37 | |
| 38 | export function userVisibleRecoveryVersions(view: Pick<RecoveryLineageView, "members">): RecoveryLineageMember[] { |
| 39 | return asArray(view.members) |
| 40 | .filter((member) => member.role !== "covered_copy" && member.versionKind !== "subagent") |
| 41 | .sort((left, right) => Number(right.canonical) - Number(left.canonical) |
| 42 | || count(right.lastActivityAt || right.createdAt) - count(left.lastActivityAt || left.createdAt)); |
| 43 | } |
| 44 | |
| 45 | export function recoveryLineageResolution(view: RecoveryLineageView): RecoveryLineageResolution { |
| 46 | if (!view.groupId || view.state === "repairing") return "wait"; |
| 47 | const visibleVersions = userVisibleRecoveryVersions(view); |
| 48 | if (view.state === "diverged" && view.unresolved > 0 && visibleVersions.length > 1) return "notify"; |
| 49 | if (visibleVersions.length <= 1 && asArray(view.members).length > 0) return "clear"; |
| 50 | if (view.state === "covered" || view.state === "adopted" || view.state === "preferred") return "clear"; |
| 51 | if (view.unresolved === 0 && asArray(view.members).length > 0) return "clear"; |
| 52 | return "wait"; |
| 53 | } |
| 54 | |
| 55 | export function pendingSessionRecovery(event: SessionRecoveryEvent): PendingSessionRecovery | null { |
| 56 | const recoveryPath = text(event.recoveryPath); |
| 57 | const topicId = text(event.topicId); |
| 58 | if (!recoveryPath || !topicId) return null; |
| 59 | const scope = text(event.scope) || (text(event.workspaceRoot) ? "project" : "global"); |
| 60 | const workspaceRoot = scope === "project" ? text(event.workspaceRoot) : ""; |
| 61 | const parent = text(event.recoveryParentId); |
| 62 | return { |
| 63 | eventKey: [scope, workspaceRoot, topicId, parent, recoveryPath].join("\u0000"), |
| 64 | topic: { scope, workspaceRoot: workspaceRoot || undefined, topicId, path: recoveryPath }, |
| 65 | }; |
| 66 | } |
| 67 | |
| 68 | export function recoveryTopicOccurrenceKey(topic: ProjectTopicKey): string { |
| 69 | return [text(topic.scope) || "global", text(topic.workspaceRoot), text(topic.topicId)].join("\u0000"); |
| 70 | } |
| 71 | |
| 72 | export function pendingRecoveryMatchesRoots(pending: PendingSessionRecovery, roots: readonly string[]): boolean { |
| 73 | if (roots.length === 0) return true; |
| 74 | const workspaceRoot = pending.topic.scope === "project" ? text(pending.topic.workspaceRoot) : ""; |
| 75 | return roots.some((root) => text(root) === workspaceRoot); |
| 76 | } |
| 77 | |
| 78 | export function sanitizedRecoveryReason(value: unknown): string { |
| 79 | const reason = text(value).toLowerCase(); |
| 80 | if (reason.includes("shutdown") || reason.includes("file_lock")) return "shutdown_lock"; |
| 81 | if (reason.includes("external") || reason.includes("removed")) return "external_change"; |
| 82 | if (reason.includes("snapshot") || reason.includes("conflict") || reason.includes("stale")) return "snapshot_conflict"; |
| 83 | return reason ? "other" : "unknown"; |
| 84 | } |
| 85 | |
| 86 | export class SessionRecoveryDivergenceTracker { |
| 87 | private readonly pending = new Map<string, PendingSessionRecovery>(); |
| 88 | private readonly notified = new Set<string>(); |
| 89 | private readonly topicOccurrences = new Map<string, number>(); |
| 90 | |
| 91 | register(event: SessionRecoveryEvent): RecoveryEventRegistration { |
| 92 | const pending = pendingSessionRecovery(event); |
| 93 | if (!pending) return { pending: null, isNew: false, occurrence: 0 }; |
| 94 | if (this.pending.has(pending.eventKey) || this.notified.has(pending.eventKey)) { |
| 95 | return { pending, isNew: false, occurrence: 0 }; |
| 96 | } |
| 97 | this.pending.set(pending.eventKey, pending); |
| 98 | const topicKey = recoveryTopicOccurrenceKey(pending.topic); |
| 99 | const occurrence = (this.topicOccurrences.get(topicKey) ?? 0) + 1; |
| 100 | this.topicOccurrences.set(topicKey, occurrence); |
| 101 | return { pending, isNew: true, occurrence }; |
| 102 | } |
| 103 | |
| 104 | entries(): PendingSessionRecovery[] { |
| 105 | return [...this.pending.values()]; |
| 106 | } |
| 107 | |
| 108 | resolve(eventKey: string, view: RecoveryLineageView): RecoveryLineageResolution { |
| 109 | const resolution = recoveryLineageResolution(view); |
| 110 | if (resolution === "wait") return resolution; |
| 111 | this.pending.delete(eventKey); |
| 112 | if (resolution !== "notify" || this.notified.has(eventKey)) return "clear"; |
| 113 | this.notified.add(eventKey); |
| 114 | return "notify"; |
| 115 | } |
| 116 | } |
| 117 |