dsh-vscode-mode 0.20.0 → 0.20.2

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/reviewRpc.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Session-scoped official review RPC. @author ddj 2026年10月26号 */
2
2
  import type { RpcHandlerMap } from './shared/rpc.js'
3
3
  import { decideReview, reviewDetail, reviewPage } from './reviewDb.js'
4
+ import { undoReview } from './reviewUndo.js'
4
5
  import { bucketOf, cwdOfSession, sessionOf } from './registry.js'
5
6
  import type { Registry } from './registry.js'
6
7
  import type { Ctx } from './store.js'
@@ -27,7 +28,15 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
27
28
  },
28
29
  'edrv.review.decide': async (args) => {
29
30
  const cwd = await workspace(args.sessionId)
30
- return { ok: true, ...decideReview(cwd, args) }
31
+ try {
32
+ const result = args.decision === 'rejected'
33
+ ? await undoReview(ctx, sessionOf(ctx, args.sessionId), cwd, args)
34
+ : decideReview(cwd, args)
35
+ return { ok: true, ...result }
36
+ } catch (error) {
37
+ // Guard failures are review results, not transport failures: preserve their reason.
38
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
39
+ }
31
40
  },
32
41
  }
33
42
  }
@@ -0,0 +1,104 @@
1
+ /** Guarded official undo with a durable write-intent journal. @author ddj 2026年10月27号 */
2
+ import { changeBlocks, reviewDb, reviewDetail } from './reviewDb.js'
3
+ import { evidenceBlocks, evidenceText, rawReviewFile } from './reviewEvidence.js'
4
+ import type { ReviewEvidence } from './reviewEvidence.js'
5
+ import type { DecideReview } from './shared/review.js'
6
+ import type { Ctx, Session } from './store.js'
7
+ import { policyOf, resolveTarget } from './store.js'
8
+
9
+ type OperationRow = { request: string; state: string; result: string | null; evidence: string }
10
+ interface UndoJournal { before: string; after: string }
11
+ const writes = new Map<string, Promise<unknown>>()
12
+
13
+ /** @public @author ddj 2026年10月27号 @param ctx Host capability. @param session Authenticated caller. @param cwd Workspace. @param request Frozen file/block version. @returns Durable decision; retries reconcile disk hashes and never blindly repeat a write. @throws Missing evidence, stale state, policy or uncertain write failure. */
14
+ export async function undoReview(ctx: Ctx, session: Session, cwd: string, request: DecideReview): Promise<{ revision: number }> {
15
+ const detail = reviewDetail(cwd, request.id)
16
+ const key = JSON.stringify([cwd, detail?.item.currentPath ?? request.id])
17
+ const prior = writes.get(key) ?? Promise.resolve()
18
+ const operation = prior.catch(() => undefined).then(() => runUndo(ctx, session, cwd, request))
19
+ writes.set(key, operation)
20
+ try { return await operation }
21
+ finally { if (writes.get(key) === operation) writes.delete(key) }
22
+ }
23
+
24
+ /** @private @author ddj 2026年10月27号 @param ctx Host. @param session Caller. @param cwd Workspace. @param request Frozen decision. @returns Serialized guarded mutation and journal reconciliation. @throws Conflicting bytes or decisions. */
25
+ async function runUndo(ctx: Ctx, session: Session, cwd: string, request: DecideReview): Promise<{ revision: number }> {
26
+ const db = reviewDb(cwd)
27
+ const encoded = JSON.stringify(request)
28
+ if (!request.operationId || request.operationId.length > 128) throw new Error('operationId 无效')
29
+ const prior = db.prepare('SELECT * FROM review_ops WHERE operation_id=?').get(request.operationId) as OperationRow | undefined
30
+ if (prior && prior.request !== encoded) throw new Error('operationId 已用于其他请求')
31
+ if (prior?.state === 'committed') return JSON.parse(prior.result!)
32
+ const detail = reviewDetail(cwd, request.id)
33
+ if (!detail || !detail.item.current || detail.item.seq !== request.seq || detail.item.revision !== request.revision
34
+ || detail.item.decision !== 'pending') throw new Error('审查版本冲突,请刷新后重试')
35
+ const fs = ctx.get('fs')
36
+ if (!fs?.readBytes || !fs?.writeText || !fs?.stat) throw new Error('后端缺少受控回滚能力')
37
+ const target = await resolveTarget(ctx, session, detail.item.currentPath)
38
+ const info = await fs.stat(target)
39
+ if (!info || info.type !== 'file' || !info.version) throw new Error('文件不存在或缺少版本守卫,不能安全 Undo')
40
+ const bytes = await fs.readBytes(target, undefined, 8 * 1024 * 1024)
41
+ const current = rawReviewFile(bytes)
42
+ const observed = await fs.stat(target)
43
+ if (observed?.version !== info.version) throw new Error('文件在回滚读取期间变化,未修改文件')
44
+ if (prior) {
45
+ const journal = JSON.parse(prior.evidence) as UndoJournal
46
+ if (current.hash === journal.after) return finishUndo(cwd, request)
47
+ if (current.hash !== journal.before) throw new Error('上次 Undo 状态不确定:磁盘既非写前也非写后,禁止重复执行')
48
+ }
49
+ const unresolved = db.prepare("SELECT operation_id FROM review_ops WHERE state='prepared' AND json_extract(request,'$.id')=? AND operation_id<>?")
50
+ .get(request.id, request.operationId)
51
+ if (unresolved) throw new Error('存在未核对的 Undo,请使用原操作重试,不能创建另一项写入')
52
+ const row = db.prepare('SELECT payload FROM review_evidence WHERE file_id=?').get(request.id) as { payload: string } | undefined
53
+ if (!row) throw new Error('该历史记录缺少可信原始字节证据,不能 Undo;差异仍可展示和 Keep')
54
+ const evidence = JSON.parse(row.payload) as ReviewEvidence
55
+ const segments = evidenceBlocks(detail.diff, evidence)
56
+ const texts = evidenceText(evidence)
57
+ if (!evidence.before.present || !evidence.after.present) throw new Error('当前后端未提供受控原子删除/重建能力')
58
+ const selected = detail.blocks.filter((block) => block.decision === 'pending' && (request.scope !== 'hunk' || block.id === request.blockId))
59
+ if (!selected.length) throw new Error('差异块不存在或已处理')
60
+ // Build the expected disk version by removing only previously rejected blocks.
61
+ const rejected = new Set(detail.blocks.filter((block) => block.decision === 'rejected').map((block) => block.id))
62
+ const expected = reverseBlocks(texts.after, segments, rejected)
63
+ if (rawReviewFile(Buffer.from(expected)).hash !== current.hash) throw new Error('当前文件已被后续编辑,版本与回滚证据冲突,未修改文件')
64
+ const undo = new Set([...rejected, ...selected.map((block) => block.id)])
65
+ const next = reverseBlocks(texts.after, segments, undo)
66
+ const journal = { before: current.hash, after: rawReviewFile(Buffer.from(next)).hash }
67
+ const latest = reviewDetail(cwd, request.id)
68
+ if (!latest?.item.current || latest.item.revision !== request.revision || latest.item.decision !== 'pending') {
69
+ throw new Error('审查版本在回滚校验期间变化,未修改文件')
70
+ }
71
+ db.prepare("INSERT INTO review_ops VALUES(?,?,'prepared',NULL,?) ON CONFLICT(operation_id) DO NOTHING")
72
+ .run(request.operationId, encoded, JSON.stringify(journal))
73
+ // Provider checks the exact observation inside its atomic mutation critical section.
74
+ await fs.writeText(target, next, { kind: 'replaceIfVersion', version: info.version }, undefined, policyOf(ctx, session))
75
+ const written = rawReviewFile(await fs.readBytes(target, undefined, 8 * 1024 * 1024))
76
+ if (written.hash !== journal.after) throw new Error('Undo 写后内容不确定,请保留原操作重试核对')
77
+ return finishUndo(cwd, request)
78
+ }
79
+
80
+ /** @private @author ddj 2026年10月27号 @param text Captured result. @param segments Validated raw ranges. @param rejected Blocks to reverse. @returns Raw storage text preserving unselected blocks. */
81
+ function reverseBlocks(text: string, segments: ReturnType<typeof evidenceBlocks>, rejected: Set<string>): string {
82
+ let result = text
83
+ for (const segment of [...segments].reverse()) {
84
+ if (!rejected.has(segment.id)) continue
85
+ result = result.slice(0, segment.offset) + segment.before + result.slice(segment.offset + segment.after.length)
86
+ }
87
+ return result
88
+ }
89
+
90
+ /** @private @author ddj 2026年10月27号 @param cwd Workspace. @param request Frozen action. @returns Transactionally committed status, or a retryable prepared journal if the database fails. */
91
+ function finishUndo(cwd: string, request: DecideReview): { revision: number } {
92
+ const db = reviewDb(cwd)
93
+ db.exec('BEGIN IMMEDIATE')
94
+ try {
95
+ const updated = db.prepare("UPDATE review_files SET revision=revision+1 WHERE id=? AND seq=? AND revision=? AND current=1 AND decision='pending'")
96
+ .run(request.id, request.seq, request.revision)
97
+ if (updated.changes !== 1) throw new Error('审查版本冲突,请核对已执行 Undo')
98
+ changeBlocks(db, request)
99
+ const result = { revision: request.revision + 1 }
100
+ db.prepare("UPDATE review_ops SET state='committed',result=? WHERE operation_id=?").run(JSON.stringify(result), request.operationId)
101
+ db.exec('COMMIT')
102
+ return result
103
+ } catch (error) { if (db.isTransaction) db.exec('ROLLBACK'); throw error }
104
+ }
@@ -35,6 +35,7 @@ export interface ReviewSnapshot {
35
35
  files: { identity: string; diff: OfficialDiff }[]
36
36
  }
