dsh-vscode-mode 0.25.1 → 0.26.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.
@@ -7,8 +7,13 @@ import type { ReviewDetail, ReviewItem, ReviewPage } from './shared/review.js'
7
7
  import type { PendingDetail, PendingItem, PendingPage } from './shared/pendingReview.js'
8
8
  import { pathKeyFor } from './shared/pathKey.js'
9
9
 
10
- /** 进程内"已被待审接管"的路径集合;旧写入口守卫只读它,绝不碰数据库。 */
11
- const coveredPaths = new Set<string>()
10
+ /**
11
+ * 进程内"已被待审接管"的路径集合;旧写入口守卫只读它,绝不碰数据库。
12
+ * 按会话登记:待审列表按会话可见,守卫必须同口径,否则会出现"拦得住、看不见"的死锁。
13
+ * 无会话归属的登记落在 workspace 集合(兼容只有工作区+路径的旧用法)。
14
+ */
15
+ const coveredBySession = new Map<string, Set<string>>()
16
+ const coveredWorkspace = new Set<string>()
12
17
 
13
18
  /** 待审条目在面板列表里的 id 前缀,用于把请求路由回待审内核。 */
14
19
  export { PENDING_PREFIX } from './shared/pendingReview.js'
@@ -138,28 +143,94 @@ export function pendingDetailOf(detail: PendingDetail, capturedAt: string, cwd?:
138
143
  }
139
144
 
140
145
  /**
141
- * @author ddj 2026年10月29号
146
+ * @author ddj 2026年11月3号
147
+ * @description: 覆盖登记键:工作区与路径各自归一化后再拼接,避免同一文件多种写法登记成两条。
148
+ * @param cwd 工作区
149
+ * @param path 路径
150
+ * @returns 覆盖登记键
151
+ */
152
+ function coverKey(cwd: string, path: string): string {
153
+ return `${pathKey(cwd)}|${pathKey(path)}`
154
+ }
155
+
156
+ /**
157
+ * @author ddj 2026年11月3号
158
+ * @description: 取某会话的覆盖集合;首次登记时就地建集合,避免调用方判空。
159
+ * @param sessionId 会话 id
160
+ * @returns 该会话的覆盖键集合
161
+ */
162
+ function sessionCovers(sessionId: string): Set<string> {
163
+ const existing = coveredBySession.get(sessionId)
164
+ if (existing) return existing
165
+ const created = new Set<string>()
166
+ coveredBySession.set(sessionId, created)
167
+ return created
168
+ }
169
+
170
+ /**
171
+ * @author ddj 2026年11月3号
142
172
  * @description: 记住/忘记"该路径由待审投影接管"。进程内集合,绝不在旧写入口上查库(避免撞上写事务锁)。
143
173
  * @param cwd 工作区
144
174
  * @param path 路径
145
175
  * @param covered 是否接管
176
+ * @param sessionId 会话 id;缺省落到无归属的工作区集合
177
+ * @returns 无
178
+ */
179
+ export function trackPendingCover(cwd: string, path: string, covered: boolean, sessionId?: string): void {
180
+ const key = coverKey(cwd, path)
181
+ const target = sessionId ? sessionCovers(sessionId) : coveredWorkspace
182
+ if (covered) target.add(key)
183
+ else target.delete(key)
184
+ }
185
+
186
+ /**
187
+ * @author ddj 2026年11月3号
188
+ * @description: 用一次列表结果整体重建某会话的接管集合:条目被移除或结案后自动失效,不再残留登记。
189
+ * @param cwd 工作区
190
+ * @param sessionId 会话 id
191
+ * @param paths 本会话当前可见的待审条目路径
146
192
  * @returns 无
147
193
  */
