dsh-vscode-mode 0.21.3 → 0.22.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.
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto'
3
3
  import { resolve } from 'node:path'
4
4
  import type { DiffRecord, MissingReview } from './shared/types.js'
5
5
  import type { Ctx, Session } from './store.js'
6
- import { archiveRecords, policyOf, READ_CAP, resolveTargetIn } from './store.js'
6
+ import { archiveRecords, policyOf, READ_CAP, resolveTargetIn, targetPathOf } from './store.js'
7
7
  import { hasOfficial } from './reviewDb.js'
8
8
  import { recordResolved } from './model.js'
9
9
 
@@ -31,13 +31,20 @@ function revisionOf(records: DiffRecord[]): string {
31
31
  return createHash('sha256').update(JSON.stringify(evidence)).digest('hex')
32
32
  }
33
33
 
34
+ /** @private @author ddj 2026年10月30号 @param ctx Host capability. @param target Resolve 结果:宿主路径字符串或不透明 FsTarget。 @returns 平台一致的物理身份;无法还原时返回空串。 */
35
+ function fileKeyOf(ctx: Ctx, target: unknown): string {
36
+ const path = targetPathOf(ctx, target)
37
+ return path ? fileKey(path) : ''
38
+ }
39
+
34
40
  /** @private @author ddj 2026年10月28号 @param ctx Host capability. @param cwd Workspace. @param bucket Records. @param target Resolved file. @returns Records for the same physical path spelling, oldest first. */
