dsh-vscode-mode 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/model.ts CHANGED
@@ -277,11 +277,15 @@ export function fileMaxBatch(records: Map<string, DiffRecord>, path: string): nu
277
277
  return m
278
278
  }
279
279
 
280
- /** 记录桶裁剪:超出 MAX_RECORDS 时剔除最旧的。 */
280
+ /** @public @author ddj 2026年10月26号 @param map Legacy records. @param max Cache target, never a pending retention limit. */
281
281
  export function prune(map: Map<string, DiffRecord>, max = MAX_RECORDS): void {
282
282
  if (map.size <= max) return
283
- const sorted = [...map.values()].sort((a, b) => (a.at < b.at ? -1 : 1))
284
- for (let i = 0; i < sorted.length - max; i++) map.delete(sorted[i].callId)
283
+ const sorted = [...map.values()].filter((record) => record.archived && recordResolved(record))
284
+ .sort((a, b) => (a.at < b.at ? -1 : 1))
285
+ for (const record of sorted) {
286
+ if (map.size <= max) break
287
+ map.delete(record.callId)
288
+ }
285
289
  }
286
290
 
287
291
  /** 按批次分组记录。 */
@@ -0,0 +1,199 @@
1
+ /** Deferred official-event collection; no private snapshot access. @author ddj 2026年10月26号 */
2
+ import { captureState, repairPaths, saveSnapshot } from './reviewDb.js'
3
+ import type { OfficialDiff, OfficialSummary } from './shared/review.js'
4
+ import type { Ctx } from './store.js'
5
+ import { loadBucket } from './store.js'
6
+ import { log } from './log.js'
7
+
8
+ interface SourceEvent { type: string; seq: number; data: { turn?: number } }
9
+ interface SourceSession { id: string; inheritedEventCount?: number; header?: { cwd?: string } }
10
+ interface SourceService {
11
+ summary(sessionId: string, seq: number): OfficialSummary | undefined
12
+ diff(sessionId: string, seq: number, index: number, signal: AbortSignal): Promise<OfficialDiff | undefined>
13
+ }
14
+ interface CaptureTask { sessionId: string; turn: number; seq: number; cwd?: string }
15
+ export interface CaptureDeps {
16
+ source: () => SourceService | undefined
17
+ prepare(cwd: string): Promise<void>
18
+ identity(cwd: string, path: string): Promise<string>
19
+ persist: typeof saveSnapshot
20
+ state: typeof captureState
21
+ report(message: string): void
22
+ timeoutMs?: number
23
+ retryMs?: number
24
+ }
25
+
26
+ /** @public @author ddj 2026年10月26号 @param deps Official source and workspace adapters. @returns Bounded collector with cancellation and deferred event reads. */
27
+ export function createCollector(deps: CaptureDeps) {
28
+ const waiting: CaptureTask[] = []
29
+ const pending = new Set<string>()
30
+ const controller = new AbortController()
31
+ let active = 0
32
+ let stopped = false
33
+ const running = new Set<string>()
34
+ const tasks = new Set<Promise<void>>()
35
+
36
+ /** @private @author ddj 2026年10月26号 @param ms Delay; aborted on unload. @returns Deferred completion, not synchronous observer work. */
37
+ function pause(ms: number): Promise<void> {
38
+ return new Promise((resolve) => {
39
+ /** @private @author ddj 2026年10月26号 Release both timer and listener. */
40
+ function finish(): void { clearTimeout(timer); controller.signal.removeEventListener('abort', finish); resolve() }
41
+ const timer = setTimeout(finish, ms)
42
+ controller.signal.addEventListener('abort', finish, { once: true })
43
+ if (stopped) finish()
44
+ })
45
+ }
46
+
47
+ /** @private @author ddj 2026年10月26号 @param task Source identity. @returns Complete payload staged before one database transaction. */
48
+ async function collect(task: CaptureTask): Promise<void> {
49
+ await pause(0) // Official append publishes before summary registration.
50
+ if (stopped) return
51
+ // The session header may predate cd. Bind the durable inbox only after
52
+ // reading the official event cwd; header cwd is a gap-report fallback.
53
+ const source = deps.source()
54
+ if (!source) throw new Error('官方 workspaceChanges 服务不可用')
55
+ let summary: OfficialSummary | undefined
56
+ for (let attempt = 0; attempt < 4 && !stopped; attempt++) {
57
+ summary = source.summary(task.sessionId, task.seq)
58
+ if (summary) break
59
+ await pause(deps.retryMs ?? 100)
60
+ }
61
+ if (!summary || stopped) throw new Error('官方事件已公告,但 summary 已失效或尚不可用')
62
+ // Detach all source aliases before asynchronous work; never resolve against current session cwd.
63
+ summary = structuredClone(summary)
64
+ if (summary.turn !== task.turn) throw new Error('官方 summary 轮次与事件不一致')
65
+ task.cwd = summary.cwd
66
+ await deps.prepare(summary.cwd)
67
+ if (stopped) return
68
+ deps.state(summary.cwd, { ...task, error: '' }, 'queued')
69
+ try {
70
+ const files = []
71
+ for (const [index, file] of summary.files.entries()) {
72
+ if (stopped) return
73
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(deps.timeoutMs ?? 10000)])
74
+ const diff = await readDiff(source, task, index, signal)
75
+ if (!diff || diff.path !== file.path || diff.display !== file.display) throw new Error('官方文件差异缺失或身份不一致')
76
+ const identity = await deps.identity(summary.cwd, file.path)
77
+ files.push({ identity, diff: structuredClone(diff) })
78
+ }
79
+ if (!stopped) deps.persist(summary.cwd, { ...task, sourceKind: 'official', capturedAt: new Date().toISOString(), summary, files })
80
+ } catch (error) {
81
+ if (!stopped) deps.state(summary.cwd, { ...task, error: String(error) }, 'gap')
82
+ throw error
83
+ }
84
+ }
85
+
86
+ /** @private @author ddj 2026年10月26号 Starts at most two independent event reads, with each event's file reads serial. */
87
+ function drain(): void {
88
+ while (!stopped && active < 2 && waiting.length) {
89
+ const index = waiting.findIndex((task) => !running.has(JSON.stringify([task.sessionId, task.turn])))
90
+ if (index < 0) break
91
+ const task = waiting.splice(index, 1)[0]
92
+ const key = JSON.stringify([task.sessionId, task.turn, task.seq])
93
+ const turnKey = JSON.stringify([task.sessionId, task.turn])
94
+ active++; running.add(turnKey)
95
+ const work = collect(task).catch(async (error) => {
96
+ deps.report(`官方差异采集 ${key}: ${String(error)}`)
97
+ if (!stopped && task.cwd) {
98
+ try { await deps.prepare(task.cwd); if (!stopped) deps.state(task.cwd, { ...task, error: String(error) }, 'gap') }
99
+ catch (failure) { deps.report(`官方缺口落盘失败: ${String(failure)}`) }
100
+ }
101
+ }).finally(() => {
102
+ active--; pending.delete(key); running.delete(turnKey); tasks.delete(work); drain()
103
+ })
104
+ tasks.add(work)
105
+ }
106
+ }
107
+
108
+ /** @public @author ddj 2026年10月26号 @param session Source session. @param event Published event; inherited fork events are excluded. */
109
+ function enqueue(session: SourceSession, event: SourceEvent): void {
110
+ if (stopped || event.type !== 'workspace/changes' || !Number.isSafeInteger(event.data?.turn)
111
+ || !Number.isSafeInteger(event.seq) || event.seq < (session.inheritedEventCount ?? 0)) return
112
+ const task = { sessionId: session.id, turn: event.data.turn!, seq: event.seq, cwd: session.header?.cwd }
113
+ const key = JSON.stringify([task.sessionId, task.turn, task.seq])
114
+ if (pending.has(key)) return
115
+ pending.add(key); waiting.push(task); drain()
116
+ }
117
+
118
+ /** @public @author ddj 2026年10月26号 @returns Bounded durability checkpoint including work queued while active tasks finish. */
119
+ async function flush(): Promise<void> {
120
+ const deadline = Date.now() + (deps.timeoutMs ?? 10000)
121
+ while (tasks.size && !stopped && Date.now() < deadline) {
122
+ let timer: ReturnType<typeof setTimeout> | undefined
123
+ await Promise.race([Promise.all([...tasks]), new Promise<void>((resolve) => { timer = setTimeout(resolve, Math.max(1, deadline - Date.now())) })])
124
+ clearTimeout(timer)
125
+ }
126
+ }
127
+
128
+ /** @public @author ddj 2026年10月26号 @returns Bounded drain, then cancel timers and reads; incomplete staging cannot publish. */
129
+ async function dispose(): Promise<void> {
130
+ await flush()
131
+ stopped = true; controller.abort(); waiting.length = 0; pending.clear()
132
+ // Never await an uncooperative external adapter after the deadline.
133
+ // Every publish path checks stopped; late completion cannot reopen the database.
134
+ }
135
+ return { enqueue, flush, dispose }
136
+ }
137
+
138
+ /** @private @author ddj 2026年10月26号 @param source Public service. @param task Event. @param index File position. @param signal Cancellation/deadline. @returns A read bounded even if an adapter ignores abort. */
139
+ async function readDiff(source: SourceService, task: CaptureTask, index: number, signal: AbortSignal): Promise<OfficialDiff | undefined> {
140
+ return new Promise((resolve, reject) => {
141
+ /** @private @author ddj 2026年10月26号 Propagate timeout or unload. */
142
+ function aborted(): void { reject(signal.reason) }
143
+ signal.addEventListener('abort', aborted, { once: true })
144
+ if (signal.aborted) { aborted(); return }
145
+ Promise.resolve().then(() => source.diff(task.sessionId, task.seq, index, signal))
146
+ .then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted))
147
+ })
148
+ }
149
+
150
+ /** @public @author ddj 2026年10月26号 @param ctx Host lifecycle owner; services remain optional for older Hosts. */
151
+ export function installCapture(ctx: Ctx): () => Promise<void> {
152
+ const collector = createCollector({
153
+ source: () => ctx.get('workspaceChanges'),
154
+ prepare: async (cwd) => { await loadBucket(ctx, cwd); await repairPaths(cwd, resolvePath) },
155
+ identity: resolvePath,
156
+ persist: saveSnapshot, state: captureState, report: (message) => log.warn(message),
157
+ })
158
+ // Subscribe first: seed reads must not create a live-event race window.
159
+ ctx.on('session/event', collector.enqueue)
160
+ ctx.on('session/created', backfill)
161
+ ctx.on('session/flush', collector.flush)
162
+ for (const session of ctx.get('sessions').list()) void backfill(session)
163
+ return collector.dispose
164
+
165
+ /** @private @author ddj 2026年10月27号 @param cwd Source workspace. @param path Official relative path. @returns Backend-owned process path; never stringify opaque targets. @throws Invalid backend path. */
166
+ async function resolvePath(cwd: string, path: string): Promise<string> {
167
+ const fs = ctx.get('fs')
168
+ const target = await fs.resolve(path, { cwd })
169
+ const processPath = fs.processPath(target)
170
+ if (typeof processPath !== 'string' || !processPath || processPath === '[object Object]') {
171
+ throw new Error('官方文件路径解析失败')
172
+ }
173
+ return processPath
174
+ }
175
+
176
+ /** @private @author ddj 2026年10月26号 @param session Live session. Only public async reads, excluding inherited records. */
177
+ async function backfill(session: SourceSession): Promise<void> {
178
+ const query = ctx.get('sessionQuery')
179
+ if (!query?.listEvents || !query?.readEvent) {
180
+ log.warn('官方历史补采不可用:缺少 sessionQuery;后续实时事件仍会采集')
181
+ return
182
+ }
183
+ try {
184
+ // Repair durable history independently: official summaries do not survive Host restart.
185
+ // Each saved snapshot resolves against its own cwd, not the current session directory.
186
+ if (session.header?.cwd) {
187
+ await loadBucket(ctx, session.header.cwd)
188
+ await repairPaths(session.header.cwd, resolvePath)
189
+ }
190
+ // Semantic search omits structural events; only the raw metadata list contains workspace/changes.
191
+ const events = await query.listEvents(session.id)
192
+ for (const event of events) {
193
+ if (event.type !== 'workspace/changes' || event.seq < (session.inheritedEventCount ?? 0)) continue
194
+ const window = await query.readEvent({ sessionId: session.id, seq: event.seq })
195
+ collector.enqueue(session, window.target)
196
+ }
197
+ } catch (error) { log.warn('官方历史补采失败:' + String(error)) }
198
+ }
199
+ }
@@ -0,0 +1,214 @@
1
+ /** Official history shares the archive connection, but never the legacy callId namespace. @author ddj 2026年10月26号 */
2
+ import { createHash } from 'node:crypto'
3
+ import type { DatabaseSync } from 'node:sqlite'
4
+ import { activeReady, dbFor } from './archiveDb.js'
5
+ import type { CaptureGap, DecideReview, OfficialDiff, ReviewDetail, ReviewItem, ReviewPage, ReviewSnapshot } from './shared/review.js'
6
+
7
+ type Row = Record<string, string | number | null>
8
+
9
+ /** @public @author ddj 2026年10月26号 @param cwd Migrated workspace. @returns Schema4 connection; DDL and version change roll back together. */
10
+ export function reviewDb(cwd: string): DatabaseSync {
11
+ if (!activeReady(cwd)) throw new Error('官方审查要求先完成活跃库迁移')
12
+ const db = dbFor(cwd)
13
+ const version = db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row
14
+ if (version.value === '4') return db
15
+ db.exec('BEGIN IMMEDIATE')
16
+ try {
17
+ db.exec(`CREATE TABLE review_snapshots (
18
+ session_id TEXT NOT NULL, turn INTEGER NOT NULL, seq INTEGER NOT NULL,
19
+ payload TEXT NOT NULL, digest TEXT NOT NULL, current INTEGER NOT NULL DEFAULT 1,
20
+ PRIMARY KEY(session_id,turn,seq));
21
+ CREATE TABLE review_files (
22
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, turn INTEGER NOT NULL, seq INTEGER NOT NULL,
23
+ identity TEXT NOT NULL, summary TEXT NOT NULL, diff TEXT NOT NULL,
24
+ decision TEXT NOT NULL DEFAULT 'pending', revision INTEGER NOT NULL DEFAULT 0,
25
+ current INTEGER NOT NULL DEFAULT 1);
26
+ CREATE INDEX review_pending ON review_files(current,decision,id);
27
+ CREATE INDEX review_turn ON review_files(session_id,turn,seq);
28
+ CREATE TABLE review_capture (
29
+ session_id TEXT NOT NULL, turn INTEGER NOT NULL, seq INTEGER NOT NULL,
30
+ state TEXT NOT NULL, error TEXT, PRIMARY KEY(session_id,turn,seq));
31
+ CREATE TABLE review_ops (
32
+ operation_id TEXT PRIMARY KEY, request TEXT NOT NULL, state TEXT NOT NULL, result TEXT,
33
+ evidence TEXT);
34
+ UPDATE metadata SET value='4' WHERE name='schema';`)
35
+ db.exec('COMMIT')
36
+ } catch (error) {
37
+ if (db.isTransaction) db.exec('ROLLBACK')
38
+ throw error
39
+ }
40
+ return db
41
+ }
42
+
43
+ /** @private @author ddj 2026年10月26号 @param text Canonical serialized input. @returns Collision-resistant identity. */
44
+ function digest(text: string): string { return createHash('sha256').update(text).digest('hex') }
45
+
46
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param snapshot Complete staged event. @returns False for late versions; commits facts, pointers and capture state atomically. */
47
+ export function saveSnapshot(cwd: string, snapshot: ReviewSnapshot): boolean {
48
+ const db = reviewDb(cwd)
49
+ const { sessionId, turn, seq } = snapshot
50
+ if (snapshot.summary.cwd !== cwd || snapshot.summary.turn !== turn || snapshot.files.length !== snapshot.summary.files.length) throw new Error('官方采集不完整')
51
+ const identities = new Set(snapshot.files.map((file) => file.identity))
52
+ if (identities.size !== snapshot.files.length) throw new Error('官方文件身份重复')
53
+ // capturedAt is acquisition metadata, not part of the immutable source identity.
54
+ const hash = digest(JSON.stringify({ ...snapshot, capturedAt: '' }))
55
+ db.exec('BEGIN IMMEDIATE')
56
+ try {
57
+ const prior = db.prepare('SELECT digest FROM review_snapshots WHERE session_id=? AND turn=? AND seq=?').get(sessionId, turn, seq) as Row | undefined
58
+ if (prior && prior.digest !== hash) throw new Error('同一官方事件的内容发生变化')
59
+ const latest = db.prepare('SELECT MAX(seq) AS seq FROM review_snapshots WHERE session_id=? AND turn=?').get(sessionId, turn) as Row
60
+ if (prior || (typeof latest.seq === 'number' && latest.seq > seq)) { db.exec('COMMIT'); return false }
61
+ insertSnapshot(db, snapshot, hash)
62
+ db.prepare("INSERT INTO review_capture VALUES(?,?,?,'complete',NULL) ON CONFLICT(session_id,turn,seq) DO UPDATE SET state='complete',error=NULL").run(sessionId, turn, seq)
63
+ db.exec('COMMIT')
64
+ return true
65
+ } catch (error) {
66
+ if (db.isTransaction) db.exec('ROLLBACK')
67
+ throw error
68
+ }
69
+ }
70
+
71
+ /** @public @author ddj 2026年10月27号 @param cwd Workspace. @param resolvePath Backend path adapter. @returns Repairs only historical object-string identities; preserves IDs, decisions and immutable official facts. @throws Resolution or concurrent payload changes. */
72
+ export async function repairPaths(cwd: string, resolvePath: (cwd: string, path: string) => Promise<string>): Promise<void> {
73
+ const db = reviewDb(cwd)
74
+ const broken = db.prepare(`SELECT DISTINCT s.* FROM review_snapshots s JOIN review_files f
75
+ USING(session_id,turn,seq) WHERE f.identity='[object Object]'`).all() as Row[]
76
+ for (const row of broken) {
77
+ const snapshot = JSON.parse(String(row.payload)) as ReviewSnapshot
78
+ for (const file of snapshot.files) {
79
+ if (file.identity !== '[object Object]') continue
80
+ file.identity = await resolvePath(snapshot.summary.cwd, file.diff.path)
81
+ if (!file.identity || file.identity === '[object Object]') throw new Error('官方文件路径修复失败')
82
+ }
83
+ if (new Set(snapshot.files.map((file) => file.identity)).size !== snapshot.files.length) throw new Error('官方文件身份重复')
84
+
85
+ // All IO is staged before locking; compare the persisted payload to avoid overwriting newer repairs.
86
+ db.exec('BEGIN IMMEDIATE')
87
+ try {
88
+ const current = db.prepare('SELECT payload FROM review_snapshots WHERE session_id=? AND turn=? AND seq=?')
89
+ .get(snapshot.sessionId, snapshot.turn, snapshot.seq) as Row | undefined
90
+ if (current?.payload === row.payload) {
91
+ for (const file of snapshot.files) {
92
+ db.prepare(`UPDATE review_files SET identity=? WHERE session_id=? AND turn=? AND seq=?
93
+ AND identity='[object Object]' AND json_extract(diff,'$.path')=?`)
94
+ .run(file.identity, snapshot.sessionId, snapshot.turn, snapshot.seq, file.diff.path)
95
+ }
96
+ db.prepare('UPDATE review_snapshots SET payload=?,digest=? WHERE session_id=? AND turn=? AND seq=?')
97
+ .run(JSON.stringify(snapshot), digest(JSON.stringify({ ...snapshot, capturedAt: '' })), snapshot.sessionId, snapshot.turn, snapshot.seq)
98
+ }
99
+ db.exec('COMMIT')
100
+ } catch (error) {
101
+ if (db.isTransaction) db.exec('ROLLBACK')
102
+ throw error
103
+ }
104
+ }
105
+ }
106
+
107
+ /** @private @author ddj 2026年10月26号 @param db Transaction. @param snapshot Source payload. @param hash Source digest. */
108
+ function insertSnapshot(db: DatabaseSync, snapshot: ReviewSnapshot, hash: string): void {
109
+ const { sessionId, turn, seq } = snapshot
110
+ const previous = db.prepare('SELECT identity,diff,decision FROM review_files WHERE session_id=? AND turn=? AND current=1').all(sessionId, turn) as Row[]
111
+ db.prepare('UPDATE review_snapshots SET current=0 WHERE session_id=? AND turn=?').run(sessionId, turn)
112
+ db.prepare('UPDATE review_files SET current=0 WHERE session_id=? AND turn=?').run(sessionId, turn)
113
+ db.prepare('INSERT INTO review_snapshots VALUES(?,?,?,?,?,1)').run(sessionId, turn, seq, JSON.stringify(snapshot), hash)
114
+ const insert = db.prepare('INSERT INTO review_files VALUES(?,?,?,?,?,?,?,?,0,1)')
115
+ for (const [index, file] of snapshot.files.entries()) {
116
+ const encoded = JSON.stringify(file.diff)
117
+ const unchanged = previous.find((item) => item.identity === file.identity && item.diff === encoded)
118
+ const id = digest(JSON.stringify([sessionId, turn, seq, file.identity]))
119
+ insert.run(id, sessionId, turn, seq, file.identity, JSON.stringify(snapshot.summary.files[index]), encoded, unchanged?.decision ?? 'pending')
120
+ }
121
+ }
122
+
123
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param gap Announced event identity. @param state Durable inbox or explicit failure. */
124
+ export function captureState(cwd: string, gap: CaptureGap, state: 'queued' | 'gap'): void {
125
+ reviewDb(cwd).prepare(`INSERT INTO review_capture VALUES(?,?,?,?,?) ON CONFLICT(session_id,turn,seq)
126
+ DO UPDATE SET state=excluded.state,error=excluded.error WHERE review_capture.state<>'complete'`).run(gap.sessionId, gap.turn, gap.seq, state, gap.error)
127
+ }
128
+
129
+ /** @private @author ddj 2026年10月26号 @param row File and state. @returns Wire-safe summary without hunks. */
130
+ function itemOf(row: Row): ReviewItem {
131
+ const summary = JSON.parse(String(row.summary))
132
+ const kind = row.kind ?? (JSON.parse(String(row.diff)) as OfficialDiff).kind
133
+ return { id: String(row.id), sessionId: String(row.session_id), turn: Number(row.turn), seq: Number(row.seq),
134
+ ...summary, currentPath: String(row.identity), kind: kind as ReviewItem['kind'], decision: row.decision as ReviewItem['decision'], revision: Number(row.revision), current: row.current === 1 }
135
+ }
136
+
137
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param cursor Page offset. @param history Include withdrawn/decided history. @returns Bounded metadata page; pending across all source sessions/turns. */
138
+ export function reviewPage(cwd: string, cursor = 0, history = false, path?: string): ReviewPage {
139
+ const db = reviewDb(cwd)
140
+ const offset = Number.isSafeInteger(cursor) && cursor >= 0 ? cursor : 0
141
+ const conditions = history ? [] : ["current=1 AND decision='pending'"]
142
+ if (path !== undefined) conditions.push('identity=?')
143
+ const clause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
144
+ const parameters = path === undefined ? [offset] : [path, offset]
145
+ const rows = db.prepare(`SELECT id,session_id,turn,seq,identity,summary,decision,revision,current,
146
+ json_extract(diff,'$.kind') AS kind FROM review_files ${clause} ORDER BY rowid DESC LIMIT 51 OFFSET ?`).all(...parameters) as Row[]
147
+ const count = db.prepare("SELECT COUNT(*) AS n FROM review_files WHERE current=1 AND decision='pending'").get() as Row
148
+ const gaps = db.prepare("SELECT * FROM review_capture WHERE state<>'complete' ORDER BY seq DESC LIMIT 50").all() as Row[]
149
+ const capped = db.prepare(`SELECT session_id,turn,seq,json_extract(payload,'$.summary.total') AS total,
150
+ json_array_length(payload,'$.summary.files') AS listed FROM review_snapshots WHERE current=1
151
+ AND json_extract(payload,'$.summary.total')>json_array_length(payload,'$.summary.files') LIMIT 50`).all() as Row[]
152
+ // Navigation is metadata-only and independent of the 50-row patch-history page.
153
+ const files = db.prepare(`SELECT identity,MIN(json_extract(summary,'$.display')) AS label,COUNT(*) AS n
154
+ FROM review_files WHERE current=1 AND decision='pending' GROUP BY identity ORDER BY identity`).all() as Row[]
155
+ const covered = db.prepare('SELECT DISTINCT identity FROM review_files').all() as Row[]
156
+ return { overview: { files: files.map((row) => ({ sourceKind: 'official', path: String(row.identity), label: String(row.label), pending: Number(row.n) })),
157
+ coveredPaths: covered.map((row) => String(row.identity)) },
158
+ items: rows.slice(0, 50).map(itemOf), pending: Number(count.n), nextCursor: rows.length > 50 ? offset + 50 : null,
159
+ gaps: [
160
+ ...gaps.map((row) => ({ sessionId: String(row.session_id), turn: Number(row.turn), seq: Number(row.seq), error: String(row.error || '采集中或上次采集被中断') })),
161
+ ...capped.map((row) => ({ sessionId: String(row.session_id), turn: Number(row.turn), seq: Number(row.seq), error: `官方列表已截断:总计 ${row.total} 个文件,仅提供 ${row.listed} 个` })),
162
+ ] }
163
+
164
+ }
165
+
166
+ /** @public @author ddj 2026年10月26号 @param cwd Migrated workspace. @param path Legacy write target. @returns Whether any official version owns the file, including decided history. */
167
+ export function hasOfficial(cwd: string, path: string): boolean {
168
+ const db = dbFor(cwd)
169
+ // A read guard must not acquire a migration write lock on legacy-only databases.
170
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name='review_files' AND type='table'").get()) return false
171
+ if (/^[A-Za-z]:[\\/]/.test(path)) {
172
+ return !!db.prepare("SELECT 1 FROM review_files WHERE lower(replace(identity,char(92),'/'))=? LIMIT 1")
173
+ .get(path.replace(/\\/g, '/').toLowerCase())
174
+ }
175
+ return !!db.prepare('SELECT 1 FROM review_files WHERE identity=? LIMIT 1').get(path)
176
+ }
177
+
178
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param id Immutable file identity. @returns Historical patch, not a reconstruction from disk. */
179
+ export function reviewDetail(cwd: string, id: string): ReviewDetail | null {
180
+ const db = reviewDb(cwd)
181
+ const row = db.prepare(`SELECT f.*,json_extract(s.payload,'$.capturedAt') AS captured_at FROM review_files f JOIN review_snapshots s
182
+ USING(session_id,turn,seq) WHERE f.id=?`).get(id) as Row | undefined
183
+ if (!row) return null
184
+ return { item: itemOf(row), diff: JSON.parse(String(row.diff)), capturedAt: String(row.captured_at),
185
+ capability: { keep: row.current === 1 && row.decision === 'pending', undo: false,
186
+ reason: '官方仅提供差异块,缺少可验证的历史全文及原始字节;拒绝写回已禁用。可标记已审或打开当前文件继续编辑。' } }
187
+ }
188
+
189
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param request Frozen identity/revision. @returns Idempotent decision; rejection never guesses a target. */
190
+ export function decideReview(cwd: string, request: DecideReview): { revision: number } {
191
+ const db = reviewDb(cwd)
192
+ const encoded = JSON.stringify(request)
193
+ if (!request.operationId || request.operationId.length > 128) throw new Error('operationId 无效')
194
+ db.exec('BEGIN IMMEDIATE')
195
+ try {
196
+ const previous = db.prepare('SELECT request,result FROM review_ops WHERE operation_id=?').get(request.operationId) as Row | undefined
197
+ if (previous) {
198
+ if (previous.request !== encoded) throw new Error('operationId 已用于其他请求')
199
+ db.exec('COMMIT')
200
+ return JSON.parse(String(previous.result))
201
+ }
202
+ if (request.decision !== 'accepted') throw new Error('缺少可信全文证据,禁止拒绝写回')
203
+ const change = db.prepare(`UPDATE review_files SET decision='accepted',revision=revision+1
204
+ WHERE id=? AND seq=? AND revision=? AND current=1 AND decision='pending'`).run(request.id, request.seq, request.revision)
205
+ if (change.changes !== 1) throw new Error('审查版本冲突,请刷新后重试')
206
+ const result = { revision: request.revision + 1 }
207
+ db.prepare("INSERT INTO review_ops VALUES(?,?,'committed',?,NULL)").run(request.operationId, encoded, JSON.stringify(result))
208
+ db.exec('COMMIT')
209
+ return result
210
+ } catch (error) {
211
+ if (db.isTransaction) db.exec('ROLLBACK')
212
+ throw error
213
+ }
214
+ }
@@ -0,0 +1,33 @@
1
+ /** Session-scoped official review RPC. @author ddj 2026年10月26号 */
2
+ import type { RpcHandlerMap } from './shared/rpc.js'
3
+ import { decideReview, reviewDetail, reviewPage } from './reviewDb.js'
4
+ import { bucketOf, cwdOfSession, sessionOf } from './registry.js'
5
+ import type { Registry } from './registry.js'
6
+ import type { Ctx } from './store.js'
7
+
8
+ /** @public @author ddj 2026年10月26号 @param ctx Host. @param registry Workspace cache. @returns Handlers resolving workspace from the live authenticated session, never caller cwd. */
9
+ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandlerMap> {
10
+ /** @private @author ddj 2026年10月26号 @param sessionId Request scope. @returns Migrated workspace, or explicit failure without fallback. */
11
+ async function workspace(sessionId: string): Promise<string> {
12
+ const session = sessionOf(ctx, sessionId)
13
+ const cwd = cwdOfSession(ctx, session)
14
+ if (!session || !cwd) throw new Error('会话不存在或没有工作区')
15
+ await bucketOf(registry, ctx, cwd)
16
+ return cwd
17
+ }
18
+ return {
19
+ 'edrv.review.list': async (args) => {
20
+ const cwd = await workspace(args.sessionId)
21
+ return { ok: true, page: reviewPage(cwd, args.cursor, args.history, args.path) }
22
+ },
23
+ 'edrv.review.detail': async (args) => {
24
+ const cwd = await workspace(args.sessionId)
25
+ const detail = reviewDetail(cwd, args.id)
26
+ return detail ? { ok: true, detail } : { ok: false, error: '官方审查记录不存在' }
27
+ },
28
+ 'edrv.review.decide': async (args) => {
29
+ const cwd = await workspace(args.sessionId)
30
+ return { ok: true, ...decideReview(cwd, args) }
31
+ },
32
+ }
33
+ }
package/src/rpc.ts CHANGED
@@ -23,6 +23,7 @@ import { cp, mkdir, readFile, rename, rm, stat, writeFile, readdir } from 'node:
23
23
  import { basename, dirname, join } from 'node:path'