148
- export function trackPendingCover(cwd: string, path: string, covered: boolean): void {
149
- const key = `${pathKey(cwd)}|${pathKey(path)}`
150
- if (covered) coveredPaths.add(key)
151
- else coveredPaths.delete(key)
194
+ export function setPendingCovers(cwd: string, sessionId: string, paths: readonly string[]): void {
195
+ const next = new Set<string>()
196
+ for (const path of paths) {
197
+ if (path) next.add(coverKey(cwd, path))
198
+ }
199
+ coveredBySession.set(sessionId, next)
200
+ }
201
+
202
+ /**
203
+ * @author ddj 2026年11月3号
204
+ * @description: 找出接管某路径的会话,用于把"谁在管"写进提示文案;仅读内存,不查库。
205
+ * @param cwd 工作区
206
+ * @param path 路径
207
+ * @returns 首个接管会话 id;无人接管返回 null
208
+ */
209
+ export function pendingCoverOwner(cwd: string, path: string): string | null {
210
+ const key = coverKey(cwd, path)
211
+ for (const [sessionId, set] of coveredBySession) {
212
+ if (set.has(key)) return sessionId
213
+ }
214
+ return null
152
215
  }
153
216
 
154
217
  /**
155
218
  * @author ddj 2026年10月29号
156
219
  * @description: 判断某路径是否已由待审投影接管;仅读进程内集合,不产生任何 IO。
220
+ * 给 sessionId 时按该会话的可见范围判定(列表看不到的接管不拦旧入口),缺省按全工作区并集判定。
157
221
  * @param cwd 工作区
158
222
  * @param path 请求路径
223
+ * @param sessionId 会话 id;缺省按并集判定
159
224
  * @returns 是否已被接管
160
225
  */
161
- export function pendingCovers(cwd: string, path: string): boolean {
162
- return coveredPaths.has(`${pathKey(cwd)}|${pathKey(path)}`)
226
+ export function pendingCovers(cwd: string, path: string, sessionId?: string): boolean {
227
+ const key = coverKey(cwd, path)
228
+ if (coveredWorkspace.has(key)) return true
229
+ if (sessionId) return coveredBySession.get(sessionId)?.has(key) === true
230
+ for (const set of coveredBySession.values()) {
231
+ if (set.has(key)) return true
232
+ }
233
+ return false
163
234
  }
164
235
 
165
236
  /**
package/src/pendingRpc.ts CHANGED
@@ -5,7 +5,7 @@ import { resolveScope, sessionOf } from './registry.js'
5
5
  import type { Registry } from './registry.js'
6
6
  import { bucketOf } from './registry.js'
7
7
  import { readText } from './pending/capture.js'
8
- import { pendingRawId, trackPendingCover } from './pendingCompat.js'
8
+ import { pendingRawId, setPendingCovers, trackPendingCover } from './pendingCompat.js'
9
9
  import { loadEntries, saveEntry } from './pending/persist.js'
10
10
  import { applyDecide, importFromVcs, itemOf, loadDetail, loadPage, refreshed, removeEntry, undoDecide } from './pending/store.js'
11
11
  import { addedFiles } from './reviewDb.js'
@@ -85,8 +85,8 @@ function readHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
85
85
  const cwd = await kit.read(args.sessionId)
86
86
  // 与官方入口同口径:本会话新建的文件基线置空,列表里的增删行数才等于整文件行数
87
87
  const page = loadPage(cwd, args.sessionId, addedFiles(cwd, args.sessionId))
88
- // 列表即登记接管关系;旧写入口守卫之后只读内存集合,不再产生数据库读
89
- for (const item of page.items) trackPendingCover(cwd, item.path, true)
88
+ // 列表即登记接管关系:整集重建本会话的登记,条目被移出列表后自动失效
89
+ setPendingCovers(cwd, args.sessionId, page.items.map((item) => item.path))
90
90
  return { ok: true, page }
91
91
  },
92
92
  'edrv.pending.detail': async (args: PendingRequest & { id: string }) => {
@@ -124,8 +124,8 @@ function decideHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
124
124
  const id = pendingRawId(args.id)
125
125
  const path = loadEntries(cwd).find((stored) => stored.entry.id === id)?.entry.path
126
126
  const removed = removeEntry(cwd, id, args.revision)
127
- // 移除后该路径不再由待审投影接管,旧入口守卫随之放行
128
- if (removed && path) trackPendingCover(cwd, path, false)
127
+ // 移除后该路径不再由待审投影接管,旧入口守卫随之放行(仅注销本会话的登记)
128
+ if (removed && path) trackPendingCover(cwd, path, false, args.sessionId)
129
129
  return { ok: true, removed }
130
130
  },
131
131
  }
@@ -168,7 +168,8 @@ function refreshHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
168
168
  try {
169
169
  const shell = shellOf(raw, policy)
170
170
  const result = await importFromVcs(shell, cwd, args.sessionId, { includeUntracked: args.includeUntracked, path: args.path })
171
- for (const item of loadPage(cwd, args.sessionId).items) trackPendingCover(cwd, item.path, true)
171
+ const page = loadPage(cwd, args.sessionId)
172
+ setPendingCovers(cwd, args.sessionId, page.items.map((item) => item.path))
172
173
  return { ok: true, imported: result.imported, changes: result.changes }
173
174
  } catch (error) {
174
175
  return { ok: false, error: failureOf(error) }
package/src/reviewDb.ts CHANGED
@@ -285,6 +285,38 @@ export function reviewDetail(cwd: string, id: string): ReviewDetail | null {
285
285
  : '该记录缺少可验证的原始字节或受控写回能力;仍可显示差异和 Keep,不能猜测历史全文执行 Undo。' } }
286
286
  }
287
287
 
288
+ /**
289
+ * @private
290
+ * @author ddj 2026年11月03号
291
+ * @description: 把一个文件的待处理差异块整体置为目标决策,块级判定仍走 changeBlocks 的 hunk 分支。
292
+ * @param db 判定事务连接
293
+ * @param id 文件身份
294
+ * @param decision 目标决策
295
+ * @returns 无
296
+ */
297
+ function markBlocks(db: DatabaseSync, id: string, decision: DecideReview['decision']): void {
298
+ db.prepare("UPDATE review_blocks SET decision=? WHERE file_id=? AND decision='pending'").run(decision, id)
299
+ }
300
+
301
+ /**
302
+ * @private
303
+ * @author ddj 2026年11月03号
304
+ * @description: 按块决定聚合回写文件决定:仍含 pending 即 pending,多值即 mixed,单一值取该值。
305
+ * @param db 判定事务连接
306
+ * @param id 文件身份
307
+ * @param fallback 没有任何块时的兜底决策
308
+ * @returns 无
309
+ */
310
+ function aggregateDecision(db: DatabaseSync, id: string, fallback: string): void {
311
+ const states = db.prepare('SELECT DISTINCT decision FROM review_blocks WHERE file_id=?').all(id) as Row[]
312
+ const decisions = states.map((row) => String(row.decision))
313
+ let aggregate = fallback
314
+ if (decisions.includes('pending')) aggregate = 'pending'
315
+ else if (decisions.length > 1) aggregate = 'mixed'
316
+ else if (decisions.length === 1) aggregate = decisions[0]
317
+ db.prepare('UPDATE review_files SET decision=? WHERE id=?').run(aggregate, id)
318
+ }
319
+
288
320
  /** @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. */
289
321
  export function changeBlocks(db: DatabaseSync, request: DecideReview): void {
290
322
  if (request.scope === 'hunk') {
@@ -292,15 +324,9 @@ export function changeBlocks(db: DatabaseSync, request: DecideReview): void {
292
324
  .run(request.decision, request.id, request.blockId ?? '')
293
325
  if (changed.changes !== 1) throw new Error('差异块不存在或已处理')
294
326
  } else {
295
- db.prepare("UPDATE review_blocks SET decision=? WHERE file_id=? AND decision='pending'").run(request.decision, request.id)
327
+ markBlocks(db, request.id, request.decision)
296
328
  }
297
- const states = db.prepare('SELECT DISTINCT decision FROM review_blocks WHERE file_id=?').all(request.id) as Row[]
298
- const decisions = states.map((row) => String(row.decision))
299
- let aggregate = request.decision as string
300
- if (decisions.includes('pending')) aggregate = 'pending'
301
- else if (decisions.length > 1) aggregate = 'mixed'
302
- else if (decisions.length === 1) aggregate = decisions[0]
303
- db.prepare('UPDATE review_files SET decision=? WHERE id=?').run(aggregate, request.id)
329
+ aggregateDecision(db, request.id, request.decision)
304
330
  }
305
331
 
306
332
  /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param request Frozen identity/revision. @returns Idempotent decision; rejection never guesses a target. */
@@ -334,3 +360,88 @@ export function decideReview(cwd: string, request: DecideReview): { revision: nu
334
360
  throw error
335
361
  }
336
362
  }
363
+
364
+ /** 官方批量 Keep 的一条目标:id/seq 参与幂等编码,revision 只用于审计。 */
365
+ export interface OfficialKeepItem {
366
+ id: string
367
+ seq: number
368
+ revision: number
369
+ operationId: string
370
+ }
371
+ /** 官方批量 Keep 的结果;skipped 只记录条目级原因,段级异常由调用方捕获。 */
372
+ export interface OfficialKeepOutcome {
373
+ paths: string[]
374
+ skipped: { id: string; reason: string }[]
375
+ }
376
+
377
+ /**
378
+ * @private
379
+ * @author ddj 2026年11月03号
380
+ * @description: 取当前仍待审的官方记录摘要,作为批量写入的权威依据(不用客户端快照值)。
381
+ * @param db 判定事务连接
382
+ * @returns id → 库内 seq/revision/identity
383
+ */
384
+ function pendingRows(db: DatabaseSync): Map<string, { seq: number; revision: number; identity: string }> {
385
+ const rows = db.prepare("SELECT id,seq,revision,identity FROM review_files WHERE current=1 AND decision='pending'").all() as Row[]
386
+ const map = new Map<string, { seq: number; revision: number; identity: string }>()
387
+ for (const row of rows) map.set(String(row.id), { seq: Number(row.seq), revision: Number(row.revision), identity: String(row.identity) })
388
+ return map
389
+ }
390
+
391
+ /**
392
+ * @public
393
+ * @author ddj 2026年11月03号
394
+ * @description: 一次事务内批量保留官方待审记录。写入条件取库内当前 revision,客户端的 seq/revision 只用于派生操作号,
395
+ * 因此快照漂移不会让整批中止;已结案、有尚待核对 Undo、操作号冲突的条目只跳过自己。
396
+ * @param cwd 工作区
397
+ * @param items 待保留条目(含派生好的 operationId)
398
+ * @returns 成功保留的 identity 列表与跳过明细
399
+ * @throws 事务级异常(SQL/程序错误)整体回滚后抛出
400
+ */
401
+ export function keepOfficialAll(cwd: string, items: OfficialKeepItem[]): OfficialKeepOutcome {
402
+ const db = reviewDb(cwd)
403
+ const rows = pendingRows(db)
404
+ const outcome: OfficialKeepOutcome = { paths: [], skipped: [] }
405
+ const keep = db.prepare(`UPDATE review_files SET decision='accepted',revision=revision+1
406
+ WHERE id=? AND seq=? AND revision=? AND current=1 AND decision='pending'`)
407
+ const prepared = db.prepare("SELECT 1 FROM review_ops WHERE state='prepared' AND json_extract(request,'$.id')=?")
408
+
409
+ db.exec('BEGIN IMMEDIATE')
410
+ try {
411
+ for (const item of items) {
412
+ const encoded = JSON.stringify({ id: item.id, seq: item.seq })
413
+ const previous = db.prepare('SELECT request,result FROM review_ops WHERE operation_id=?').get(item.operationId) as Row | undefined
414
+
415
+ // 同一批次重放(客户端重试)按操作号幂等返回,不重复推进 revision
416
+ if (previous) {
417
+ if (previous.request !== encoded || !previous.result) outcome.skipped.push({ id: item.id, reason: '该条目已由其他操作处理' })
418
+ else outcome.paths.push(String((JSON.parse(String(previous.result)) as { identity: string }).identity))
419
+ continue
420
+ }
421
+
422
+ const row = rows.get(item.id)
423
+ if (!row || prepared.get(item.id)) {
424
+ outcome.skipped.push({ id: item.id, reason: row ? '该文件有尚待核对的 Undo,暂不能 Keep' : '已结案或不再是待审记录' })
425
+ continue
426
+ }
427
+
428
+ // 权威条件用库内 revision:客户端没看见的中间版本也能安全保留,落笔后仍由 CAS 兜住并发
429
+ if (keep.run(item.id, row.seq, row.revision).changes !== 1) {
430
+ outcome.skipped.push({ id: item.id, reason: '审查版本冲突,请刷新后重试' })
431
+ continue
432
+ }
433
+
434
+ markBlocks(db, item.id, 'accepted')
435
+ aggregateDecision(db, item.id, 'accepted')
436
+ db.prepare("INSERT INTO review_ops VALUES(?,?,'committed',?,NULL)")
437
+ .run(item.operationId, encoded, JSON.stringify({ identity: row.identity }))
438
+ outcome.paths.push(row.identity)
439
+ rows.delete(item.id)
440
+ }
441
+ db.exec('COMMIT')
442
+ return outcome
443
+ } catch (error) {
444
+ if (db.isTransaction) db.exec('ROLLBACK')
445
+ throw error
446
+ }
447
+ }
package/src/reviewRpc.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /** Session-scoped official review RPC. @author ddj 2026年10月26号 */
2
+ import { createHash } from 'node:crypto'
2
3
  import type { RpcHandlerMap } from './shared/rpc.js'
3
- import type { ReviewDetail, ReviewItem, ReviewPage } from './shared/review.js'
4
+ import type { KeepAllItem, KeepAllResult, ReviewDetail, ReviewItem, ReviewPage } from './shared/review.js'
4
5
  import type { PendingPage } from './shared/pendingReview.js'
5
- import { addedFiles, decideReview, reviewDetail, reviewPage } from './reviewDb.js'
6
+ import { addedFiles, decideReview, keepOfficialAll, reviewDetail, reviewPage } from './reviewDb.js'
6
7
  import { undoReview } from './reviewUndo.js'
7
- import { applyDecide, loadDetail, loadPage } from './pending/store.js'
8
+ import { applyDecide, keepAll, loadDetail, loadPage } from './pending/store.js'
8
9
  import { PENDING_PREFIX, mergedPage, pendingDetailOf, pendingItemOf, pendingSamePath } from './pendingCompat.js'
9
10
  import { bucketOf, resolveScope, sessionOf } from './registry.js'
10
11
  import type { Registry } from './registry.js'
@@ -164,6 +165,88 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
164
165
  } catch { return { items: [], pending: 0, nextCursor: null, gaps: [] } }
165
166
  }
