返回 DeepSeek-Reasonix
sessionRecoveryRuntime.ts
根目录 / desktop / frontend / src / lib / sessionRecoveryRuntime.ts
1 import { app, onSessionActiveVersionChanged, onSessionRecovered } from "./bridge";
2 import { recordFrontendDiagnostic } from "./frontendDiagnosticBridge";
3 import { onProjectTreeChangedV2 } from "./sessionCatalogBridge";
4 import type { ProjectTopicKey } from "./sessionCatalogTypes";
5 import type { RecoveryLineageView } from "./types";
6 import {
7 normalizeRecoveryLineageView,
8 pendingRecoveryMatchesRoots,
9 sanitizedRecoveryReason,
10 SessionRecoveryDivergenceTracker,
11 type PendingSessionRecovery,
12 } from "./sessionRecoveryVersions";
13
14 type SessionRecoveryRuntimeOptions = {
15 onRecovered: () => void;
16 onDiverged: (topic: ProjectTopicKey, view: RecoveryLineageView) => void;
17 };
18
19 // Recovery is an exceptional path, so App loads this coordinator lazily. Its
20 // classification is revision-driven; it never sleeps or polls.
21 export function startSessionRecoveryRuntime(options: SessionRecoveryRuntimeOptions): () => void {
22 const tracker = new SessionRecoveryDivergenceTracker();
23 const inFlight = new Set<string>();
24 const reconciling = new Set<string>();
25 let stopped = false;
26
27 const settle = async (pending: PendingSessionRecovery) => {
28 if (stopped || inFlight.has(pending.eventKey)) return;
29 inFlight.add(pending.eventKey);
30 try {
31 const view = normalizeRecoveryLineageView(await app.GetRecoveryLineage({
32 ...pending.topic,
33 recordClassification: true,
34 }));
35 if (stopped) return;
36 const resolution = tracker.resolve(pending.eventKey, view);
37 if (resolution === "wait") return;
38 recordFrontendDiagnostic("runtime", "session.recovery-classified", {
39 status: "ok",
40 state: view.state || "covered",
41 outcome: resolution,
42 total: view.branchCount,
43 });
44 if (resolution === "notify") options.onDiverged(pending.topic, view);
45 } catch {
46 // Catalog rebuilds are transient. Retry only on a later matching
47 // project-tree revision.
48 } finally {
49 inFlight.delete(pending.eventKey);
50 }
51 };
52
53 const unsubscribeRecovery = onSessionRecovered((event) => {
54 const registration = tracker.register(event);
55 if (!registration.isNew) return;
56 recordFrontendDiagnostic("runtime", "session.recovered", {
57 status: "ok",
58 reason: sanitizedRecoveryReason(event.recoveryReason),
59 outcome: event.existing ? "existing" : "created",
60 total: registration.occurrence,
61 });
62 options.onRecovered();
63 const topicId = event.topicId;
64 const reconcileKey = topicId ? [event.scope, event.workspaceRoot, topicId].join("\u0000") : "";
65 if (topicId && !reconciling.has(reconcileKey) && typeof app.ReconcileRecoveryVersions === "function") {
66 reconciling.add(reconcileKey);
67 void app.ReconcileRecoveryVersions({
68 scope: event.scope || (event.workspaceRoot ? "project" : "global"),
69 workspaceRoot: event.workspaceRoot,
70 topicId,
71 path: event.recoveryPath,
72 }).catch(() => {
73 // Background reconciliation can race catalog rebuilds. Keep the
74 // recovery pending for classification on a later catalog revision;
75 // raw backend errors can contain private session paths.
76 if (!stopped) recordFrontendDiagnostic("runtime", "session.recovery-reconcile", {
77 status: "error", reason: "reconcile_failed",
78 });
79 }).finally(() => reconciling.delete(reconcileKey));
80 }
81 });
82 const unsubscribeActiveVersion = onSessionActiveVersionChanged(() => {
83 if (!stopped) options.onRecovered();
84 });
85 const unsubscribeCatalog = onProjectTreeChangedV2((event) => {
86 for (const pending of tracker.entries()) {
87 if (pendingRecoveryMatchesRoots(pending, event.roots)) void settle(pending);
88 }
89 });
90
91 return () => {
92 stopped = true;
93 unsubscribeRecovery();
94 unsubscribeActiveVersion();
95 unsubscribeCatalog();
96 };
97 }
98
98 lines TYPESCRIPT