dsh-vscode-mode 0.21.0 → 0.21.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.
@@ -0,0 +1,127 @@
1
+ /** Missing historical files: frozen, whole-file deletion reviews. */
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { resolve } from 'node:path'
4
+ import type { DiffRecord, MissingReview } from './shared/types.js'
5
+ import type { Ctx, Session } from './store.js'
6
+ import { archiveRecords, policyOf, READ_CAP, resolveTargetIn } from './store.js'
7
+ import { hasOfficial } from './reviewDb.js'
8
+ import { recordResolved } from './model.js'
9
+
10
+ interface FrozenReview {
11
+ view: MissingReview
12
+ cwd: string
13
+ session: Session
14
+ revision: string
15
+ expires: number
16
+ operation?: { id: string; undo: boolean; restored: boolean; done: boolean }
17
+ }
18
+ const frozen = new Map<string, FrozenReview>()
19
+ const locks = new Set<string>()
20
+
21
+ /** @private @author ddj 2026年10月28号 @param target Resolved absolute path. @returns Platform file identity. */
22
+ function fileKey(target: string): string {
23
+ const normalized = resolve(target).replace(/\\/g, '/')
24
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized
25
+ }
26
+
27
+ /** @private @author ddj 2026年10月28号 @param records Ordered records. @returns Full evidence and decision revision, excluding transient conflict flags. */
28
+ function revisionOf(records: DiffRecord[]): string {
29
+ const evidence = records.map(({ callId, path, at, after, hunks, decisions, archived, superseded }) =>
30
+ ({ callId, path, at, after, hunks, decisions, archived, superseded }))
31
+ return createHash('sha256').update(JSON.stringify(evidence)).digest('hex')
32
+ }
33
+
34
+ /** @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
+ async function fileRecords(ctx: Ctx, cwd: string, bucket: Map<string, DiffRecord>, target: string): Promise<DiffRecord[]> {
36
+ const matches: DiffRecord[] = []
37
+ const identity = fileKey(target)
38
+ for (const record of bucket.values()) {
39
+ if (record.superseded) continue
40
+ if (fileKey(await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record)
41
+ }
42
+ return matches.sort((left, right) => left.at.localeCompare(right.at))
43
+ }
44
+
45
+ /** @public @author ddj 2026年10月28号 @param ctx Host. @param cwd Workspace. @param path Requested file. @returns True only for confirmed absence; directories and IO failures are not missing files. */
46
+ export async function fileAbsent(ctx: Ctx, cwd: string, path: string): Promise<boolean> {
47
+ const fs = ctx.get('fs')
48
+ if (!fs) throw new Error('缺少 fs')
49
+ const target = await resolveTargetIn(ctx, cwd, path)
50
+ try { return !(await fs.stat(target)) }
51
+ catch (error) {
52
+ if (['ENOENT', 'FS_NOT_FOUND'].includes(String((error as { code?: string }).code))) return true
53
+ throw error
54
+ }
55
+ }
56
+
57
+ /** @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
+ export async function missingReview(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, path: string): Promise<MissingReview> {
59
+ 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)
63
+ const latest = records.at(-1)
64
+ if (!records.some((record) => !record.archived && !recordResolved(record))) throw new Error('没有待审历史记录')
65
+ if (!latest || typeof latest.after !== 'string') throw new Error('缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件')
66
+ if (Buffer.byteLength(latest.after, 'utf8') > READ_CAP) throw new Error('历史快照过大,无法整文件预览')
67
+
68
+ for (const [token, item] of frozen) if (item.expires < Date.now()) frozen.delete(token)
69
+ if (frozen.size >= 256) throw new Error('缺失文件预览过多,请稍后重试')
70
+ const view: MissingReview = { token: randomUUID(), path, content: latest.after, callIds: records.filter((record) => !record.archived && !recordResolved(record)).map((record) => record.callId), at: latest.at }
71
+ frozen.set(view.token, { view, cwd, session, revision: revisionOf(records), expires: Date.now() + 30 * 60_000 })
72
+ return view
73
+ }
74
+
75
+ /** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen evidence. @param target Resolved path. @param undo Restore instead of keeping absence. @sideEffects Atomically creates absent file, verifies exact bytes; retries never overwrite an existing file. */
76
+ async function applyDisk(ctx: Ctx, item: FrozenReview, target: string, undo: boolean): Promise<void> {
77
+ const fs = ctx.get('fs')
78
+ const absent = await fileAbsent(ctx, item.cwd, target)
79
+ if (!undo) { if (!absent) throw new Error('文件已重新出现,未执行 Keep'); return }
80
+ const operation = item.operation!
81
+ if (!absent && !operation.restored) throw new Error('文件已重新出现,未执行 Undo')
82
+ if (absent && !operation.restored) {
83
+ // Publish through the official capability and session sandbox, never unconditional overwrite.
84
+ await fs.writeText(target, item.view.content, { kind: 'createIfAbsent' }, undefined, policyOf(ctx, item.session))
85
+ operation.restored = true
86
+ }
87
+ if (absent && operation.restored && !await fs.stat(target)) throw new Error('恢复后的文件再次缺失,未结清记录')
88
+ if (await fs.readText(target) !== item.view.content) throw new Error('恢复内容校验失败,未结清记录')
89
+ }
90
+
91
+ /** @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
+ export async function missingDecide(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, token: string, operationId: string, undo: boolean): Promise<string[]> {
93
+ const item = frozen.get(token)
94
+ if (!item || item.expires < Date.now() || item.cwd !== cwd || item.session.id !== session.id) throw new Error('删除预览已过期,请刷新后重试')
95
+ if (!operationId || operationId.length > 128) throw new Error('缺少有效操作 ID')
96
+ const target = await resolveTargetIn(ctx, cwd, item.view.path)
97
+ const key = JSON.stringify([cwd, fileKey(target)])
98
+ if (locks.has(key)) throw new Error('该文件正在处理,请稍后重试')
99
+ locks.add(key)
100
+ item.session = session
101
+ try { return await commitMissing(ctx, item, bucket, target, operationId, undo) }
102
+ finally { locks.delete(key) }
103
+ }
104
+
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[]> {
107
+ const previous = item.operation
108
+ if (previous && (previous.id !== operationId || previous.undo !== undo)) throw new Error('已有不同操作,请刷新后重试')
109
+ 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)
112
+ if (revisionOf(records) !== item.revision) throw new Error('历史记录已变化,请刷新删除预览')
113
+ item.operation ??= { id: operationId, undo, restored: false, done: false }
114
+ await applyDisk(ctx, item, target, undo)
115
+
116
+ // Recheck after awaited IO: capture/another review must not silently join this decision.
117
+ const current = await fileRecords(ctx, item.cwd, bucket, target)
118
+ if (revisionOf(current) !== item.revision) throw new Error('历史记录已变化,未结清记录,请刷新')
119
+ const pending = current.filter((record) => item.view.callIds.includes(record.callId))
120
+ const decisions = pending.map((record) => record.decisions)
121
+ const decision = undo ? 'rejected' : 'accepted'
122
+ for (const record of pending) record.decisions = { call: decision, perHunk: record.hunks.map(() => decision) }
123
+ try { await archiveRecords(ctx, item.session, item.cwd, bucket, pending, undo ? '已恢复删除文件快照' : '已保留文件删除') }
124
+ catch (error) { pending.forEach((record, index) => { record.decisions = decisions[index] }); throw error }
125
+ item.operation.done = true
126
+ return item.view.callIds
127
+ }
package/src/rpc.ts CHANGED
@@ -60,6 +60,7 @@ import type { FileVersions } from './fileVersions.js'
60
60
  import { settingsDocPath } from './settingsDocPath.js'
61
61
  import { createTextPages } from './textPages.js'
62
62
  import { reviewHandlers } from './reviewRpc.js'
63
+ import { fileAbsent, missingReview, missingDecide } from './missingReview.js'
63
64
 
64
65
  /** Process-local cursor signer; reads must stay on the Host filesystem capability. */