166
167
 
168
+ /**
169
+ * @private
170
+ * @author ddj 2026年11月03号
171
+ * @description: 按批次与条目派生幂等操作号:同一批次重放复用同一结果,不重复推进 revision。
172
+ * @param batchId 客户端批次身份
173
+ * @param id 条目身份
174
+ * @param seq 事件序号
175
+ * @returns 64 位 hex 操作号
176
+ */
177
+ function operationOf(batchId: string, id: string, seq: number): string {
178
+ return createHash('sha256').update(`${batchId}|${id}|${seq}`).digest('hex')
179
+ }
180
+
181
+ /**
182
+ * @private
183
+ * @author ddj 2026年11月03号
184
+ * @description: 取条目展示名,缺失时退回身份本身。
185
+ * @param items 本批条目
186
+ * @param id 条目身份
187
+ * @returns 展示名
188
+ */
189
+ function displayOf(items: KeepAllItem[], id: string): string {
190
+ return items.find((item) => item.id === id)?.display ?? id
191
+ }
192
+
193
+ /**
194
+ * @private
195
+ * @author ddj 2026年11月03号
196
+ * @description: 官方段的批量保留。段级异常只记进 result.error,不取消待审段。
197
+ * @param cwd 工作区
198
+ * @param batchId 批次身份
199
+ * @param items 官方条目
200
+ * @param result 汇总结果
201
+ * @returns 无
202
+ */
203
+ function keepOfficialStage(cwd: string, batchId: string, items: KeepAllItem[], result: KeepAllResult): void {
204
+ try {
205
+ const outcome = keepOfficialAll(cwd, items.map((item) => ({
206
+ id: item.id, seq: item.seq, revision: item.revision, operationId: operationOf(batchId, item.id, item.seq),
207
+ })))
208
+ result.completed += outcome.paths.length
209
+ result.paths.push(...outcome.paths)
210
+ for (const skip of outcome.skipped) result.skipped.push({ id: skip.id, display: displayOf(items, skip.id), reason: skip.reason })
211
+ } catch (error) {
212
+ const message = error instanceof Error ? error.message : String(error)
213
+ log.warn(`[review] keepAll 官方段失败: ${message}`)
214
+ if (!result.error) result.error = message
215
+ }
216
+ }
217
+
218
+ /**
219
+ * @private
220
+ * @author ddj 2026年11月03号
221
+ * @description: 待审段的批量保留。冷端只跳过待审条目(判定需要活跃会话),官方条目不受影响。
222
+ * @param scope 已解析作用域
223
+ * @param batchId 批次身份
224
+ * @param items 待审条目(带 pending 前缀)
225
+ * @param result 汇总结果
226
+ * @returns 无
227
+ */
228
+ async function keepPendingStage(
229
+ scope: { cwd: string; live: boolean }, batchId: string, items: KeepAllItem[], result: KeepAllResult,
230
+ ): Promise<void> {
231
+ if (!scope.live) {
232
+ for (const item of items) result.skipped.push({ id: item.id, display: item.display, reason: '待审条目需要在会话活跃的一端判定' })
233
+ return
234
+ }
235
+ const targets = items.map((item) => ({
236
+ id: item.id.slice(PENDING_PREFIX.length), display: item.display, operationId: operationOf(batchId, item.id, item.seq),
237
+ }))
238
+ try {
239
+ const outcome = await keepAll(scope.cwd, targets)
240
+ result.completed += outcome.kept.length
241
+ for (const entry of outcome.kept) result.paths.push(entry.path)
242
+ for (const skip of outcome.skipped) result.skipped.push({ id: PENDING_PREFIX + skip.id, display: skip.display, reason: skip.reason })
243
+ } catch (error) {
244
+ const message = error instanceof Error ? error.message : String(error)
245
+ log.warn(`[review] keepAll 待审段失败: ${message}`)
246
+ if (!result.error) result.error = message
247
+ }
248
+ }
249
+
167
250
  return {
168
251
  'edrv.review.list': async (args) => {
169
252
  const { cwd } = await scopeOf(args.sessionId)
@@ -252,5 +335,29 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
252
335
  return { ok: false, error: message }
253
336
  }
254
337
  },