24
24
  import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } from './model.js'
25
25
  import { clearRevertState, listArchives, pageArchives, readArchives, revertState, setRevertState, startReverts } from './archiveDb.js'
26
+ import { hasOfficial } from './reviewDb.js'
26
27
  import { log } from './log.js'
27
28
  import type { Registry } from './registry.js'
28
29
  import { bucketOf, cwdOfSession, sessionOf } from './registry.js'
@@ -57,6 +58,7 @@ import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfC
57
58
  import type { FileVersions } from './fileVersions.js'
58
59
  import { settingsDocPath } from './settingsDocPath.js'
59
60
  import { createTextPages } from './textPages.js'
61
+ import { reviewHandlers } from './reviewRpc.js'
60
62
 
61
63
  /** Process-local cursor signer; reads must stay on the Host filesystem capability. */
62
64
  const readTextPage = createTextPages()
@@ -239,17 +241,8 @@ async function afterManualSave(
239
241
  ): Promise<void> {
240
242
  // 手动保存后目录树可能变化(新建/删除文件):父目录+祖先进失效,后台自愈。
241
243
  invalidateIndex(ctx, sc.cwd, path)
242
- const bucket = await bucketOf(registry, ctx, sc.cwd)
243
- let changed = false
244
- for (const rec of bucket.values()) {
245
- if (rec.path === path && rec.superseded !== true) { rec.superseded = true; rec.at = new Date().toISOString(); changed = true }
246
- }
247
- if (changed) {
248
- const done: DiffRecord[] = []
249
- for (const rec of bucket.values()) if (rec.path === path && rec.superseded) done.push(rec)
250
- if (done.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, done, '被手动编辑覆盖')
251
- else await saveBucket(ctx, sc.cwd, bucket, sc.session, { upserts: [...bucket.values()].filter((rec) => rec.path === path), removed: [] })
252
- }
244
+ // Editing disk is not a review decision. Keep all historical pending records;
245
+ // location checks may report conflict but must not silently accept or archive them.
253
246
  }