37
37
  export type ReviewDecision = 'pending' | 'accepted' | 'rejected'
38
+ export type ReviewFileDecision = ReviewDecision | 'mixed'
38
39
  export interface ReviewItem {
39
40
  id: string
40
41
  sessionId: string
@@ -46,7 +47,7 @@ export interface ReviewItem {
46
47
  added: number
47
48
  deleted: number
48
49
  kind: OfficialDiff['kind']
49
- decision: ReviewDecision
50
+ decision: ReviewFileDecision
50
51
  revision: number
51
52
  current: boolean
52
53
  }
@@ -54,6 +55,7 @@ export interface ReviewDetail {
54
55
  item: ReviewItem
55
56
  diff: OfficialDiff
56
57
  capturedAt: string
58
+ blocks: import('./reviewRegions.js').ReviewBlockState[]
57
59
  capability: { keep: boolean; undo: boolean; reason: string }
58
60
  }
59
61
  export interface CaptureGap {
@@ -86,6 +88,8 @@ export interface DecideReview extends ReviewRequest {
86
88
  seq: number
87
89
  revision: number
88
90
  operationId: string
91
+ scope?: 'file' | 'hunk'
92
+ blockId?: string
89
93
  decision: 'accepted' | 'rejected'
90
94
  }
91
95
  export type ReviewResult<T> = ({ ok: true } & T) | { ok: false; error: string }
@@ -0,0 +1,74 @@
1
+ /** Official patch parsing shared by review decisions and editor display. @author ddj 2026年10月27号 */
2
+ import type { OfficialDiff, ReviewDecision } from './review.js'
3
+
4
+ export interface ReviewBlock {
5
+ id: string
6
+ hunkIndex: number
7
+ oldStart: number
8
+ newStart: number
9
+ oldLines: string[]
10
+ newLines: string[]
11
+ contextBefore: string[]
12
+ contextAfter: string[]
13
+ }
14
+ export interface ReviewBlockState { id: string; decision: ReviewDecision }
15
+ export interface ReviewRegion extends ReviewBlock {
16
+ sourceKind: 'official'
17
+ recordId: string
18
+ idx: number
19
+ status: ReviewDecision
20
+ stale: boolean
21
+ start?: number
22
+ end?: number
23
+ }
24
+
25
+ /** @public @author ddj 2026年10月27号 @param diff Immutable official patch. @returns Contiguous change runs with stable source-coordinate identities. */
26
+ export function reviewBlocks(diff: OfficialDiff): ReviewBlock[] {
27
+ if (diff.kind !== 'text') return []
28
+ const blocks: ReviewBlock[] = []
29
+ for (const [hunkIndex, hunk] of diff.hunks.entries()) {
30
+ let oldLine = hunk.oldStart, newLine = hunk.newStart
31
+ let before: string[] = []
32
+ let current: ReviewBlock | undefined
33
+ for (const line of hunk.lines) {
34
+ const marker = line[0], text = line.slice(1)
35
+ if (marker === ' ') {
36
+ if (current) { current.contextAfter.push(text); current = undefined }
37
+ else if (blocks.at(-1)?.hunkIndex === hunkIndex) blocks.at(-1)!.contextAfter.push(text)
38
+ before.push(text); oldLine++; newLine++
39
+ continue
40
+ }
41
+ if (marker !== '+' && marker !== '-') continue
42
+ if (!current) {
43
+ current = { id: `${hunkIndex}:${oldLine}:${newLine}`, hunkIndex, oldStart: oldLine, newStart: newLine,
44
+ oldLines: [], newLines: [], contextBefore: before.slice(-3), contextAfter: [] }
45
+ blocks.push(current); before = []
46
+ }
47
+ if (marker === '-') { current.oldLines.push(text); oldLine++ }
48
+ else { current.newLines.push(text); newLine++ }
49
+ }
50
+ }
51
+ return blocks
52
+ }
53
+
54
+ /** @public @author ddj 2026年10月27号 @param diff Source patch. @param recordId Official record, not a tool call. @param states Persisted per-block decisions. @param content Current editor text; null has no current-file location. @returns Regions only located by unique full contextual matches, never by guessed history line numbers. */
55
+ export function reviewRegions(diff: OfficialDiff, recordId: string, states: ReviewBlockState[], content: string | null): ReviewRegion[] {
56
+ const lines = content === null ? null : content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n')
57
+ return reviewBlocks(diff).map((block, idx) => {
58
+ const status = states.find((state) => state.id === block.id)?.decision ?? 'pending'
59
+ const region: ReviewRegion = { ...block, sourceKind: 'official', recordId, idx, status, stale: true }
60
+ if (!lines) return region
61
+ const pattern = [...block.contextBefore, ...block.newLines, ...block.contextAfter]
62
+ if (!pattern.length) return region
63
+ const matches: number[] = []
64
+ for (let offset = 0; offset + pattern.length <= lines.length; offset++) {
65
+ if (pattern.every((text, index) => lines[offset + index] === text)) matches.push(offset)
66
+ if (matches.length > 1) break
67
+ }
68
+ if (matches.length !== 1) return region
69
+ region.start = matches[0] + block.contextBefore.length + 1
70
+ region.end = region.start + block.newLines.length
71
+ region.stale = false
72
+ return region
73
+ })
74
+ }