338
+ 'edrv.review.keepAll': async (args) => {
339
+ // 批量 Keep 只写共享审查库与待审库,不写源文件;冷端只影响待审条目,官方条目照常保留。
340
+ try {
341
+ const scope = await scopeOf(args.sessionId)
342
+ const items = Array.isArray(args.items) ? args.items : []
343
+ if (!items.length) return { ok: false, error: '没有可保留的待审记录' }
344
+ if (items.length > BATCH_LIMIT) {
345
+ return { ok: false, error: `待审记录过多(${items.length} 条,上限 ${BATCH_LIMIT}),请先分批处理后重试` }
346
+ }
347
+
348
+ const result: KeepAllResult = { completed: 0, total: items.length, paths: [], skipped: [], error: '' }
349
+ const official = items.filter((item) => !item.id.startsWith(PENDING_PREFIX))
350
+ const pending = items.filter((item) => item.id.startsWith(PENDING_PREFIX))
351
+ if (official.length) keepOfficialStage(scope.cwd, args.batchId, official, result)
352
+ if (pending.length) await keepPendingStage(scope, args.batchId, pending, result)
353
+ log.info(`[review] keepAll live=${scope.live ? 1 : 0} total=${result.total}`
354
+ + ` kept=${result.completed} skipped=${result.skipped.length}`)
355
+ return { ok: true, result }
356
+ } catch (error) {
357
+ const message = error instanceof Error ? error.message : String(error)
358
+ log.warn(`[review] keepAll 失败: ${message}`)
359
+ return { ok: false, error: message }
360
+ }
361
+ },
255
362
  }
