返回 DeepSeek-Reasonix
transcriptSessionFollowerRuntime.ts
根目录 / desktop / frontend / src / lib / transcriptSessionFollowerRuntime.ts
1 import { noteSessionObservation } from "./sessionObservationDiagnostics";
2 import { app } from "./bridge";
3 import { entriesFor, registerTranscriptContentRecovery } from "./canonicalTranscriptBackend";
4 import { TranscriptFollowClient } from "./transcriptFollowClient";
5 import { getTranscriptStore } from "./transcriptStore";
6 import type { Action, State } from "./useController";
7 import type { HistoryEntry, HistoryMessage, WireEvent } from "./types";
8 import type { TranscriptSnapshot } from "./transcriptProtocol";
9 import type { Message, TranscriptFollowResponse } from "../generated/desktopContract.generated";
10 import { canonicalUserConfirmations } from "./localSubmissionState";
11 import { snapshotRecords } from "./transcriptSnapshotState";
12
13 export class TranscriptSessionFollowerRuntime {
14 private readonly client: TranscriptFollowClient;
15 private readonly orders = new Map<string, number>();
16 private nextOrder = 0;
17 private turn = 0;
18 private generation = 0;
19 private releaseContentRecovery?: () => void;
20 private recoveringContent = false;
21 private coverage = 0;
22 private recoveryScheduled = false;
23 private readonly confirmationReads = new Map<string, { coverage: number; pending: boolean }>();
24 private readonly submissionCoverage = new Map<string, number>();
25 metrics = { entries: 0, inlineBytes: 0 };
26
27 constructor(private readonly tabId: string, private readonly path: string, private readonly remote: boolean,
28 private readonly dispatch: (action: Action) => void,
29 private readonly state: () => State | undefined = () => getTranscriptStore().states.get(tabId)) {
30 this.client = new TranscriptFollowClient(request => {
31 const read = remote ? app.RemoteTranscriptFollowForTab : app.TranscriptFollowForTab;
32 if (!read) return Promise.reject(new Error("Transcript v2 is required. Upgrade Desktop and Serve together."));
33 return read(tabId, request);
34 }, { transport: remote ? "remote" : "local" });
35 }
36
37 async start(): Promise<void> {
38 this.generation++;
39 noteSessionObservation(this.path, { action: "subscribe", tabId: this.tabId, generation: this.generation, sequence: this.coverage });
40 this.recoveryScheduled = false;
41 this.coverage = 0;
42 this.confirmationReads.clear();
43 this.submissionCoverage.clear();
44 this.observeSubmissions();
45 this.releaseContentRecovery?.();
46 this.releaseContentRecovery = registerTranscriptContentRecovery(this.tabId, () => {
47 if (this.recoveringContent) return;
48 this.recoveringContent = true;
49 void this.start().catch(() => undefined).finally(() => { this.recoveringContent = false; });
50 });
51 await this.client.start({
52 install: response => this.install(response),
53 changes: changes => {
54 this.observeSubmissions();
55 for (const change of changes) {
56 if (change.records?.length) {
57 const entries = change.records.map(message => this.entry(message));
58 const projection = getTranscriptStore().upsertEntries(this.tabId, this.path, entries, change.commitSeq);
59 if (!projection) throw new Error("Transcript window is unavailable; synchronize again");
60 this.dispatch({ type: "transcript_records", projection,
61 confirmedUsers: canonicalUserConfirmations(entries.map(entry => ({ ...entry.message, kind: entry.message.role }))) });
62 this.coverage = Math.max(this.coverage, change.commitSeq);
63 }
64 if (change.event) this.dispatch({ type: "event", e: { ...change.event, tabId: this.tabId } as unknown as WireEvent, remote: this.remote });
65 if (change.runtime) this.dispatch({ type: "transcript_runtime", runtime: change.runtime });
66 }
67 this.scheduleConfirmationRecovery();
68 },
69 connection: (status, error) => { noteSessionObservation(this.path, { action: "connection", tabId: this.tabId, generation: this.generation, sequence: this.coverage, status }); this.dispatch({ type: "transcript_connection", status, error }); },
70 });
71 }
72
73 stop(closeSubscription = true, reason?: "service_stopping"): void { noteSessionObservation(this.path, { action: "unsubscribe", tabId: this.tabId, generation: this.generation, sequence: this.coverage }); this.generation++; this.confirmationReads.clear(); this.submissionCoverage.clear(); this.releaseContentRecovery?.(); this.releaseContentRecovery = undefined; this.client.stop(closeSubscription, reason); }
74
75 private observeSubmissions(): void {
76 const pending = new Set(this.state()?.localSubmissionOrder ?? []);
77 for (const id of this.submissionCoverage.keys()) if (!pending.has(id)) this.submissionCoverage.delete(id);
78 for (const id of pending) if (!this.submissionCoverage.has(id)) this.submissionCoverage.set(id, this.coverage);
79 }
80
81 private scheduleConfirmationRecovery(): void {
82 if (this.recoveryScheduled) return;
83 this.recoveryScheduled = true;
84 const generation = this.generation;
85 queueMicrotask(() => {
86 this.recoveryScheduled = false;
87 if (generation !== this.generation) return;
88 const state = this.state();
89 if (!state) return;
90 this.observeSubmissions();
91 const unresolved = new Set(state.localSubmissionOrder);
92 for (const key of this.confirmationReads.keys()) if (!unresolved.has(key)) this.confirmationReads.delete(key);
93 for (const local of Object.values(state.localSubmissions)) {
94 // Identity events alone do not justify a history read. Only recover
95 // after a committed cut could have hidden an earlier formal record.
96 if (!local.messageId || this.coverage <= (this.submissionCoverage.get(local.submissionId) ?? this.coverage)) continue;
97 const previous = this.confirmationReads.get(local.submissionId);
98 if (previous?.pending || previous?.coverage === this.coverage) continue;
99 const read = this.remote ? app.RemoteSessionHistoryWindowForTab : app.SessionHistoryWindowForTab;
100 if (!read) continue;
101 const ticket = { coverage: this.coverage, pending: true };
102 this.confirmationReads.set(local.submissionId, ticket);
103 const current = () => generation === this.generation && this.state()?.sessionGen === state.sessionGen
104 && this.state()?.localSubmissions[local.submissionId]?.messageId === local.messageId;
105 void read(this.tabId, { anchor: "message", messageId: local.messageId, limit: 1 }).then(page => {
106 if (!current() || page.status !== "ready") return;
107 const message = page.messages.find(message => message.messageId === local.messageId && message.role === "user");
108 if (message) this.dispatch({ type: "submission_verified", submissionId: local.submissionId, messageId: local.messageId! });
109 }).catch(() => { /* An inconclusive read retains the echo until the next committed cut. */ }).finally(() => {
110 if (this.confirmationReads.get(local.submissionId) !== ticket) return;
111 ticket.pending = false;
112 if (!current()) this.confirmationReads.delete(local.submissionId);
113 else if (ticket.coverage !== this.coverage) this.scheduleConfirmationRecovery();
114 });
115 }
116 });
117 }
118
119 private entry(message: Message | HistoryMessage, outerRecordId?: string): HistoryEntry {
120 // Canonical history addresses every persisted message as m:<messageId>,
121 // including tool results. A snapshot may instead carry its projection
122 // identity (for example tool:<toolCallId>); that explicit identity is
123 // valid metadata, while messageId remains the merge key used by history.
124 const derived = message.messageId ? `m:${message.messageId}`
125 : message.role === "tool" && message.toolCallId ? `tool:${message.toolCallId}` : undefined;
126 if (outerRecordId && message.recordId && outerRecordId !== message.recordId) {
127 throw new Error("transcript snapshot record identity mismatch");
128 }
129 const entryId = derived ?? message.recordId ?? outerRecordId;
130 if (!entryId) throw new Error("invalid transcript snapshot record identity");
131 const normalized = message.recordId === entryId ? message : { ...message, recordId: entryId };
132 let order = this.orders.get(entryId);
133 if (order === undefined) {
134 order = this.nextOrder++; this.orders.set(entryId, order);
135 if (message.role === "user") this.turn++;
136 // Only the resident tail needs an order index. Older pages carry their
137 // canonical positions and are owned by the bounded transcript store.
138 while (this.orders.size > 192) this.orders.delete(this.orders.keys().next().value!);
139 }
140 return { entryId, order, turn: message.historyTurn || this.turn, message: normalized as unknown as HistoryMessage, refs: [] };
141 }
142
143 private async install(response: TranscriptFollowResponse): Promise<void> {
144 const generation = this.generation;
145 const snapshot = response.snapshot!;
146 // Validate the untrusted bridge cut before resolving refs, merging rows or
147 // changing the resident store. The outer record id is authoritative for
148 // older peers that did not repeat it inside the message.
149 const records = snapshotRecords(snapshot as unknown as TranscriptSnapshot);
150 // A suffix must never be applied to a truncated prefix. Resolve active
151 // snapshot references before publishing any part of this recovery cut.
152 const activeIds = new Set(snapshot.activeAttempts.map(attempt => attempt.messageId));
153 for (const record of records) {
154 if (!record.message.messageId || !activeIds.has(record.message.messageId)) continue;
155 for (const ref of record.refs) {
156 const read = this.remote ? app.RemoteTranscriptContentForTab : app.TranscriptContentForTab;
157 if (!read) throw new Error("Transcript v2 content is unavailable");
158 let offset = 0, text = "";
159 while (true) {
160 const chunk = await read(this.tabId, { ...ref, offset });
161 if (generation !== this.generation) return;
162 if (chunk.stale) throw new Error("Active transcript snapshot expired; synchronize again");
163 text += chunk.data;
164 if (chunk.done) break;
165 if (chunk.nextOffset <= offset) throw new Error("Active transcript content did not advance");
166 offset = chunk.nextOffset;
167 }
168 let target = record.message as unknown as Record<string, unknown>;
169 for (const key of ref.path.slice(0, -1)) target = target[key] as Record<string, unknown>;
170 target[ref.path[ref.path.length - 1]] = text;
171 }
172 record.refs = [];
173 }
174 if (generation !== this.generation) return;
175 this.observeSubmissions();
176 const page = response.history;
177 this.turn = page?.totalTurns ?? snapshot.totalTurns;
178 this.orders.clear();
179 const entries = entriesFor(page?.messages ?? [], page?.snapshotSequence ?? snapshot.coveredThroughSeq);
180 for (const entry of entries) this.orders.set(entry.entryId, entry.order);
181 this.nextOrder = Math.max(0, ...entries.map(entry => entry.order + 1));
182 const merged = new Map(entries.map(entry => [entry.entryId, entry]));
183 for (const record of records) {
184 const entry = this.entry(record.message, record.id);
185 // Durable canonical refs remain loadable after a view snapshot expires.
186 const canonical = merged.get(entry.entryId);
187 if (canonical && !snapshot.activeAttempts.some(attempt => attempt.messageId === record.message.messageId)) continue;
188 entry.refs = record.refs.map(ref => ({ entryId: entry.entryId, field: ref.path[0], size: ref.bytes, chunks: 1,
189 revision: snapshot.coveredThroughSeq, revKnown: true, digest: snapshot.snapshotId, transcriptRef: ref }));
190 merged.set(entry.entryId, entry);
191 }
192 const all = [...merged.values()].sort((a, b) => a.order - b.order);
193 this.metrics = { entries: all.length, inlineBytes: all.reduce((bytes, entry) => bytes + entry.message.content.length + (entry.message.reasoning?.length ?? 0), 0) };
194 const prepared = getTranscriptStore().prepareInstallSlice(this.tabId, this.path, {
195 entries: all, nextCursor: page?.olderCursor ?? "", newerCursor: page?.newerCursor ?? "",
196 hasOlder: Boolean(page?.hasOlder), hasNewer: false, totalTurns: this.turn,
197 startTurn: Math.min(this.turn, ...all.map(entry => entry.turn)), endTurn: this.turn,
198 revision: page?.snapshotSequence ?? snapshot.coveredThroughSeq, revisionKnown: true, digest: page?.generation ?? "", stale: false,
199 });
200 const combined: TranscriptSnapshot = {
201 ...snapshot as unknown as TranscriptSnapshot,
202 records: all.map((entry, order) => ({ id: entry.entryId, order, message: entry.message, refs: [] })),
203 activeRecords: [], totalRecords: all.length, totalTurns: this.turn,
204 };
205 this.dispatch({ type: "transcript_v2_snapshot", snapshot: combined, projection: prepared.projection, remote: this.remote });
206 prepared.commit();
207 this.dispatch({ type: "transcript_runtime", runtime: snapshot.runtime });
208 this.coverage = snapshot.coveredThroughSeq;
209 noteSessionObservation(this.path, { action: "snapshot_installed", tabId: this.tabId, generation: this.generation, sequence: this.coverage, status: snapshot.runtime.status });
210 this.scheduleConfirmationRecovery();
211 }
212 }
213
213 lines TYPESCRIPT