254
247
 
255
248
  /**
@@ -288,6 +281,10 @@ async function applyDecisions(
288
281
  results.push({ callId: item.callId, ok: false, error: '记录不存在' })
289
282
  continue
290
283
  }
284
+ if (hasOfficial(cwd, record.path)) {
285
+ results.push({ callId: item.callId, ok: false, error: '该文件已有官方审查记录;旧工具记录禁止决策或回滚,请使用官方审查入口' })
286
+ continue
287
+ }
291
288
  if (record.archived) {
292
289
  results.push({ callId: item.callId, ok: false, error: '记录已归档,无法重复决策' })
293
290
  continue
@@ -364,8 +361,8 @@ async function applyDecisions(
364
361
  // #endregion
365
362
  grouped.delete(index)
366
363
  try {
367
- if (outcome.ok || outcome.stale === true) setRevertState(cwd, record.callId, scope, hunkIndex, 'applied')
368
- else if (outcome.error === '找不到该差异块' || outcome.error === '回滚失败:文件不存在'
364
+ if (outcome.ok) setRevertState(cwd, record.callId, scope, hunkIndex, 'applied')
365
+ else if (outcome.stale === true || outcome.error === '找不到该差异块' || outcome.error === '回滚失败:文件不存在'
369
366
  || outcome.error === '回滚失败:文件过大,无法安全定位') {
370
367
  clearRevertState(cwd, record.callId, scope, hunkIndex)
371
368
  }
@@ -375,11 +372,8 @@ async function applyDecisions(
375
372
  continue
376
373
  }
377
374
  if (!outcome.ok) {
378
- if (scope !== 'hunk' || outcome.stale !== true) {
379
- results.push({ callId: item.callId, ok: false, error: outcome.error })
380
- continue
381
- }
382
- revertedStale = true
375
+ results.push({ callId: item.callId, ok: false, error: outcome.error || '差异已偏移,未修改文件,保留待处理' })
376
+ continue
383
377
  }
384
378
  }
385
379
  const wasResolved = recordResolved(record)
@@ -595,6 +589,7 @@ export function buildHandlers(
595
589
  ...((svnHandlers ?? {}) as RpcHandlerMap),
596
590
  // edrv.dap.* 由 createDapRpc 提供(调试会话单例),这里并入。
597
591
  ...((dapHandlers ?? {}) as RpcHandlerMap),
592
+ ...(reviewHandlers(ctx, registry) as RpcHandlerMap),
598
593
  'edrv.list': async (args) => {
599
594
  // #region debug log
600
595
  const start = performance.now()
@@ -945,6 +940,7 @@ export function buildHandlers(
945
940
  if (!fs) return { ok: false, error: '缺少 fs' }
946
941
  const batch = args.batch
947
942
  const bucket = await bucketOf(registry, ctx, sc.cwd)
943
+ if (hasOfficial(sc.cwd, args.path)) return { ok: false, error: '该文件已有官方审查记录,禁止使用旧版批次回滚覆盖当前文件' }
948
944
  try {
949
945
  let affected: DiffRecord[] = []
950
946
  let beforeRec: DiffRecord | null = null
@@ -0,0 +1,91 @@
1
+ /** Official facts are immutable; review decisions never imply a disk version. @author ddj 2026年10月26号 */
2
+ export interface ChangedFile {
3
+ path: string
4
+ display: string
5
+ added: number
6
+ deleted: number
7
+ binary?: true
8
+ oversized?: true
9
+ }
10
+ export interface ReviewHunk {
11
+ oldStart: number
12
+ oldLines: number
13
+ newStart: number
14
+ newLines: number
15
+ lines: string[]
16
+ }
17
+ export type OfficialDiff = { kind: 'text'; path: string; display: string; before: boolean; after: boolean; hunks: ReviewHunk[]; coarse: boolean }
18
+ | { kind: 'binary' | 'oversized'; path: string; display: string }
19
+ export interface OfficialSummary {
20
+ turn: number
21
+ cwd: string
22
+ files: ChangedFile[]
23
+ total: number
24
+ added: number
25
+ deleted: number
26
+ snapshot?: { before: string; after: string }
27
+ }
28
+ export interface ReviewSnapshot {
29
+ sourceKind: 'official'
30
+ sessionId: string
31
+ turn: number
32
+ seq: number
33
+ capturedAt: string
34
+ summary: OfficialSummary
35
+ files: { identity: string; diff: OfficialDiff }[]
36
+ }
37
+ export type ReviewDecision = 'pending' | 'accepted' | 'rejected'
38
+ export interface ReviewItem {
39
+ id: string
40
+ sessionId: string
41
+ turn: number
42
+ seq: number
43
+ path: string
44
+ currentPath: string
45
+ display: string
46
+ added: number
47
+ deleted: number
48
+ kind: OfficialDiff['kind']
49
+ decision: ReviewDecision
50
+ revision: number
51
+ current: boolean
52
+ }
53
+ export interface ReviewDetail {
54
+ item: ReviewItem
55
+ diff: OfficialDiff
56
+ capturedAt: string
57
+ capability: { keep: boolean; undo: boolean; reason: string }
58
+ }
59
+ export interface CaptureGap {
60
+ sessionId: string
61
+ turn: number
62
+ seq: number
63
+ error: string
64
+ }
65
+ export interface ReviewFile {
66
+ sourceKind: 'official'
67
+ path: string
68
+ label: string
69
+ pending: number
70
+ }
71
+ export interface ReviewOverview {
72
+ files: ReviewFile[]
73
+ /** Every official identity, including decided files: never revive legacy actions for them. */
74
+ coveredPaths: string[]
75
+ }
76
+ export interface ReviewPage {
77
+ overview?: ReviewOverview
78
+ items: ReviewItem[]
79
+ pending: number
80
+ nextCursor: number | null
81
+ gaps: CaptureGap[]
82
+ }
83
+ export interface ReviewRequest { sessionId: string }
84
+ export interface DecideReview extends ReviewRequest {
85
+ id: string
86
+ seq: number
87
+ revision: number
88
+ operationId: string
89
+ decision: 'accepted' | 'rejected'
90
+ }
91
+ export type ReviewResult<T> = ({ ok: true } & T) | { ok: false; error: string }