dsh-vscode-mode 0.19.2 → 0.20.1

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.
@@ -0,0 +1,203 @@
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, ReviewSnapshot } 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, onSnapshot?: (cwd: string, snapshot: ReviewSnapshot) => void): () => 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: (cwd, snapshot) => {
157
+ const saved = saveSnapshot(cwd, snapshot)
158
+ onSnapshot?.(cwd, snapshot)
159
+ return saved
160
+ }, state: captureState, report: (message) => log.warn(message),
161
+ })
162
+ // Subscribe first: seed reads must not create a live-event race window.
163
+ ctx.on('session/event', collector.enqueue)
164
+ ctx.on('session/created', backfill)
165
+ ctx.on('session/flush', collector.flush)
166
+ for (const session of ctx.get('sessions').list()) void backfill(session)
167
+ return collector.dispose
168
+
169
+ /** @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. */
170
+ async function resolvePath(cwd: string, path: string): Promise<string> {
171
+ const fs = ctx.get('fs')
172
+ const target = await fs.resolve(path, { cwd })
173
+ const processPath = fs.processPath(target)
174
+ if (typeof processPath !== 'string' || !processPath || processPath === '[object Object]') {
175
+ throw new Error('官方文件路径解析失败')
176
+ }
177
+ return processPath
178
+ }
179
+
180
+ /** @private @author ddj 2026年10月26号 @param session Live session. Only public async reads, excluding inherited records. */
181
+ async function backfill(session: SourceSession): Promise<void> {
182
+ const query = ctx.get('sessionQuery')
183
+ if (!query?.listEvents || !query?.readEvent) {
184
+ log.warn('官方历史补采不可用:缺少 sessionQuery;后续实时事件仍会采集')
185
+ return
186
+ }
187
+ try {
188
+ // Repair durable history independently: official summaries do not survive Host restart.
189
+ // Each saved snapshot resolves against its own cwd, not the current session directory.
190
+ if (session.header?.cwd) {
191
+ await loadBucket(ctx, session.header.cwd)
192
+ await repairPaths(session.header.cwd, resolvePath)
193
+ }
194
+ // Semantic search omits structural events; only the raw metadata list contains workspace/changes.
195
+ const events = await query.listEvents(session.id)
196
+ for (const event of events) {
197
+ if (event.type !== 'workspace/changes' || event.seq < (session.inheritedEventCount ?? 0)) continue
198
+ const window = await query.readEvent({ sessionId: session.id, seq: event.seq })
199
+ collector.enqueue(session, window.target)
200
+ }
201
+ } catch (error) { log.warn('官方历史补采失败:' + String(error)) }
202
+ }
203
+ }
@@ -0,0 +1,112 @@
1
+ /** Independent byte evidence; official diff remains the only display authority. @author ddj 2026年10月27号 */
2
+ import { rawReviewFile, evidenceBlocks } from './reviewEvidence.js'
3
+ import type { RawReviewFile } from './reviewEvidence.js'
4
+ import type { ReviewSnapshot } from './shared/review.js'
5
+ import { reviewDb } from './reviewDb.js'
6
+ import { resolveTarget } from './store.js'
7
+ import type { Ctx } from './store.js'
8
+ import { log } from './log.js'
9
+
10
+ interface ByteChain { before: RawReviewFile; after: RawReviewFile; busy: boolean; invalid: boolean }
11
+ interface EvidenceCapture {
12
+ persist(cwd: string, snapshot: ReviewSnapshot): void
13
+ dispose(): void
14
+ }
15
+
16
+ /** @public @author ddj 2026年10月27号 @param ctx Host lifecycle owner. @returns Scoped byte recorder; failures disable undo evidence only, never tool execution. */
17
+ export function installEvidence(ctx: Ctx): EvidenceCapture {
18
+ const chains = new Map<string, Map<string, ByteChain>>()
19
+ const staged = new Map<string, Map<string, ByteChain>>()
20
+ let stopped = false
21
+ ctx.on('tools/execute', aroundTool)
22
+ ctx.on('session/event', onEvent)
23
+ ctx.on('session/disposed', onDisposed)
24
+
25
+ /** @private @author ddj 2026年10月27号 @param session Disposed owner. Drops optional in-memory evidence, never persisted official facts. */
26
+ function onDisposed(session: { id: string }): void { chains.delete(session.id) }
27
+
28
+ /** @private @author ddj 2026年10月27号 @returns Current bounded capture footprint, excluding transient filesystem reads. */
29
+ function footprint(): number {
30
+ let bytes = 0
31
+ for (const group of [chains, staged]) {
32
+ for (const files of group.values()) {
33
+ for (const chain of files.values()) bytes += chain.before.bytes.length + chain.after.bytes.length
34
+ }
35
+ }
36
+ return bytes
37
+ }
38
+
39
+ /** @private @author ddj 2026年10月27号 @param exec Allowed tool dispatch. @param next Original dispatch. @returns Original result unchanged, regardless of capture failure. */
40
+ async function aroundTool(exec: any, next: () => Promise<any>): Promise<any> {
41
+ const session = exec.agent?.session
42
+ const path = exec.arguments?.file_path
43
+ if (stopped || !session?.id || !['edit', 'write'].includes(exec.name) || typeof path !== 'string') return next()
44
+ let fs: any
45
+ let chain: ByteChain | undefined
46
+ let target: any
47
+ try {
48
+ if ((chains.size >= 16 && !chains.has(session.id)) || footprint() >= 32 * 1024 * 1024) return next()
49
+ fs = ctx.get('fs')
50
+ target = await resolveTarget(ctx, session, path)
51
+ const identity = fs.processPath(target)
52
+ const before = await readRaw(fs, target, exec.signal)
53
+ let files = chains.get(session.id)
54
+ if (!files) { files = new Map(); chains.set(session.id, files) }
55
+ if (files.size >= 64 && !files.has(identity)) return next()
56
+ chain = files.get(identity)
57
+ if (!chain) { chain = { before, after: before, busy: false, invalid: false }; files.set(identity, chain) }
58
+ if (chain.busy || chain.after.hash !== before.hash) chain.invalid = true
59
+ chain.busy = true
60
+ } catch { /* No trustworthy read means no undo evidence, not a failed user tool. */ }
61
+ try { return await next() }
62
+ finally {
63
+ if (chain) {
64
+ try { chain.after = await readRaw(fs, target, exec.signal) } catch { chain.invalid = true }
65
+ chain.busy = false
66
+ }
67
+ }
68
+ }
69
+
70
+ /** @private @author ddj 2026年10月27号 @param session Source session. @param event Published official turn. Freezes the chain before the next turn can mutate it. */
71
+ function onEvent(session: any, event: any): void {
72
+ if (stopped || event.type !== 'workspace/changes') return
73
+ const files = chains.get(session.id)
74
+ chains.delete(session.id)
75
+ if (!files || staged.size >= 128) return
76
+ staged.set(JSON.stringify([session.id, event.seq]), structuredClone(files))
77
+ }
78
+
79
+ /** @public @author ddj 2026年10月27号 @param cwd Official workspace. @param snapshot Committed official facts. Persists only independently validated, exact-correspondence byte chains. */
80
+ function persist(cwd: string, snapshot: ReviewSnapshot): void {
81
+ const key = JSON.stringify([snapshot.sessionId, snapshot.seq])
82
+ const files = staged.get(key)
83
+ staged.delete(key)
84
+ if (!files || stopped) return
85
+ const db = reviewDb(cwd)
86
+ for (const file of snapshot.files) {
87
+ const chain = files.get(file.identity)
88
+ if (!chain || chain.invalid || chain.busy) continue
89
+ try {
90
+ const evidence = { before: chain.before, after: chain.after }
91
+ evidenceBlocks(file.diff, evidence)
92
+ db.prepare(`INSERT OR IGNORE INTO review_evidence(file_id,payload)
93
+ SELECT id,? FROM review_files WHERE session_id=? AND turn=? AND seq=? AND identity=?`)
94
+ .run(JSON.stringify(evidence), snapshot.sessionId, snapshot.turn, snapshot.seq, file.identity)
95
+ } catch (error) { log.warn('官方回滚证据不可用:' + String(error)) }
96
+ }
97
+ }
98
+
99
+ /** @public @author ddj 2026年10月27号 Release bounded in-memory images after capture has drained. */
100
+ function dispose(): void { stopped = true; chains.clear(); staged.clear() }
101
+ return { persist, dispose }
102
+ }
103
+
104
+ /** @private @author ddj 2026年10月27号 @param fs Public filesystem. @param target Backend identity. @returns Raw bounded file or confirmed missing. @throws Any non-missing read error. */
105
+ async function readRaw(fs: any, target: any, signal?: AbortSignal): Promise<RawReviewFile> {
106
+ try { return rawReviewFile(await fs.readBytes(target, signal, 1024 * 1024)) }
107
+ catch (error) {
108
+ const code = (error as { code?: string }).code
109
+ if (code === 'ENOENT' || code === 'FS_NOT_FOUND') return rawReviewFile(null)
110
+ throw error
111
+ }
112
+ }
@@ -0,0 +1,285 @@
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 { existsSync } from 'node:fs'
4
+ import { archiveDbFile } from './paths.js'
5
+ import { reviewBlocks } from './shared/reviewRegions.js'
6
+ import type { ReviewBlockState } from './shared/reviewRegions.js'
7
+ import type { DatabaseSync } from 'node:sqlite'
8
+ import { activeReady, dbFor } from './archiveDb.js'
9
+ import type { CaptureGap, DecideReview, OfficialDiff, ReviewDetail, ReviewItem, ReviewPage, ReviewSnapshot } from './shared/review.js'
10
+
11
+ type Row = Record<string, string | number | null>
12
+
13
+ /** @public @author ddj 2026年10月26号 @param cwd Migrated workspace. @returns Schema4 connection; DDL and version change roll back together. */
14
+ export function reviewDb(cwd: string): DatabaseSync {
15
+ if (!activeReady(cwd)) throw new Error('官方审查要求先完成活跃库迁移')
16
+ const db = dbFor(cwd)
17
+ const version = db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row
18
+ if (version.value === '4') { migrateBlocks(cwd, db); return db }
19
+ db.exec('BEGIN IMMEDIATE')
20
+ try {
21
+ db.exec(`CREATE TABLE review_snapshots (
22
+ session_id TEXT NOT NULL, turn INTEGER NOT NULL, seq INTEGER NOT NULL,
23
+ payload TEXT NOT NULL, digest TEXT NOT NULL, current INTEGER NOT NULL DEFAULT 1,
24
+ PRIMARY KEY(session_id,turn,seq));
25
+ CREATE TABLE review_files (
26
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, turn INTEGER NOT NULL, seq INTEGER NOT NULL,
27
+ identity TEXT NOT NULL, summary TEXT NOT NULL, diff TEXT NOT NULL,
28
+ decision TEXT NOT NULL DEFAULT 'pending', revision INTEGER NOT NULL DEFAULT 0,
29
+ current INTEGER NOT NULL DEFAULT 1);
30
+ CREATE INDEX review_pending ON review_files(current,decision,id);
31
+ CREATE INDEX review_turn ON review_files(session_id,turn,seq);
32
+ CREATE TABLE review_capture (
33
+ session_id TEXT NOT NULL, turn INTEGER NOT NULL, seq INTEGER NOT NULL,
34
+ state TEXT NOT NULL, error TEXT, PRIMARY KEY(session_id,turn,seq));
35
+ CREATE TABLE review_ops (
36
+ operation_id TEXT PRIMARY KEY, request TEXT NOT NULL, state TEXT NOT NULL, result TEXT,
37
+ evidence TEXT);
38
+ UPDATE metadata SET value='4' WHERE name='schema';`)
39
+ db.exec('COMMIT')
40
+ } catch (error) {
41
+ if (db.isTransaction) db.exec('ROLLBACK')
42
+ throw error
43
+ }
44
+ migrateBlocks(cwd, db)
45
+ return db
46
+ }
47
+
48
+ /** @private @author ddj 2026年10月27号 @param cwd Workspace backup identity. @param db Open schema4 database. Extends the official-review sub-schema without changing legacy archive schema. @throws Migration failure, leaving prior decisions intact. */
49
+ function migrateBlocks(cwd: string, db: DatabaseSync): void {
50
+ if (db.prepare("SELECT value FROM metadata WHERE name='review_schema'").get()) return
51
+ const backup = archiveDbFile(cwd) + '.before-review-blocks.bak'
52
+ if (!existsSync(backup)) db.exec(`VACUUM INTO '${backup.replace(/'/g, "''")}'`)
53
+
54
+ db.exec('BEGIN IMMEDIATE')
55
+ try {
56
+ db.exec(`CREATE TABLE review_blocks (file_id TEXT NOT NULL, block_id TEXT NOT NULL,
57
+ decision TEXT NOT NULL, PRIMARY KEY(file_id,block_id));
58
+ CREATE TABLE review_evidence (file_id TEXT PRIMARY KEY, payload TEXT NOT NULL);
59
+ INSERT INTO metadata(name,value) VALUES('review_schema','2');`)
60
+ const files = db.prepare('SELECT id,diff,decision FROM review_files').all() as Row[]
61
+ for (const file of files) seedBlocks(db, String(file.id), JSON.parse(String(file.diff)), String(file.decision))
62
+ db.exec('COMMIT')
63
+ } catch (error) {
64
+ if (db.isTransaction) db.exec('ROLLBACK')
65
+ throw error
66
+ }
67
+ }
68
+
69
+ /** @private @author ddj 2026年10月27号 @param db Transaction connection. @param id File record. @param diff Immutable official source. @param decision Prior file decision. Seeds deterministic blocks without changing source facts. */
70
+ function seedBlocks(db: DatabaseSync, id: string, diff: OfficialDiff, decision: string): void {
71
+ const insert = db.prepare('INSERT INTO review_blocks VALUES(?,?,?)')
72
+ for (const block of reviewBlocks(diff)) insert.run(id, block.id, decision)
73
+ }
74
+
75
+ /** @private @author ddj 2026年10月26号 @param text Canonical serialized input. @returns Collision-resistant identity. */
76
+ function digest(text: string): string { return createHash('sha256').update(text).digest('hex') }
77
+
78
+ /** @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. */
79
+ export function saveSnapshot(cwd: string, snapshot: ReviewSnapshot): boolean {
80
+ const db = reviewDb(cwd)
81
+ const { sessionId, turn, seq } = snapshot
82
+ if (snapshot.summary.cwd !== cwd || snapshot.summary.turn !== turn || snapshot.files.length !== snapshot.summary.files.length) throw new Error('官方采集不完整')
83
+ const identities = new Set(snapshot.files.map((file) => file.identity))
84
+ if (identities.size !== snapshot.files.length) throw new Error('官方文件身份重复')
85
+ // capturedAt is acquisition metadata, not part of the immutable source identity.
86
+ const hash = digest(JSON.stringify({ ...snapshot, capturedAt: '' }))
87
+ db.exec('BEGIN IMMEDIATE')
88
+ try {
89
+ const prior = db.prepare('SELECT digest FROM review_snapshots WHERE session_id=? AND turn=? AND seq=?').get(sessionId, turn, seq) as Row | undefined
90
+ if (prior && prior.digest !== hash) throw new Error('同一官方事件的内容发生变化')
91
+ const latest = db.prepare('SELECT MAX(seq) AS seq FROM review_snapshots WHERE session_id=? AND turn=?').get(sessionId, turn) as Row
92
+ if (prior || (typeof latest.seq === 'number' && latest.seq > seq)) { db.exec('COMMIT'); return false }
93
+ insertSnapshot(db, snapshot, hash)
94
+ 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)
95
+ db.exec('COMMIT')
96
+ return true
97
+ } catch (error) {
98
+ if (db.isTransaction) db.exec('ROLLBACK')
99
+ throw error
100
+ }
101
+ }
102
+
103
+ /** @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. */
104
+ export async function repairPaths(cwd: string, resolvePath: (cwd: string, path: string) => Promise<string>): Promise<void> {
105
+ const db = reviewDb(cwd)
106
+ const broken = db.prepare(`SELECT DISTINCT s.* FROM review_snapshots s JOIN review_files f
107
+ USING(session_id,turn,seq) WHERE f.identity='[object Object]'`).all() as Row[]
108
+ for (const row of broken) {
109
+ const snapshot = JSON.parse(String(row.payload)) as ReviewSnapshot
110
+ for (const file of snapshot.files) {
111
+ if (file.identity !== '[object Object]') continue
112
+ file.identity = await resolvePath(snapshot.summary.cwd, file.diff.path)
113
+ if (!file.identity || file.identity === '[object Object]') throw new Error('官方文件路径修复失败')
114
+ }
115
+ if (new Set(snapshot.files.map((file) => file.identity)).size !== snapshot.files.length) throw new Error('官方文件身份重复')
116
+
117
+ // All IO is staged before locking; compare the persisted payload to avoid overwriting newer repairs.
118
+ db.exec('BEGIN IMMEDIATE')
119
+ try {
120
+ const current = db.prepare('SELECT payload FROM review_snapshots WHERE session_id=? AND turn=? AND seq=?')
121
+ .get(snapshot.sessionId, snapshot.turn, snapshot.seq) as Row | undefined
122
+ if (current?.payload === row.payload) {
123
+ for (const file of snapshot.files) {
124
+ db.prepare(`UPDATE review_files SET identity=? WHERE session_id=? AND turn=? AND seq=?
125
+ AND identity='[object Object]' AND json_extract(diff,'$.path')=?`)
126
+ .run(file.identity, snapshot.sessionId, snapshot.turn, snapshot.seq, file.diff.path)
127
+ }
128
+ db.prepare('UPDATE review_snapshots SET payload=?,digest=? WHERE session_id=? AND turn=? AND seq=?')
129
+ .run(JSON.stringify(snapshot), digest(JSON.stringify({ ...snapshot, capturedAt: '' })), snapshot.sessionId, snapshot.turn, snapshot.seq)
130
+ }
131
+ db.exec('COMMIT')
132
+ } catch (error) {
133
+ if (db.isTransaction) db.exec('ROLLBACK')
134
+ throw error
135
+ }
136
+ }
137
+ }
138
+
139
+ /** @private @author ddj 2026年10月26号 @param db Transaction. @param snapshot Source payload. @param hash Source digest. */
140
+ function insertSnapshot(db: DatabaseSync, snapshot: ReviewSnapshot, hash: string): void {
141
+ const { sessionId, turn, seq } = snapshot
142
+ const previous = db.prepare('SELECT id,identity,diff,decision FROM review_files WHERE session_id=? AND turn=? AND current=1').all(sessionId, turn) as Row[]
143
+ db.prepare('UPDATE review_snapshots SET current=0 WHERE session_id=? AND turn=?').run(sessionId, turn)
144
+ db.prepare('UPDATE review_files SET current=0 WHERE session_id=? AND turn=?').run(sessionId, turn)
145
+ db.prepare('INSERT INTO review_snapshots VALUES(?,?,?,?,?,1)').run(sessionId, turn, seq, JSON.stringify(snapshot), hash)
146
+ const insert = db.prepare('INSERT INTO review_files VALUES(?,?,?,?,?,?,?,?,0,1)')
147
+ for (const [index, file] of snapshot.files.entries()) {
148
+ const encoded = JSON.stringify(file.diff)
149
+ const unchanged = previous.find((item) => item.identity === file.identity && item.diff === encoded)
150
+ const id = digest(JSON.stringify([sessionId, turn, seq, file.identity]))
151
+ const decision = String(unchanged?.decision ?? 'pending')
152
+ insert.run(id, sessionId, turn, seq, file.identity, JSON.stringify(snapshot.summary.files[index]), encoded, decision)
153
+ seedBlocks(db, id, file.diff, decision)
154
+ if (unchanged) {
155
+ // A same-turn republish must preserve mixed per-block decisions, not seed all blocks as mixed.
156
+ db.prepare(`UPDATE review_blocks SET decision=(SELECT p.decision FROM review_blocks p
157
+ WHERE p.file_id=? AND p.block_id=review_blocks.block_id)
158
+ WHERE file_id=? AND EXISTS(SELECT 1 FROM review_blocks p WHERE p.file_id=? AND p.block_id=review_blocks.block_id)`)
159
+ .run(unchanged.id, id, unchanged.id)
160
+ db.prepare('INSERT INTO review_evidence SELECT ?,payload FROM review_evidence WHERE file_id=?').run(id, unchanged.id)
161
+ }
162
+ }
163
+ }
164
+
165
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param gap Announced event identity. @param state Durable inbox or explicit failure. */
166
+ export function captureState(cwd: string, gap: CaptureGap, state: 'queued' | 'gap'): void {
167
+ reviewDb(cwd).prepare(`INSERT INTO review_capture VALUES(?,?,?,?,?) ON CONFLICT(session_id,turn,seq)
168
+ DO UPDATE SET state=excluded.state,error=excluded.error WHERE review_capture.state<>'complete'`).run(gap.sessionId, gap.turn, gap.seq, state, gap.error)
169
+ }
170
+
171
+ /** @private @author ddj 2026年10月26号 @param row File and state. @returns Wire-safe summary without hunks. */
172
+ function itemOf(row: Row): ReviewItem {
173
+ const summary = JSON.parse(String(row.summary))
174
+ const kind = row.kind ?? (JSON.parse(String(row.diff)) as OfficialDiff).kind
175
+ return { id: String(row.id), sessionId: String(row.session_id), turn: Number(row.turn), seq: Number(row.seq),
176
+ ...summary, currentPath: String(row.identity), kind: kind as ReviewItem['kind'], decision: row.decision as ReviewItem['decision'], revision: Number(row.revision), current: row.current === 1 }
177
+ }
178
+
179
+ /** @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. */
180
+ export function reviewPage(cwd: string, cursor = 0, history = false, path?: string): ReviewPage {
181
+ const db = reviewDb(cwd)
182
+ const offset = Number.isSafeInteger(cursor) && cursor >= 0 ? cursor : 0
183
+ const conditions = history ? [] : ["current=1 AND decision='pending'"]
184
+ if (path !== undefined) conditions.push('identity=?')
185
+ const clause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
186
+ const parameters = path === undefined ? [offset] : [path, offset]
187
+ const rows = db.prepare(`SELECT id,session_id,turn,seq,identity,summary,decision,revision,current,
188
+ json_extract(diff,'$.kind') AS kind FROM review_files ${clause} ORDER BY rowid DESC LIMIT 51 OFFSET ?`).all(...parameters) as Row[]
189
+ const count = db.prepare("SELECT COUNT(*) AS n FROM review_files WHERE current=1 AND decision='pending'").get() as Row
190
+ const gaps = db.prepare("SELECT * FROM review_capture WHERE state<>'complete' ORDER BY seq DESC LIMIT 50").all() as Row[]
191
+ const capped = db.prepare(`SELECT session_id,turn,seq,json_extract(payload,'$.summary.total') AS total,
192
+ json_array_length(payload,'$.summary.files') AS listed FROM review_snapshots WHERE current=1
193
+ AND json_extract(payload,'$.summary.total')>json_array_length(payload,'$.summary.files') LIMIT 50`).all() as Row[]
194
+ // Navigation is metadata-only and independent of the 50-row patch-history page.
195
+ const files = db.prepare(`SELECT identity,MIN(json_extract(summary,'$.display')) AS label,COUNT(*) AS n
196
+ FROM review_files WHERE current=1 AND decision='pending' GROUP BY identity ORDER BY identity`).all() as Row[]
197
+ const covered = db.prepare('SELECT DISTINCT identity FROM review_files').all() as Row[]
198
+ return { overview: { files: files.map((row) => ({ sourceKind: 'official', path: String(row.identity), label: String(row.label), pending: Number(row.n) })),
199
+ coveredPaths: covered.map((row) => String(row.identity)) },
200
+ items: rows.slice(0, 50).map(itemOf), pending: Number(count.n), nextCursor: rows.length > 50 ? offset + 50 : null,
201
+ gaps: [
202
+ ...gaps.map((row) => ({ sessionId: String(row.session_id), turn: Number(row.turn), seq: Number(row.seq), error: String(row.error || '采集中或上次采集被中断') })),
203
+ ...capped.map((row) => ({ sessionId: String(row.session_id), turn: Number(row.turn), seq: Number(row.seq), error: `官方列表已截断:总计 ${row.total} 个文件,仅提供 ${row.listed} 个` })),
204
+ ] }
205
+
206
+ }
207
+
208
+ /** @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. */
209
+ export function hasOfficial(cwd: string, path: string): boolean {
210
+ const db = dbFor(cwd)
211
+ // A read guard must not acquire a migration write lock on legacy-only databases.
212
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name='review_files' AND type='table'").get()) return false
213
+ if (/^[A-Za-z]:[\\/]/.test(path)) {
214
+ return !!db.prepare("SELECT 1 FROM review_files WHERE lower(replace(identity,char(92),'/'))=? LIMIT 1")
215
+ .get(path.replace(/\\/g, '/').toLowerCase())
216
+ }
217
+ return !!db.prepare('SELECT 1 FROM review_files WHERE identity=? LIMIT 1').get(path)
218
+ }
219
+
220
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param id Immutable file identity. @returns Historical patch, not a reconstruction from disk. */
221
+ export function reviewDetail(cwd: string, id: string): ReviewDetail | null {
222
+ const db = reviewDb(cwd)
223
+ const row = db.prepare(`SELECT f.*,json_extract(s.payload,'$.capturedAt') AS captured_at FROM review_files f JOIN review_snapshots s
224
+ USING(session_id,turn,seq) WHERE f.id=?`).get(id) as Row | undefined
225
+ if (!row) return null
226
+ const blocks = db.prepare('SELECT block_id AS id,decision FROM review_blocks WHERE file_id=? ORDER BY rowid').all(id) as unknown as ReviewBlockState[]
227
+ const diff = JSON.parse(String(row.diff)) as OfficialDiff
228
+ const evidence = db.prepare('SELECT 1 FROM review_evidence WHERE file_id=?').get(id)
229
+ const pending = row.current === 1 && row.decision === 'pending'
230
+ const undo = pending && !!evidence && diff.kind === 'text' && diff.before && diff.after && !diff.coarse
231
+ return { item: itemOf(row), diff, capturedAt: String(row.captured_at), blocks,
232
+ capability: { keep: pending, undo,
233
+ reason: undo ? 'Undo 将校验原始字节、当前文件版本及已处理差异块;文件变化会安全拒绝。'
234
+ : '该记录缺少可验证的原始字节或受控写回能力;仍可显示差异和 Keep,不能猜测历史全文执行 Undo。' } }
235
+ }
236
+
237
+ /** @public @author ddj 2026年10月27号 @param db Active decision transaction. @param request File or block action. Updates aggregate without losing mixed decisions. @throws Invalid or already-decided block. */
238
+ export function changeBlocks(db: DatabaseSync, request: DecideReview): void {
239
+ if (request.scope === 'hunk') {
240
+ const changed = db.prepare("UPDATE review_blocks SET decision=? WHERE file_id=? AND block_id=? AND decision='pending'")
241
+ .run(request.decision, request.id, request.blockId ?? '')
242
+ if (changed.changes !== 1) throw new Error('差异块不存在或已处理')
243
+ } else {
244
+ db.prepare("UPDATE review_blocks SET decision=? WHERE file_id=? AND decision='pending'").run(request.decision, request.id)
245
+ }
246
+ const states = db.prepare('SELECT DISTINCT decision FROM review_blocks WHERE file_id=?').all(request.id) as Row[]
247
+ const decisions = states.map((row) => String(row.decision))
248
+ let aggregate = request.decision as string
249
+ if (decisions.includes('pending')) aggregate = 'pending'
250
+ else if (decisions.length > 1) aggregate = 'mixed'
251
+ else if (decisions.length === 1) aggregate = decisions[0]
252
+ db.prepare('UPDATE review_files SET decision=? WHERE id=?').run(aggregate, request.id)
253
+ }
254
+
255
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param request Frozen identity/revision. @returns Idempotent decision; rejection never guesses a target. */
256
+ export function decideReview(cwd: string, request: DecideReview): { revision: number } {
257
+ const db = reviewDb(cwd)
258
+ const encoded = JSON.stringify(request)
259
+ if (!request.operationId || request.operationId.length > 128) throw new Error('operationId 无效')
260
+ db.exec('BEGIN IMMEDIATE')
261
+ try {
262
+ const previous = db.prepare('SELECT request,result FROM review_ops WHERE operation_id=?').get(request.operationId) as Row | undefined
263
+ if (previous) {
264
+ if (previous.request !== encoded) throw new Error('operationId 已用于其他请求')
265
+ if (!previous.result) throw new Error('Undo 尚待写后核对,请重试原操作')
266
+ db.exec('COMMIT')
267
+ return JSON.parse(String(previous.result))
268
+ }
269
+ if (db.prepare("SELECT 1 FROM review_ops WHERE state='prepared' AND json_extract(request,'$.id')=?").get(request.id)) {
270
+ throw new Error('该文件有尚待核对的 Undo,暂不能 Keep')
271
+ }
272
+ if (request.decision !== 'accepted') throw new Error('缺少可信全文证据,禁止拒绝写回')
273
+ const change = db.prepare(`UPDATE review_files SET decision='accepted',revision=revision+1
274
+ WHERE id=? AND seq=? AND revision=? AND current=1 AND decision='pending'`).run(request.id, request.seq, request.revision)
275
+ if (change.changes !== 1) throw new Error('审查版本冲突,请刷新后重试')
276
+ changeBlocks(db, request)
277
+ const result = { revision: request.revision + 1 }
278
+ db.prepare("INSERT INTO review_ops VALUES(?,?,'committed',?,NULL)").run(request.operationId, encoded, JSON.stringify(result))
279
+ db.exec('COMMIT')
280
+ return result
281
+ } catch (error) {
282
+ if (db.isTransaction) db.exec('ROLLBACK')
283
+ throw error
284
+ }
285
+ }