35
41
  async function fileRecords(ctx: Ctx, cwd: string, bucket: Map<string, DiffRecord>, target: string): Promise<DiffRecord[]> {
36
42
  const matches: DiffRecord[] = []
37
- const identity = fileKey(target)
43
+ // 解析结果可能是不透明对象,比较前必须先统一还原成宿主路径。
44
+ const identity = fileKeyOf(ctx, target)
38
45
  for (const record of bucket.values()) {
39
46
  if (record.superseded) continue
40
- if (fileKey(await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record)
47
+ if (fileKeyOf(ctx, await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record)
41
48
  }
42
49
  return matches.sort((left, right) => left.at.localeCompare(right.at))
43
50
  }
@@ -57,9 +64,12 @@ export async function fileAbsent(ctx: Ctx, cwd: string, path: string): Promise<b
57
64
  /** @public @author ddj 2026年10月28号 @param ctx Host. @param session Live session. @param cwd Workspace. @param bucket Records. @param path Requested file. @returns Frozen complete snapshot; never constructs text from hunks or writes files. @throws Missing evidence or nonmissing target. */
58
65
  export async function missingReview(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, path: string): Promise<MissingReview> {
59
66
  const target = await resolveTargetIn(ctx, cwd, path)
60
- if (hasOfficial(cwd, path) || hasOfficial(cwd, target)) throw new Error('该文件已有官方审查记录,请使用官方审查入口')
61
- if (!await fileAbsent(ctx, cwd, target)) throw new Error('文件已重新出现,请刷新')
62
- const records = await fileRecords(ctx, cwd, bucket, target)
67
+ // 解析结果可能是不透明 FsTarget,凡按路径字符串使用的分支都必须先还原。
68
+ const abs = targetPathOf(ctx, target)
69
+ if (!abs) throw new Error('无法解析目标文件路径')
70
+ if (hasOfficial(cwd, path) || hasOfficial(cwd, abs)) throw new Error('该文件已有官方审查记录,请使用官方审查入口')
71
+ if (!await fileAbsent(ctx, cwd, abs)) throw new Error('文件已重新出现,请刷新')
72
+ const records = await fileRecords(ctx, cwd, bucket, abs)
63
73
  const latest = records.at(-1)
64
74
  if (!records.some((record) => !record.archived && !recordResolved(record))) throw new Error('没有待审历史记录')
65
75
  if (!latest || typeof latest.after !== 'string') throw new Error('缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件')
@@ -88,33 +98,45 @@ async function applyDisk(ctx: Ctx, item: FrozenReview, target: string, undo: boo
88
98
  if (await fs.readText(target) !== item.view.content) throw new Error('恢复内容校验失败,未结清记录')
89
99
  }
90
100
 
101
+ /**
102
+ * @author ddj 2026年10月29号
103
+ * @description: 取删除预览对应的文件路径,供服务端判定该路径是否已由待审投影接管。
104
+ * @param token 冻结预览 token
105
+ * @returns 文件路径,token 无效时为 undefined
106
+ */
107
+ export function missingPath(token: string): string | undefined {
108
+ return frozen.get(token)?.view.path
109
+ }
110
+
91
111
  /** @public @author ddj 2026年10月28号 @param ctx Host. @param session Requesting session. @param cwd Workspace. @param bucket Records. @param token Frozen preview token. @param operationId Stable retry ID. @param undo True restores shown snapshot; false keeps deletion. @returns Covered call IDs. @throws Changed evidence, concurrent action or sandbox/IO failure. */
92
112
  export async function missingDecide(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, token: string, operationId: string, undo: boolean): Promise<string[]> {
93
113
  const item = frozen.get(token)
94
114
  if (!item || item.expires < Date.now() || item.cwd !== cwd || item.session.id !== session.id) throw new Error('删除预览已过期,请刷新后重试')
95
115
  if (!operationId || operationId.length > 128) throw new Error('缺少有效操作 ID')
96
116
  const target = await resolveTargetIn(ctx, cwd, item.view.path)
97
- const key = JSON.stringify([cwd, fileKey(target)])
117
+ const abs = targetPathOf(ctx, target)
118
+ if (!abs) throw new Error('无法解析目标文件路径')
119
+ const key = JSON.stringify([cwd, fileKey(abs)])
98
120
  if (locks.has(key)) throw new Error('该文件正在处理,请稍后重试')
99
121
  locks.add(key)
100
122
  item.session = session
101
- try { return await commitMissing(ctx, item, bucket, target, operationId, undo) }
123
+ try { return await commitMissing(ctx, item, bucket, target, abs, operationId, undo) }
102
124
  finally { locks.delete(key) }
103
125
  }
104
126
 
105
- /** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
106
- async function commitMissing(ctx: Ctx, item: FrozenReview, bucket: Map<string, DiffRecord>, target: string, operationId: string, undo: boolean): Promise<string[]> {
127
+ /** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param abs Host path of the target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
128
+ async function commitMissing(ctx: Ctx, item: FrozenReview, bucket: Map<string, DiffRecord>, target: string, abs: string, operationId: string, undo: boolean): Promise<string[]> {
107
129
  const previous = item.operation
108
130
  if (previous && (previous.id !== operationId || previous.undo !== undo)) throw new Error('已有不同操作,请刷新后重试')
109
131
  if (previous?.done) return item.view.callIds
110
- if (hasOfficial(item.cwd, target) || hasOfficial(item.cwd, item.view.path)) throw new Error('该文件已有官方审查记录,请刷新')
111
- const records = await fileRecords(ctx, item.cwd, bucket, target)
132
+ if (hasOfficial(item.cwd, abs) || hasOfficial(item.cwd, item.view.path)) throw new Error('该文件已有官方审查记录,请刷新')
133
+ const records = await fileRecords(ctx, item.cwd, bucket, abs)
112
134
  if (revisionOf(records) !== item.revision) throw new Error('历史记录已变化,请刷新删除预览')
113
135
  item.operation ??= { id: operationId, undo, restored: false, done: false }
114
- await applyDisk(ctx, item, target, undo)
136
+ await applyDisk(ctx, item, abs, undo)
115
137
 
116
138
  // Recheck after awaited IO: capture/another review must not silently join this decision.
117
- const current = await fileRecords(ctx, item.cwd, bucket, target)
139
+ const current = await fileRecords(ctx, item.cwd, bucket, abs)
118
140
  if (revisionOf(current) !== item.revision) throw new Error('历史记录已变化,未结清记录,请刷新')
119
141
  const pending = current.filter((record) => item.view.callIds.includes(record.callId))
120
142
  const decisions = pending.map((record) => record.decisions)
@@ -0,0 +1,136 @@
1
+ /**
2
+ * 待审差异采集:环绕一次文件写入读取真实前后内容,只有内容确实变化才落库。
3
+ * @author ddj 2026年10月29号
4
+ */
5
+ import { cwdOf } from '../registry.js'
6
+ import { resolveTarget } from '../store.js'
7
+ import type { Ctx } from '../store.js'
8
+ import { log } from '../log.js'
9
+
10
+ /** 可被采信为待审来源的写文件工具。 */
11
+ const WRITE_TOOLS = ['edit', 'write', 'str_replace_editor']
12
+ /** 单文件文本上限:超过即标注 oversized,不静默截断。 */
13
+ const MAX_TEXT = 1024 * 1024
14
+
15
+ /** 一次已确认的写入。 */
16
+ export interface PendingWrite {
17
+ cwd: string
18
+ sessionId: string
19
+ path: string
20
+ identity: string
21
+ before: string | null
22
+ after: string | null
23
+ now: number
24
+ }
25
+
26
+ /** 采集器。 */
27
+ export interface PendingCapture {
28
+ /** 释放监听并丢弃暂存。 */
29
+ dispose(): void
30
+ }
31
+
32
+ /**
33
+ * @author ddj 2026年10月29号
34
+ * @description: 安装写文件采集;读写失败只影响待审来源,绝不影响用户工具本身。
35
+ * @param ctx Host 生命周期
36
+ * @param sink 已确认写入的接收方
37
+ * @returns 可释放的采集器
38
+ */
39
+ export function installPendingCapture(ctx: Ctx, sink: (write: PendingWrite) => void): PendingCapture {
40
+ const busy = new Set<string>()
41
+ let stopped = false
42
+ ctx.on('tools/execute', aroundTool)
43
+ return { dispose }
44
+
45
+ /**
46
+ * @author ddj 2026年10月29号
47
+ * @description: 写工具执行前后各读一次,内容变化才上报。
48
+ * @param exec 工具分发上下文
49
+ * @param next 原始分发
50
+ * @returns 原始工具结果,永不因采集失败而改变
51
+ */
52
+ async function aroundTool(exec: any, next: () => Promise<any>): Promise<any> {
53
+ const session = exec.agent?.session
54
+ const path = exec.arguments?.file_path ?? exec.arguments?.path
55
+ if (stopped || !session?.id || typeof path !== 'string' || !WRITE_TOOLS.includes(exec.name)) return next()
56
+ const cwd = cwdOf(session)
57
+ if (!cwd) return next()
58
+ let before: string | null = null
59
+ let identity: string | null = null
60
+ try {
61
+ const fs = ctx.get('fs')
62
+ const target = await resolveTarget(ctx, session, path)
63
+ const key = fs.processPath(target)
64
+ if (typeof key !== 'string' || !key) return next()
65
+ if (busy.has(key)) return next()
66
+ before = await readText(fs, target)
67
+ if (before !== null && before.length > MAX_TEXT) return next()
68
+ identity = key
69
+ busy.add(key)
70
+ } catch (error) {
71
+ log.debug('待审采集跳过:' + String(error))
72
+ return next()
73
+ }
74
+ try {
75
+ return await next()
76
+ } finally {
77
+ if (identity !== null) {
78
+ const key: string = identity
79
+ try { report(String(cwd), String(session.id), String(path), key, before, exec) } catch (error) {
80
+ log.debug('待审上报失败:' + String(error))
81
+ }
82
+ busy.delete(key)
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @author ddj 2026年10月29号
89
+ * @description: 写后读回并比较,内容一致视为无净变化不产生条目。
90
+ * @param cwd 工作区
91
+ * @param sessionId 会话
92
+ * @param path 展示路径
93
+ * @param identity 目标身份
94
+ * @param before 写前内容
95
+ * @param exec 工具分发上下文
96
+ * @returns 无
97
+ */
98
+ function report(
99
+ cwd: string, sessionId: string, path: string, identity: string, before: string | null, exec: any,
100
+ ): void {
101
+ const fs = ctx.get('fs')
102
+ void resolveTarget(ctx, exec.agent.session, path).then(async (target: any) => {
103
+ const after = await readText(fs, target)
104
+ if (after !== null && after.length > MAX_TEXT) return
105
+ if (before === after) return
106
+ sink({ cwd, sessionId, path, identity, before, after, now: Date.now() })
107
+ }).catch((error: unknown) => log.debug('待审读回失败:' + String(error)))
108
+ }
109
+
110
+ /**
111
+ * @author ddj 2026年10月29号
112
+ * @description: 释放监听。
113
+ * @returns 无
114
+ */
115
+ function dispose(): void {
116
+ stopped = true
117
+ busy.clear()
118
+ }
119
+ }
120
+
121
+ /**
122
+ * @author ddj 2026年10月29号
123
+ * @description: 读取全文;缺失或不可读按"无内容"处理,交由上层决定语义。
124
+ * @param fs 文件系统
125
+ * @param target 目标
126
+ * @returns 文本或 null
127
+ */
128
+ export async function readText(fs: any, target: any): Promise<string | null> {
129
+ try {
130
+ return String(await fs.readText(target))
131
+ } catch (error) {
132
+ const code = (error as { code?: string }).code
133
+ if (code === 'ENOENT' || code === 'FS_NOT_FOUND' || code === 'FS_TOO_LARGE') return null
134
+ throw error
135
+ }
136
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * 待审判定内核:Keep 只推进投影,Revert 才写磁盘;写盘由注入的 writer 完成以便测试零写断言。
3
+ * @author ddj 2026年10月29号
4
+ */
5
+ import type { PendingDecide } from '../shared/pendingReview.js'
6
+ import type { ReviewBlock, ReviewBlockState } from '../shared/reviewRegions.js'
7
+ import { reviewBlocks } from '../shared/reviewRegions.js'
8
+ import type { OfficialDiff } from '../shared/review.js'
9
+ import { keepBlock, revertBlock, textDiff } from './hunks.js'
10
+ import type { PendingEntry } from './kernel.js'
11
+ import { applyUpdate, isSettled } from './kernel.js'
12
+ import type { PendingFlags } from './persist.js'
13
+
14
+ /** 写盘结果:`content` 是读回校验通过后的最终内容,null 表示删除。 */
15
+ export type WriteResult = { ok: true; content: string | null } | { ok: false; reason: string }
16
+
17
+ /** 写盘能力,由 host 侧实现;纯判定层只调用它一次。 */
18
+ export type WriteBack = (content: string | null) => Promise<WriteResult>
19
+
20
+ /** 一次判定的输入。 */
21
+ export interface DecideInput {
22
+ request: PendingDecide
23
+ entry: PendingEntry
24
+ flags: PendingFlags
25
+ blocks: ReviewBlockState[]
26
+ now: number
27
+ write: WriteBack
28
+ }
29
+
30
+ /** 一次判定的输出。 */
31
+ export interface DecideOutcome {
32
+ ok: boolean
33
+ reason: string
34
+ entry: PendingEntry
35
+ flags: PendingFlags
36
+ blocks: ReviewBlockState[]
37
+ /** 是否真正写过磁盘(Keep 恒为 false)。 */
38
+ wrote: boolean
39
+ }
40
+
41
+ /**
42
+ * @author ddj 2026年10月29号
43
+ * @description: 执行一次 Keep/Revert;失败时返回原状态,调用方不得落库。
44
+ * @param input 判定输入
45
+ * @returns 判定结果
46
+ */
47
+ export async function decide(input: DecideInput): Promise<DecideOutcome> {
48
+ const failed = (reason: string): DecideOutcome => ({
49
+ ok: false, reason, entry: input.entry, flags: input.flags, blocks: input.blocks, wrote: false,
50
+ })
51
+ if (input.request.revision !== input.flags.revision) return failed('revision-mismatch')
52
+ if (input.flags.oversized) return failed('oversized')
53
+ if (input.entry.oldText === input.entry.newText) return failed('settled')
54
+
55
+ const diff = textDiff(input.entry.path, input.entry.path, input.entry.oldText, input.entry.newText)
56
+ const targets = pickBlocks(reviewBlocks(diff), input)
57
+ if (targets === null) return failed('unknown-block')
58
+ if (input.request.scope !== 'file' && input.request.scope !== 'all' && targets.length === 0) {
59
+ return failed('no-block')
60
+ }
61
+ const isKeep = input.request.decision === 'accepted'
62
+ const whole = input.request.scope === 'file' || input.request.scope === 'all'
63
+ return isKeep ? keep(input, targets, whole) : revert(input, targets, whole)
64
+ }
65
+
66
+ /**
67
+ * @author ddj 2026年10月29号
68
+ * @description: Keep:只把目标块或整文件的新内容并入基线,绝不写目标文件。
69
+ * @param input 判定输入
70
+ * @param targets 目标块(整文件时为空)
71
+ * @param whole 是否整文件
72
+ * @returns 判定结果
73
+ */
74
+ function keep(input: DecideInput, targets: ReviewBlock[], whole: boolean): DecideOutcome {
75
+ if (whole) {
76
+ const entry = applyUpdate(input.entry, { oldText: input.entry.newText }, input.now)
77
+ return {
78
+ ok: true, reason: '', entry, wrote: false,
79
+ flags: { ...input.flags, revision: input.flags.revision + 1 },
80
+ blocks: markAll(input.blocks, targets, 'accepted'),
81
+ }
82
+ }
83
+ let oldText = input.entry.oldText
84
+ for (const block of targets) {
85
+ const next = keepBlock(oldText, block)
86
+ if (next === null) {
87
+ return { ok: false, reason: 'ambiguous-block', entry: input.entry, flags: input.flags, blocks: input.blocks, wrote: false }
88
+ }
89
+ oldText = next
90
+ }
91
+ const entry = applyUpdate(input.entry, { oldText }, input.now)
92
+ return {
93
+ ok: true, reason: '', entry, wrote: false,
94
+ flags: { ...input.flags, revision: input.flags.revision + 1 },
95
+ blocks: markAll(input.blocks, targets, 'accepted'),
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @author ddj 2026年10月29号
101
+ * @description: Revert:合成回退内容后交给 writer 写盘并读回,写失败则状态不变。
102
+ * @param input 判定输入
103
+ * @param targets 目标块(整文件时为空)
104
+ * @param whole 是否整文件
105
+ * @returns 判定结果
106
+ */
107
+ async function revert(input: DecideInput, targets: ReviewBlock[], whole: boolean): Promise<DecideOutcome> {
108
+ const failed = (reason: string): DecideOutcome => ({
109
+ ok: false, reason, entry: input.entry, flags: input.flags, blocks: input.blocks, wrote: false,
110
+ })
111
+ const next = whole ? wholeRevert(input.entry) : blockRevert(input.entry, targets)
112
+ if (next === undefined) return failed('ambiguous-block')
113
+
114
+ const written = await input.write(next)
115
+ if (!written.ok) return failed(written.reason)
116
+
117
+ const entry = applyUpdate(input.entry, { newText: written.content ?? '' }, input.now)
118
+ return {
119
+ ok: true, reason: '', entry, wrote: true,
120
+ flags: { ...input.flags, revision: input.flags.revision + 1, missing: written.content === null },
121
+ blocks: markAll(input.blocks, targets, 'rejected'),
122
+ }
123
+ }
124
+
125
+ /**
126
+ * @author ddj 2026年10月29号
127
+ * @description: 整文件回退目标:基线为"文件不存在"时删除,否则写回基线全文。
128
+ * @param entry 待审条目
129
+ * @returns 目标内容,null 表示删除
130
+ */
131
+ function wholeRevert(entry: PendingEntry): string | null {
132
+ return entry.earlierVersion === 'none' ? null : entry.oldText
133
+ }
134
+
135
+ /**
136
+ * @author ddj 2026年10月29号
137
+ * @description: 块级回退:在最新内容里依次换回目标块,任一不可定位即整体放弃。
138
+ * @param entry 待审条目
139
+ * @param targets 目标块
140
+ * @returns 新内容或 undefined
141
+ */
142
+ function blockRevert(entry: PendingEntry, targets: ReviewBlock[]): string | null | undefined {
143
+ let newText = entry.newText
144
+ for (const block of targets) {
145
+ const next = revertBlock(newText, block)
146
+ if (next === null) return undefined
147
+ newText = next
148
+ }
149
+ return newText
150
+ }
151
+
152
+ /**
153
+ * @author ddj 2026年10月29号
154
+ * @description: 按请求粒度挑出目标块;块不存在时返回 null 以区分"没有块"。
155
+ * @param blocks 当前差异块
156
+ * @param input 判定输入
157
+ * @returns 目标块,未知块 ID 时为 null
158
+ */
159
+ function pickBlocks(blocks: ReviewBlock[], input: DecideInput): ReviewBlock[] | null {
160
+ const scope = input.request.scope
161
+ if (scope === 'file' || scope === 'all') return []
162
+ if (scope === 'block') {
163
+ const block = blocks.find((item) => item.id === input.request.blockId)
164
+ return block ? [block] : null
165
+ }
166
+ const start = input.request.startLine ?? 0
167
+ const end = input.request.endLine ?? start
168
+ return blocks.filter((block) => block.newStart <= end && block.newStart + Math.max(block.newLines.length, 1) >= start)
169
+ }
170
+
171
+ /**
172
+ * @author ddj 2026年10月29号
173
+ * @description: 把目标块标记为已决定;整文件时把全部块一起标记。
174
+ * @param states 已存块决定
175
+ * @param targets 目标块
176
+ * @param decision 决定
177
+ * @returns 新的块决定列表
178
+ */
179
+ function markAll(states: ReviewBlockState[], targets: ReviewBlock[], decision: 'accepted' | 'rejected'): ReviewBlockState[] {
180
+ const next = states.slice()
181
+ for (const block of targets) {
182
+ const at = next.findIndex((state) => state.id === block.id)
183
+ if (at >= 0) next[at] = { id: block.id, decision }
184
+ else next.push({ id: block.id, decision })
185
+ }
186
+ return next
187
+ }
188
+
189
+ /**
190
+ * @author ddj 2026年10月29号
191
+ * @description: 能力口径:Keep 恒可;Revert 受超大/缺失/已解决限制,拒绝原因要能展示。
192
+ * @param entry 待审条目
193
+ * @param flags 持久标记
194
+ * @returns 能力与原因
195
+ */
196
+ export function capabilityOf(entry: PendingEntry, flags: PendingFlags): { keep: boolean; revert: boolean; reason: string } {
197
+ if (isSettled(entry)) return { keep: false, revert: false, reason: 'settled' }
198
+ if (flags.oversized) return { keep: false, revert: false, reason: 'oversized' }
199
+ if (entry.earlierVersion === 'none' && entry.newText.length === 0) {
200
+ return { keep: true, revert: false, reason: 'empty-create' }
201
+ }
202
+ return { keep: true, revert: true, reason: '' }
203
+ }
204
+
205
+ /**
206
+ * @author ddj 2026年10月29号
207
+ * @description: 供详情接口复用的差异构造。
208
+ * @param entry 待审条目
209
+ * @returns 官方差异
210
+ */
211
+ export function diffOf(entry: PendingEntry): OfficialDiff {
212
+ return textDiff(entry.path, entry.path, entry.oldText, entry.newText)
213
+ }