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.
- package/README.md +26 -15
- package/lib/client.js +4427 -3510
- package/lib/client.js.map +1 -1
- package/lib/index.js +937 -42
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/archiveDb.ts +8 -8
- package/src/capture.ts +2 -2
- package/src/client/monaco/diffRender.ts +2 -2
- package/src/client/reviewBatch.ts +64 -0
- package/src/client/state/diffSummary.ts +30 -0
- package/src/client/state/reviewFeed.ts +64 -0
- package/src/client/styles/editor.css +39 -0
- package/src/client/ui/ConversationDiffDock.ts +9 -10
- package/src/client/ui/DiffBadge.ts +8 -6
- package/src/client/ui/DiffBox.ts +3 -0
- package/src/client/ui/DiffLauncher.ts +21 -5
- package/src/client/ui/EditorView.ts +163 -13
- package/src/client/ui/OfficialDiffBar.tsx +112 -0
- package/src/client/ui/OfficialReview.ts +93 -0
- package/src/client-primitives.d.ts +22 -0
- package/src/index.ts +6 -0
- package/src/model.ts +7 -3
- package/src/officialCapture.ts +203 -0
- package/src/reviewCapture.ts +112 -0
- package/src/reviewDb.ts +285 -0
- package/src/reviewEvidence.ts +70 -0
- package/src/reviewRpc.ts +42 -0
- package/src/reviewUndo.ts +104 -0
- package/src/rpc.ts +14 -18
- package/src/shared/review.ts +95 -0
- package/src/shared/reviewRegions.ts +74 -0
- package/src/shared/rpc.ts +7 -0
- package/src/store.ts +2 -2
|
@@ -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
|
+
}
|
package/src/reviewDb.ts
ADDED
|
@@ -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
|
+
}
|