256
363
  }
package/src/rpc.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  archiveRecords,
16
16
  archiveCanceled,
17
17
  autoArchiveStale,
18
+ retireCovered,
18
19
  policyOf,
19
20
  resolveTarget,
20
21
  resolveTargetIn,
@@ -108,15 +109,16 @@ function recView(record: DiffRecord, ws: RemoteWs | null = null): RecordView {
108
109
  }
109
110
 
110
111
  /**
111
- * 旧写入口守卫:路径已由待审累计基线接管时一律拒绝,避免两套权威同时写同一文件。
112
- * @author ddj 2026年10月29号
113
- * @param cwd 工作区
112
+ * 旧写入口守卫:本会话可见的待审累计基线接管了该路径时一律拒绝,避免两套权威同时写同一文件。
113
+ * 只按会话可见范围判定:别的会话在管、本会话看不见的条目不能拦人,否则用户无从处理。
114
+ * @author ddj 2026年11月3号
115
+ * @param sc 会话与工作区
114
116
  * @param path 请求涉及的路径(可为空表示无法定位)
115
117
  * @returns 拒绝原因,未接管时为 null
116
118
  */
117
- function legacyBlocked(cwd: string, path: string | undefined): string | null {
119
+ function legacyBlocked(sc: { session: Session; cwd: string }, path: string | undefined): string | null {
118
120
  if (!path) return null
119
- if (!pendingCovers(cwd, path)) return null
121
+ if (!pendingCovers(sc.cwd, path, sc.session.id)) return null
120
122
  return '该文件已由待审累计基线接管,请使用面板 Keep/Revert(edrv.review.decide)'
121
123
  }
122
124
 
@@ -645,6 +647,8 @@ export function buildHandlers(
645
647
  // #endregion
646
648
  await autoArchiveStale(ctx, sc.session, sc.cwd, bucket)
647
649
  await archiveCanceled(ctx, sc.session, sc.cwd, bucket)
650
+ // 已被待审累计基线接管的路径:旧记录直接结案归档,旧通道的 Keep/Undo 随之从界面消失
651
+ await retireCovered(ctx, sc.session, sc.cwd, bucket)
648
652
  // #region debug log
649
653
  staleMs = performance.now() - staleStart
650
654
  // #endregion
@@ -706,7 +710,7 @@ export function buildHandlers(
706
710
  const sc = await requireSession(ctx, args.sessionId)
707
711
  if ('err' in sc) return { ok: false, error: sc.err }
708
712
  const bucket = await bucketOf(registry, ctx, sc.cwd)
709
- const blocked = legacyBlocked(sc.cwd, bucket.get(args.callId)?.path)
713
+ const blocked = legacyBlocked(sc, bucket.get(args.callId)?.path)
710
714
  if (blocked) return { ok: false, error: blocked }
711
715
  const results = await applyDecisions(ctx, sc.session, sc.cwd, bucket, [{ callId: args.callId, scope: args.scope, hunkIndex: args.hunkIndex, decision: 'accepted' }])
712
716
  const item = results[0]
@@ -717,7 +721,7 @@ export function buildHandlers(
717
721
  const sc = await requireSession(ctx, args.sessionId)
718
722
  if ('err' in sc) return { ok: false, error: sc.err }
719
723
  const bucket = await bucketOf(registry, ctx, sc.cwd)
720
- const blocked = legacyBlocked(sc.cwd, bucket.get(args.callId)?.path)
724
+ const blocked = legacyBlocked(sc, bucket.get(args.callId)?.path)
721
725
  if (blocked) return { ok: false, error: blocked }
722
726
  const results = await applyDecisions(ctx, sc.session, sc.cwd, bucket, [{ callId: args.callId, scope: args.scope, hunkIndex: args.hunkIndex, decision: 'rejected' }])
723
727
  const item = results[0]
@@ -727,7 +731,7 @@ export function buildHandlers(
727
731
  'edrv.missingReview': async (args) => {
728
732
  const sc = await requireSession(ctx, args.sessionId)
729
733
  if ('err' in sc) return { ok: false, error: sc.err }
730
- const blocked = legacyBlocked(sc.cwd, args.path)
734
+ const blocked = legacyBlocked(sc, args.path)
731
735
  if (blocked) return { ok: false, error: blocked }
732
736
  try {
733
737
  const bucket = await bucketOf(registry, ctx, sc.cwd)
@@ -742,7 +746,7 @@ export function buildHandlers(
742
746
  'edrv.missingDecide': async (args) => {
743
747
  const sc = await requireSession(ctx, args.sessionId)
744
748
  if ('err' in sc) return { ok: false, error: sc.err }
745
- const blocked = legacyBlocked(sc.cwd, missingPath(args.token))
749
+ const blocked = legacyBlocked(sc, missingPath(args.token))
746
750
  if (blocked) return { ok: false, error: blocked }
747
751
  try {
748
752
  const bucket = await bucketOf(registry, ctx, sc.cwd)
@@ -754,7 +758,7 @@ export function buildHandlers(
754
758
  if ('err' in sc) return { ok: false, error: sc.err }
755
759
  const bucket = await bucketOf(registry, ctx, sc.cwd)
756
760
  const items = Array.isArray(args.items) ? args.items : []
757
- const blocked = items.map((item) => legacyBlocked(sc.cwd, bucket.get(item.callId)?.path)).find((reason) => reason !== null)
761
+ const blocked = items.map((item) => legacyBlocked(sc, bucket.get(item.callId)?.path)).find((reason) => reason !== null)
758
762
  if (blocked) return { ok: false, error: blocked }
759
763
  const results = await applyDecisions(ctx, sc.session, sc.cwd, bucket, items)
760
764
  return { ok: true, results }
@@ -94,3 +94,24 @@ export interface DecideReview extends ReviewRequest {
94
94
  decision: 'accepted' | 'rejected'
95
95
  }
96
96
  export type ReviewResult<T> = ({ ok: true } & T) | { ok: false; error: string }
97
+ /** 批量 Keep 的一条目标:seq/revision 只参与操作幂等与审计,写入条件以库内此刻状态为准。 */
98
+ export interface KeepAllItem {
99
+ id: string
100
+ display: string
101
+ seq: number
102
+ revision: number
103
+ }
104
+ /** 批量 Keep 中被跳过的条目:跳过不是失败,条目仍留在待审列表里。 */
105
+ export interface KeepAllSkip {
106
+ id: string
107
+ display: string
108
+ reason: string
109
+ }
110
+ /** 批量 Keep 的结果:completed/paths 只计真正落库的条目,error 只记录段级异常。 */
111
+ export interface KeepAllResult {
112
+ completed: number
113
+ total: number
114
+ paths: string[]
115
+ skipped: KeepAllSkip[]
116
+ error: string
117
+ }
package/src/shared/rpc.ts CHANGED
@@ -27,7 +27,7 @@ import { DAP_ACTIONS } from './dap.js'
27
27
  import { transportUrl } from './remoteChannel.js'
28
28
  import type { LoggerLevel } from './logger.js'
29
29
  import type { SvnViewRequest, SvnViewPayload } from './svnExplorer.js'
30
- import type { DecideReview, ReviewDetail, ReviewPage, ReviewRequest } from './review.js'
30
+ import type { DecideReview, KeepAllItem, KeepAllResult, ReviewDetail, ReviewPage, ReviewRequest } from './review.js'
31
31
  import type {
32
32
  PendingDecide, PendingDetail, PendingHistoryMove, PendingImport,
33
33
  PendingItem, PendingPage, PendingRemove, PendingRequest,
@@ -378,6 +378,7 @@ export interface RpcRequestMap {
378
378
  'edrv.review.list': ReviewRequest & { cursor?: number; history?: boolean; path?: string; all?: boolean }
379
379
  'edrv.review.detail': ReviewRequest & { id: string }
380
380
  'edrv.review.decide': DecideReview
381
+ 'edrv.review.keepAll': ReviewRequest & { batchId: string; items: KeepAllItem[] }
381
382
  'edrv.pending.list': PendingRequest
382
383
  'edrv.pending.detail': PendingRequest & { id: string }
383
384
  'edrv.pending.decide': PendingDecide
@@ -563,6 +564,7 @@ export interface RpcOkMap {
563
564
  'edrv.review.list': { page: ReviewPage }
564
565
  'edrv.review.detail': { detail: ReviewDetail }
565
566
  'edrv.review.decide': { revision: number }
567
+ 'edrv.review.keepAll': { result: KeepAllResult }
566
568
  'edrv.pending.list': { page: PendingPage }
567
569
  'edrv.pending.detail': { detail: PendingDetail }
568
570
  'edrv.pending.decide': { item: PendingItem; wrote: boolean }
package/src/store.ts CHANGED
@@ -11,6 +11,8 @@ import { SIDECAR, SIDECAR_ARCHIVE } from './paths.js'
11
11
  import { log } from './log.js'
12
12
  import { debugRecord } from './debugLog.js'
13
13
  import { activeReady, appendArchive, checkActiveSource, commitActive, committedRecord, migrateActive, restoreReverts, type ActiveChange } from './archiveDb.js'
14
+ import { pendingSamePath } from './pendingCompat.js'
15
+ import { loadEntries } from './pending/persist.js'
14
16
 
15
17
  export { SIDECAR, SIDECAR_ARCHIVE }
16
18
  export const READ_CAP = 8 * 1024 * 1024
@@ -303,6 +305,48 @@ export async function autoArchiveStale(ctx: Ctx, session: Session, cwd: string,
303
305
  return stale.length
304
306
  }
305
307
 
308
+ /**
309
+ * @public
310
+ * @author ddj 2026年11月3号
311
+ * 待审累计基线接管某路径后,旧 sidecar 记录不再有独立决策价值:标记 superseded 并归档,
312
+ * 连编辑器的行内 Keep/Undo 一起消失,彻底去掉"旧通道还能点、点了又被守卫拒绝"的双权威残留。
313
+ * 接管是文件级事实,按工作区全部待审条目判定,不跟随"本会话能否看见"漂移。
314
+ * @param ctx DSH 上下文
315
+ * @param session 当前会话
316
+ * @param cwd 工作区
317
+ * @param bucket 活跃记录桶
318
+ * @returns 本次结案记录数
319
+ * @throws 归档失败时恢复内存标志并透传错误
320
+ */
321
+ export async function retireCovered(
322
+ ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>,
323
+ ): Promise<number> {
324
+ const covers = loadEntries(cwd).flatMap((stored) => [stored.entry.path, stored.entry.id]).filter((path) => !!path)
325
+ if (!covers.length) return 0
326
+
327
+ const taken: DiffRecord[] = []
328
+ for (const rec of bucket.values()) {
329
+ if (rec.archived || rec.superseded === true) continue
330
+ if (covers.some((cover) => pendingSamePath(cover, rec.path))) taken.push(rec)
331
+ }
332
+ if (!taken.length) return 0
333
+
334
+ const original = taken.map((rec) => ({ rec, at: rec.at, superseded: rec.superseded }))
335
+ const at = new Date().toISOString()
336
+ for (const rec of taken) { rec.superseded = true; rec.at = at }
337
+ try {
338
+ await archiveRecords(ctx, session, cwd, bucket, taken, '已由待审累计基线接管,旧工具记录不再单独决策')
339
+ } catch (error) {
340
+ for (const item of original) {
341
+ if (item.rec.archived) continue
342
+ item.rec.superseded = item.superseded
343
+ item.rec.at = item.at
344
+ }
345
+ throw error
346
+ }
347
+ return taken.length
348
+ }
349
+
306
350
  /**
307
351
  * @public
308
352
  * @author ddj 2026年10月10号