65
66
  const readTextPage = createTextPages()
@@ -169,20 +170,19 @@ async function readTargetOf(
169
170
  ctx: Ctx,
170
171
  sc: SessionScope,
171
172
  path: string,
172
- ): Promise<ReadTargetOk | { err: string; resolvedPath?: string; code?: 'file-too-large' }> {
173
+ ): Promise<ReadTargetOk | { err: string; resolvedPath?: string; code?: 'file-too-large' | 'file-missing' }> {
173
174
  const fs = ctx.get('fs')
174
175
  if (!fs) return { err: '缺少 fs' }
175
176
  try {
176
177
  const target = await resolveTargetIn(ctx, sc.cwd, path)
177
178
  debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
178
179
  const info = await fs.stat(target)
179
- if (!info || info.type !== 'file') {
180
- // 带上解析后的真实路径:跳转失败时可直接看出是路径解析错还是目标本身不存在
181
- return { err: '文件不存在', resolvedPath: fs.processPath(target) }
182
- }
180
+ if (!info) return { err: '文件不存在', resolvedPath: fs.processPath(target), code: 'file-missing' }
181
+ if (info.type !== 'file') return { err: '目标不是文件', resolvedPath: fs.processPath(target) }
183
182
  if ((info.size ?? 0) > BINARY_READ_CAP) return { err: '文件过大(>32MB),不支持整文件预览', code: 'file-too-large' }
184
183
  return { target, fs, info }
185
184
  } catch (error) {
185
+ if (['ENOENT', 'FS_NOT_FOUND'].includes(String((error as { code?: string }).code))) return { err: '文件不存在', code: 'file-missing' }
186
186
  return { err: '读取失败:' + String(error) }
187
187
  }
188
188
  }
