dsh-vscode-mode 0.23.1 → 0.23.3

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,6 @@
1
1
  /** Session-scoped official review RPC. @author ddj 2026年10月26号 */
2
2
  import type { RpcHandlerMap } from './shared/rpc.js'
3
- import type { ReviewItem, ReviewPage } from './shared/review.js'
3
+ import type { ReviewDetail, ReviewItem, ReviewPage } from './shared/review.js'
4
4
  import type { PendingPage } from './shared/pendingReview.js'
5
5
  import { decideReview, reviewDetail, reviewPage } from './reviewDb.js'
6
6
  import { undoReview } from './reviewUndo.js'
@@ -8,11 +8,21 @@ import { applyDecide, loadDetail, loadPage } from './pending/store.js'
8
8
  import { PENDING_PREFIX, mergedPage, pendingDetailOf, pendingItemOf, pendingSamePath } from './pendingCompat.js'
9
9
  import { bucketOf, resolveScope, sessionOf } from './registry.js'
10
10
  import type { Registry } from './registry.js'
11
+ import { resolveTargetIn } from './store.js'
11
12
  import type { Ctx } from './store.js'
13
+ import { log } from './log.js'
12
14
 
13
15
  /** 批量写入快照的硬上限:待审积压超过它说明状态异常,宁可拒绝也不做无界写循环。 */
14
16
  const BATCH_LIMIT = 2000
15
17
 
18
+ /** 冷端 Undo 说明:Undo 会校验证据并写回源文件,只能在持有该会话的那一端执行。 */
19
+ const UNDO_NEEDS_LIVE = 'Undo 需要在本端活跃的会话上执行:该会话当前在另一端(Web / 桌面)激活。'
20
+ + 'Keep 不受影响,可在任一端执行;或在本端打开该会话后重试。'
21
+
22
+ /** 冷端待审判定说明:待审库是按工作区的本地存储,判定需要与正在编辑的那一端保持一致。 */
23
+ const PENDING_NEEDS_LIVE = '待审条目的判定需要在本端活跃的会话上执行:该会话当前在另一端激活,'
24
+ + '请在那一端处理,或在本端打开该会话后重试。'
25
+
16
26
  /**
17
27
  * @private
18
28
  * @author ddj 2026年10月31号
@@ -45,6 +55,35 @@ function batchSnapshot(cwd: string, pending: PendingPage): { ok: true; page: Rev
45
55
  }
46
56
  }
47
57
 
58
+ /**
59
+ * @private
60
+ * @author ddj 2026年10月31号
61
+ * @description: 文件已在磁盘删除时修正官方记录的能力说明。Keep 只写审查库、仍可结案,
62
+ * Undo 需要读写源文件、必然不可用:把「缺少原始字节」这种误导性原因换成可执行说明。
63
+ * 只改文案与 undo 位,不参与判定写入。
64
+ * @param ctx Host 能力
65
+ * @param cwd 工作区
66
+ * @param detail 官方记录详情
67
+ * @param capability 已按冷/热端调整过的能力
68
+ * @returns 修正后的能力;无法确认删除时原样返回
69
+ */
70
+ async function absenceCapability(ctx: Ctx, cwd: string, detail: ReviewDetail, capability: ReviewDetail['capability']): Promise<ReviewDetail['capability']> {
71
+ if (!capability.keep) return capability
72
+ // 记录本身就是删除(after=false):Undo 要整文件内容,官方差异只有片段
73
+ if (detail.diff.kind === 'text' && !detail.diff.after) {
74
+ return { ...capability, undo: false, reason: '该记录删除了这个文件:Keep 按删除结案;Undo 需要完整文件内容,官方差异只有片段,不可用。' }
75
+ }
76
+
77
+ // 文件已被删除时,Undo 的失败原因是「文件不在」,不是「缺少原始字节」:先查磁盘再谈证据
78
+ const fs = ctx.get('fs')
79
+ if (!fs) return capability
80
+ try {
81
+ const target = await resolveTargetIn(ctx, cwd, detail.item.currentPath)
82
+ if (await fs.stat(target)) return capability
83
+ } catch { return capability }
84
+ return { ...capability, undo: false, reason: '文件已在磁盘删除:Keep 按删除结案;Undo 需要读写该文件,当前不可用。' }
85
+ }
86
+
48
87
  /**
49
88
  * @public
50
89
  * @author ddj 2026年10月26号 / 2026年10月28号
@@ -55,20 +94,52 @@ function batchSnapshot(cwd: string, pending: PendingPage): { ok: true; page: Rev
55
94
  * @returns 审查相关处理器表
56
95
  */
