返回 DeepSeek-Reasonix
sessionRecoveryVersions.ts
根目录 / desktop / frontend / src / lib / sessionRecoveryVersions.ts
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
117 lines TYPESCRIPT