@@ -193,7 +193,7 @@ async function readTargetOf(
193
193
  * @param prep readTargetOf 的失败分支
194
194
  * @returns { ok:false } 形态载荷
195
195
  */
196
- function targetErrOf(prep: { err: string; resolvedPath?: string; code?: 'file-too-large' }): { ok: false; error: string; resolvedPath?: string; code?: 'file-too-large' } {
196
+ function targetErrOf(prep: { err: string; resolvedPath?: string; code?: 'file-too-large' | 'file-missing' }): { ok: false; error: string; resolvedPath?: string; code?: 'file-too-large' | 'file-missing' } {
197
197
  return { ok: false, error: prep.err,
198
198
  ...(prep.resolvedPath !== undefined ? { resolvedPath: prep.resolvedPath } : {}),
199
199
  ...(prep.code ? { code: prep.code } : {}) }
@@ -290,6 +290,15 @@ async function applyDecisions(
290
290
  results.push({ callId: item.callId, ok: false, error: '记录已归档,无法重复决策' })
291
291
  continue
292
292
  }
293
+ try {
294
+ if (await fileAbsent(ctx, cwd, record.path)) {
295
+ results.push({ callId: item.callId, ok: false, error: '文件已删除,请刷新后按整文件删除预览执行 Keep / Undo' })
296
+ continue
297
+ }
298
+ } catch (error) {
299
+ results.push({ callId: item.callId, ok: false, error: '无法确认文件状态:' + String(error) })
300
+ continue
301
+ }
293
302
  // 重试已执行的决定只补持久化,不能再次修改磁盘上的同一个 hunk。
294
303
  const previous = item.scope === 'hunk'
295
304
  ? record.decisions.perHunk[item.hunkIndex ?? -1] : record.decisions.call
@@ -693,6 +702,23 @@ export function buildHandlers(
693
702
  if (!item?.ok) return { ok: false, error: item?.error ?? '操作失败' }
694
703
  return { ok: true, record: item.record! }
695
704
  },
705
+ 'edrv.missingReview': async (args) => {
706
+ const sc = await requireSession(ctx, args.sessionId)
707
+ if ('err' in sc) return { ok: false, error: sc.err }
708
+ try {
709
+ const bucket = await bucketOf(registry, ctx, sc.cwd)
710
+ if (!await fileAbsent(ctx, sc.cwd, args.path)) return { ok: true, review: null }
711
+ return { ok: true, review: await missingReview(ctx, sc.session, sc.cwd, bucket, args.path) }
712
+ } catch (error) { return { ok: false, error: String(error) } }
713
+ },
714
+ 'edrv.missingDecide': async (args) => {
715
+ const sc = await requireSession(ctx, args.sessionId)
716
+ if ('err' in sc) return { ok: false, error: sc.err }
717
+ try {
718
+ const bucket = await bucketOf(registry, ctx, sc.cwd)
719
+ return { ok: true, callIds: await missingDecide(ctx, sc.session, sc.cwd, bucket, args.token, args.operationId, args.undo === true) }
720
+ } catch (error) { return { ok: false, error: String(error) } }
721
+ },
696
722
  'edrv.decideBatch': async (args) => {
697
723
  const sc = await requireSession(ctx, args.sessionId)
698
724
  if ('err' in sc) return { ok: false, error: sc.err }
package/src/shared/rpc.ts CHANGED
@@ -377,6 +377,8 @@ export interface RpcRequestMap {
377
377
  'edrv.reject': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
378
378
  'edrv.decideBatch': { sessionId?: string; items: DecideItem[] }
379
379
  'edrv.textPage': import('./textPages.js').TextPageArgs
380
+ 'edrv.missingReview': { sessionId?: string; path: string }
381
+ 'edrv.missingDecide': { sessionId?: string; token: string; operationId: string; undo: boolean }
380
382
  'edrv.read': { sessionId?: string; path: string; encoding?: 'base64' }
381
383
  'edrv.readBinary': { sessionId?: string; path: string }
382
384
  'edrv.versions': { sessionId?: string; paths: string[] }
@@ -549,6 +551,8 @@ export interface RpcOkMap {
549
551
  'edrv.reject': { record: RecordView }
550
552
  'edrv.decideBatch': { results: DecideResult[] }
551
553
  'edrv.textPage': Extract<import('./textPages.js').TextPageResult, { ok: true }>
554
+ 'edrv.missingReview': { review: import('./types.js').MissingReview | null }
555
+ 'edrv.missingDecide': { callIds: string[] }
552
556
  'edrv.read': { content: string; size: number; encoding?: 'base64'; mime?: string; version?: string }
553
557
  'edrv.readBinary': { binary: BinaryReadPayload }
554
558
  'edrv.versions': { items: FileVersionItem[] }
@@ -707,7 +711,7 @@ export interface RpcOkMap {
707
711
  /** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
708
712
  export type RpcResult<M extends RpcMethod> =
709
713
  | ({ ok: true } & RpcOkMap[M])
710
- | { ok: false; error: string; code?: 'file-too-large'; reason?: Extract<import('./textPages.js').TextPageResult, { ok: false }>['reason'] }
714
+ | { ok: false; error: string; code?: 'file-too-large' | 'file-missing'; reason?: Extract<import('./textPages.js').TextPageResult, { ok: false }>['reason'] }
711
715
 
712
716
  /** 每个方法的 handler 签名(host 分发表与 client 类型检查共用)。 */
713
717
  export type RpcHandler<M extends RpcMethod> = (
@@ -8,6 +8,15 @@
8
8
  * 作者 ddj 2026-08-20
9
9
  */
10
10
 
11
+ /** Frozen whole-file deletion preview, backed by a complete Host snapshot. */
12
+ export interface MissingReview {
13
+ token: string
14
+ path: string
15
+ content: string
16
+ callIds: string[]
17
+ at: string
18
+ }
19
+
11
20
  /** 单条差异块的决策状态。 */
12
21
  export type Decision = 'pending' | 'accepted' | 'rejected'
13
22