57
96
  export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandlerMap> {
58
- /** @private @author ddj 2026年10月28号 @param sessionId 请求作用域 @returns 已迁移工作区(允许跨实例冷读) */
59
- async function workspace(sessionId: string): Promise<string> {
97
+ /**
98
+ * @private
99
+ * @author ddj 2026年10月31号
100
+ * @description: 解析会话作用域,并标明本端是否持有该会话的活跃实例。
101
+ * 工作区一律由 host 解析,绝不采信调用方 cwd;冷读作用域只带 cwd,不带会话实例。
102
+ * @param sessionId 请求作用域
103
+ * @returns 已迁移工作区与活跃标记
104
+ * @throws 会话不存在、会话无工作区或活跃库迁移未完成
105
+ */
106
+ async function scopeOf(sessionId: string): Promise<{ cwd: string; live: boolean }> {
60
107
  const scope = await resolveScope(ctx, sessionId)
61
108
  if ('err' in scope) throw new Error('会话不存在或没有工作区')
109
+
62
110
  await bucketOf(registry, ctx, scope.cwd)
63
- return scope.cwd
111
+ return { cwd: scope.cwd, live: !!scope.session }
64
112
  }
65
113
 
66
- /** @private @author ddj 2026年10月28号 @param sessionId 请求作用域 @returns 本端 live 会话的工作区;冷读作用域一律拒绝写入 */
67
- async function liveWorkspace(sessionId: string): Promise<string> {
68
- const scope = await resolveScope(ctx, sessionId)
69
- if ('err' in scope || !scope.session) throw new Error('会话不存在或没有工作区')
70
- await bucketOf(registry, ctx, scope.cwd)
71
- return scope.cwd
114
+ /**
115
+ * @private
116
+ * @author ddj 2026年10月31号
117
+ * @description: 本端是否持有该会话的活跃实例(纯诊断用,不解析工作区)。
118
+ * @param sessionId 请求作用域
119
+ * @returns 本端加载了该会话时为 true
120
+ */
121
+ function liveOf(sessionId: string): boolean {
122
+ return !!sessionOf(ctx, sessionId)
123
+ }
124
+
125
+ /**
126
+ * @private
127
+ * @author ddj 2026年10月31号
128
+ * @description: 按本端是否活跃覆写面板能力位。活跃端保持数据库结论;
129
+ * 冷端关闭依赖会话的操作(Undo、待审判定)并给出跨端原因,Keep 仍按数据库结论放行。
130
+ * @param capability 数据库推导的能力位
131
+ * @param live 本端是否持有活跃会话
132
+ * @param pending 是否为待审投影条目
133
+ * @returns 面板可用的能力位
134
+ */
135
+ function liveCapabilityOf(
136
+ capability: { keep: boolean; undo: boolean; reason: string },
137
+ live: boolean,
138
+ pending: boolean,
139
+ ): { keep: boolean; undo: boolean; reason: string; live: boolean } {
140
+ if (live) return { ...capability, live: true }
141
+ if (pending) return { keep: false, undo: false, reason: PENDING_NEEDS_LIVE, live: false }
142
+ return { ...capability, undo: false, live: false, reason: capability.undo ? UNDO_NEEDS_LIVE : capability.reason }
72
143
  }
73
144
 
74
145
  /**
@@ -81,13 +152,19 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
81
152
  * @returns 待审页(失败为空页)
82
153
  */
83
154
  function pendingPageOf(cwd: string, sessionId: string): PendingPage {
84
- try { return loadPage(cwd, sessionId) }
85
- catch { return { items: [], pending: 0, nextCursor: null, gaps: [] } }
155
+ try {
156
+ const page = loadPage(cwd, sessionId)
157
+ // 已结算(老文本与新文本一致)的投影没有净变化可审:留着会顶掉同路径的官方待审记录,
158
+ // 让差异栏选中一条 Keep 不可用的条目,表现为「按钮亮着却点不动」。
159
+ return page.items.some((item) => item.settled === true)
160
+ ? { ...page, items: page.items.filter((item) => item.settled !== true) }
161
+ : page
162
+ } catch { return { items: [], pending: 0, nextCursor: null, gaps: [] } }
86
163
  }
87
164
 
88
165
  return {
89
166
  'edrv.review.list': async (args) => {
90
- const cwd = await workspace(args.sessionId)
167
+ const { cwd } = await scopeOf(args.sessionId)
91
168
  // 批量通道要的是一次性完整快照:分页口径的 pending 只是页内展示计数,不能当完整性不变式
92
169
  if (args.all && !args.history && args.path === undefined) {
93
170
  return batchSnapshot(cwd, pendingPageOf(cwd, args.sessionId))
@@ -106,43 +183,69 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
106
183
  return { ok: true, page: { ...official, items: [...own, ...history] } }
107
184
  }
108
185
  // 待审投影就位后由它承接被覆盖路径,官方旧行让位,同一路径只保留一个权威
109
- return { ok: true, page: mergedPage(official, pending, cwd) }
186
+ const merged = mergedPage(official, pending, cwd)
187
+ log.debug(`[review] list live=${liveOf(args.sessionId) ? 1 : 0} path=${args.path ?? '(all)'}`
188
+ + ` items=${merged.items.length} pending=${merged.pending} covered=${merged.overview?.coveredPaths.length ?? -1}`)
189
+ return { ok: true, page: merged }
110
190
  },
111
191
  'edrv.review.detail': async (args) => {
112
- const cwd = await workspace(args.sessionId)
192
+ const scope = await scopeOf(args.sessionId)
193
+ const cwd = scope.cwd
113
194
  if (args.id.startsWith(PENDING_PREFIX)) {
114
195
  const detail = loadDetail(cwd, args.id.slice(PENDING_PREFIX.length))
115
196
  if (!detail) return { ok: false, error: '待审条目不存在' }
116
- return { ok: true, detail: pendingDetailOf(detail, new Date(detail.item.updatedAt).toISOString(), cwd) }
197
+
198
+ const mapped = pendingDetailOf(detail, new Date(detail.item.updatedAt).toISOString(), cwd)
199
+ // 待审判定走本地待审库,跨端会与另一端 agent 的写入抢同一份 JSON,故冷端一律关闭
200
+ return { ok: true, detail: { ...mapped, capability: liveCapabilityOf(mapped.capability, scope.live, true) } }
117
201
  }
202
+
118
203
  const detail = reviewDetail(cwd, args.id)
119
- return detail ? { ok: true, detail } : { ok: false, error: '官方审查记录不存在' }
204
+ if (!detail) {
205
+ log.debug(`[review] detail live=${scope.live ? 1 : 0} id=${args.id} found=0`)
206
+ return { ok: false, error: '官方审查记录不存在' }
207
+ }
208
+ // Keep 只写共享库,冷端放行;Undo 依赖会话证据与源文件写回,冷端关闭
209
+ const capability = await absenceCapability(ctx, cwd, detail, liveCapabilityOf(detail.capability, scope.live, false))
210
+ log.debug(`[review] detail live=${scope.live ? 1 : 0} id=${args.id} found=1`
211
+ + ` keep=${capability.keep ? 1 : 0} undo=${capability.undo ? 1 : 0}`)
212
+ return { ok: true, detail: { ...detail, capability } }
120
213
  },
121
214
  'edrv.review.decide': async (args) => {
122
- // 写类操作要求 live 会话:避免两端并发写同一工作区审查库,回滚也依赖本端会话内容
123
- const cwd = await liveWorkspace(args.sessionId)
124
- if (args.id.startsWith(PENDING_PREFIX)) {
125
- // 待审条目走累计基线:Keep 零写盘,Revert 写回并读回校验
126
- const result = await applyDecide(ctx.get('fs'), cwd, {
127
- sessionId: args.sessionId,
128
- id: args.id.slice(PENDING_PREFIX.length),
129
- revision: args.revision,
130
- operationId: args.operationId,
131
- decision: args.decision,
132
- scope: args.scope === 'hunk' ? 'block' : 'file',
133
- ...(args.blockId === undefined ? {} : { blockId: args.blockId }),
134
- })
135
- if (!result.ok) return { ok: false, error: result.reason }
136
- return { ok: true, revision: result.flags.revision }
137
- }
215
+ // Keep 只写共享审查库(CAS + operationId 幂等 + WAL),冷端也放行;
216
+ // Undo 与待审判定依赖本端会话内容或本地待审库,冷端返回可读原因而不是 500。
138
217
  try {
139
- const result = args.decision === 'rejected'
140
- ? await undoReview(ctx, sessionOf(ctx, args.sessionId), cwd, args)
141
- : decideReview(cwd, args)
218
+ const scope = await scopeOf(args.sessionId)
219
+ if (args.id.startsWith(PENDING_PREFIX)) {
220
+ if (!scope.live) return { ok: false, error: PENDING_NEEDS_LIVE }
221
+ // 待审条目走累计基线:Keep 零写盘,Revert 写回并读回校验
222
+ const result = await applyDecide(ctx.get('fs'), scope.cwd, {
223
+ sessionId: args.sessionId,
224
+ id: args.id.slice(PENDING_PREFIX.length),
225
+ revision: args.revision,
226
+ operationId: args.operationId,
227
+ decision: args.decision,
228
+ scope: args.scope === 'hunk' ? 'block' : 'file',
229
+ ...(args.blockId === undefined ? {} : { blockId: args.blockId }),
230
+ })
231
+ if (!result.ok) return { ok: false, error: result.reason }
232
+ return { ok: true, revision: result.flags.revision }
233
+ }
234
+
235
+ if (args.decision === 'rejected') {
236
+ if (!scope.live) return { ok: false, error: UNDO_NEEDS_LIVE }
237
+ const result = await undoReview(ctx, sessionOf(ctx, args.sessionId), scope.cwd, args)
238
+ return { ok: true, ...result }
239
+ }
240
+
241
+ const result = decideReview(scope.cwd, args)
242
+ if (!scope.live) log.info(`[review] 冷端 Keep 官方记录 ${args.id}`)
142
243
  return { ok: true, ...result }
143
244
  } catch (error) {
144
245
  // Guard failures are review results, not transport failures: preserve their reason.
145
- return { ok: false, error: error instanceof Error ? error.message : String(error) }
246
+ const message = error instanceof Error ? error.message : String(error)
247
+ log.warn(`[review] decide 失败 id=${args.id} decision=${args.decision}: ${message}`)
248
+ return { ok: false, error: message }
146
249
  }
147
250
  },
148
251
  }
package/src/rpc.ts CHANGED
@@ -63,7 +63,7 @@ import { settingsDocPath } from './settingsDocPath.js'
63
63
  import { createTextPages } from './textPages.js'
64
64
  import { reviewHandlers } from './reviewRpc.js'
65
65
  import { pendingHandlers } from './pendingRpc.js'
66
- import { fileAbsent, missingReview, missingDecide, missingPath } from './missingReview.js'
66
+ import { fileAbsent, missingReview, missingDecide, missingPath, refusalCodeOf } from './missingReview.js'
67
67
  import { pendingCovers } from './pendingCompat.js'
68
68
 
69
69
  /** Process-local cursor signer; reads must stay on the Host filesystem capability. */
@@ -733,7 +733,11 @@ export function buildHandlers(
733
733
  const bucket = await bucketOf(registry, ctx, sc.cwd)
734
734
  if (!await fileAbsent(ctx, sc.cwd, args.path)) return { ok: true, review: null }
735
735
  return { ok: true, review: await missingReview(ctx, sc.session, sc.cwd, bucket, args.path) }
736
- } catch (error) { return { ok: false, error: String(error) } }
736
+ } catch (error) {
737
+ // 可预期的拒绝带稳定错误码:客户端据此显示「已删除」终态,而不是把错误原文当加载失败重试
738
+ const code = refusalCodeOf(error)
739
+ return { ok: false, error: String(error), ...(code ? { code } : {}) }
740
+ }
737
741
  },
738
742
  'edrv.missingDecide': async (args) => {
739
743
  const sc = await requireSession(ctx, args.sessionId)
@@ -56,7 +56,8 @@ export interface ReviewDetail {
56
56
  diff: OfficialDiff
57
57
  capturedAt: string
58
58
  blocks: import('./reviewRegions.js').ReviewBlockState[]
59
- capability: { keep: boolean; undo: boolean; reason: string }
59
+ /** live 缺省视为未知:只有 host 明确给出 false 时才代表会话不在本端活跃。 */
60
+ capability: { keep: boolean; undo: boolean; reason: string; live?: boolean }
60
61
  }
61
62
  export interface CaptureGap {
62
63
  sessionId: string
package/src/shared/rpc.ts CHANGED
@@ -736,10 +736,13 @@ export interface RpcOkMap {
736
736
  'edrv.uiState.set': object
737
737
  }
738
738
 
739
+ /** 缺失文件预览的稳定拒绝码:客户端据此显示「已删除」终态,而不是把错误原文当加载失败重试。 */
740
+ export type MissingRefusal = 'official-covered' | 'official-decided' | 'no-records' | 'no-evidence' | 'too-large'
741
+
739
742
  /** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
740
743
  export type RpcResult<M extends RpcMethod> =
741
744
  | ({ ok: true } & RpcOkMap[M])
742
- | { ok: false; error: string; code?: 'file-too-large' | 'file-missing'; reason?: Extract<import('./textPages.js').TextPageResult, { ok: false }>['reason'] }
745
+ | { ok: false; error: string; code?: 'file-too-large' | 'file-missing' | MissingRefusal; reason?: Extract<import('./textPages.js').TextPageResult, { ok: false }>['reason'] }
743
746
 
744
747
  /** 每个方法的 handler 签名(host 分发表与 client 类型检查共用)。 */
745
748
  export type RpcHandler<M extends RpcMethod> = (