dsh-vscode-mode 0.20.3 → 0.20.5

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/registry.ts CHANGED
@@ -6,10 +6,29 @@
6
6
  import type { DiffRecord } from './shared/types.js'
7
7
  import type { Ctx, Session } from './store.js'
8
8
  import { cwdOfSession, loadBucket } from './store.js'
9
+ import { SESSION_MISSING_ERROR, SESSION_NO_WORKSPACE_ERROR } from './shared/rpc.js'
9
10
 
10
11
  /** cwd → 记录桶(内存缓存;随会话销毁清理)。 */
11
12
  export type Registry = Map<string, Map<string, DiffRecord>>
12
13
 
14
+ /** 会话作用域:本进程 live 会话可带 session;跨实例冷读只有已确认的 cwd。 */
15
+ export interface SessionScope {
16
+ /** 本进程 live 会话;冷读持久化会话时为 undefined。 */
17
+ session?: Session
18
+ /** 已确认的绝对工作区路径。 */
19
+ cwd: string
20
+ }
21
+
22
+ /** sessionQuery 会话记录的最小读取面:只取 header.cwd,不依赖 DSH 类型包。 */
23
+ interface ColdSessionRecord {
24
+ header?: { id?: string; cwd?: string }
25
+ }
26
+
27
+ /** sessionId → cwd 冷读缓存:header.cwd 建会话后不可变,故只增不改。 */
28
+ const coldCwdCache = new Map<string, string>()
29
+ /** 冷读缓存上限:超出后整表清空,避免长跑实例无限累积。 */
30
+ const COLD_CWD_CACHE_MAX = 2048
31
+
13
32
  /** 按 sessionId 取会话;缺省时仅当唯一活跃会话才返回。 */
14
33
  export function sessionOf(ctx: Ctx, sessionId?: string): Session | undefined {
15
34
  const sessions = ctx.get('sessions')
@@ -46,3 +65,82 @@ export function bucketOf(registry: Registry, ctx: Ctx, cwd: string): Promise<Map
46
65
  return map
47
66
  })
48
67
  }
68
+
69
+ /**
70
+ * 解析会话作用域:优先本进程 live 会话;未命中时冷读持久化会话头取 cwd。
71
+ * 仅供只读 RPC 使用——桌面端与 Web 端各有独立宿主,会话只在创建端 live,
72
+ * 但审查数据按 cwd 落盘、跨实例可读;写类 RPC 必须另行要求 live 会话。
73
+ * @author ddj 2026年10月28号
74
+ * @param ctx DSH 上下文(可选探测 sessions 与 sessionQuery)
75
+ * @param sessionId 目标会话 id(冷读必需;缺省时只认唯一活跃会话)
76
+ * @returns 会话作用域,或与 requireSession 同源的错误文案
77
+ */
78
+ export async function resolveScope(ctx: Ctx, sessionId: string | undefined): Promise<SessionScope | { err: string }> {
79
+ const session = sessionOf(ctx, sessionId)
80
+ if (session) {
81
+ const cwd = cwdOfSession(ctx, session)
82
+ return cwd ? { session, cwd } : { err: SESSION_NO_WORKSPACE_ERROR }
83
+ }
84
+
85
+ const cwd = await coldCwdOf(ctx, sessionId)
86
+ return cwd ? { cwd } : { err: SESSION_MISSING_ERROR }
87
+ }
88
+
89
+ /**
90
+ * 冷读持久化会话头的 cwd:走 DSH sessionQuery 的持久化视图,不读事件日志、不碰写句柄。
91
+ * @author ddj 2026年10月28号
92
+ * @param ctx DSH 上下文
93
+ * @param sessionId 目标会话 id
94
+ * @returns 绝对路径;服务缺失、查无此会话或读取失败时 null(失败语义等同 live 未命中)
95
+ */
96
+ export async function coldCwdOf(ctx: Ctx, sessionId: string | undefined): Promise<string | null> {
97
+ if (!sessionId) return null
98
+ const cached = coldCwdCache.get(sessionId)
99
+ if (cached) return cached
100
+
101
+ const query = ctx?.get?.('sessionQuery')
102
+ if (!query) return null
103
+
104
+ try {
105
+ const record = await findColdRecord(query, sessionId)
106
+ const cwd = record?.header?.cwd
107
+ if (typeof cwd !== 'string' || !cwd) return null
108
+ rememberColdCwd(sessionId, cwd)
109
+ return cwd
110
+ } catch {
111
+ // 冷读故障不改语义:仍按「会话不存在」上报,避免把存储异常说成权限问题
112
+ return null
113
+ }
114
+ }
115
+
116
+ /**
117
+ * 在 sessionQuery 的持久化会话视图里定位一条记录(优先精确过滤,其次整表列举)。
118
+ * 两种能力都缺失时返回 null,调用方回落原有「会话不存在」。
119
+ * @author ddj 2026年10月28号
120
+ * @param query ctx.sessionQuery 服务(不同 DSH 版本能力不同)
121
+ * @param sessionId 目标会话 id
122
+ * @returns 会话记录;无此会话或能力缺失时 null
123
+ */
124
+ async function findColdRecord(query: Ctx, sessionId: string): Promise<ColdSessionRecord | null> {
125
+ const filterable = typeof query.filterSessions === 'function'
126
+ const listable = typeof query.listSessions === 'function'
127
+ if (!filterable && !listable) return null
128
+
129
+ const records = filterable
130
+ ? await query.filterSessions([{ kind: 'id', values: [sessionId] }])
131
+ : await query.listSessions()
132
+ if (!Array.isArray(records)) return null
133
+
134
+ return records.find((item: ColdSessionRecord) => item?.header?.id === sessionId) ?? null
135
+ }
136
+
137
+ /**
138
+ * 记录冷读结果(header.cwd 不可变,可长期复用)。
139
+ * @author ddj 2026年10月28号
140
+ * @param sessionId 目标会话 id
141
+ * @param cwd 已确认的绝对工作区路径
142
+ */
143
+ function rememberColdCwd(sessionId: string, cwd: string): void {
144
+ if (coldCwdCache.size >= COLD_CWD_CACHE_MAX) coldCwdCache.clear()
145
+ coldCwdCache.set(sessionId, cwd)
146
+ }
package/src/reviewRpc.ts CHANGED
@@ -2,19 +2,34 @@
2
2
  import type { RpcHandlerMap } from './shared/rpc.js'
3
3
  import { decideReview, reviewDetail, reviewPage } from './reviewDb.js'
4
4
  import { undoReview } from './reviewUndo.js'
5
- import { bucketOf, cwdOfSession, sessionOf } from './registry.js'
5
+ import { bucketOf, resolveScope, sessionOf } from './registry.js'
6
6
  import type { Registry } from './registry.js'
7
7
  import type { Ctx } from './store.js'
8
8
 
9
- /** @public @author ddj 2026年10月26号 @param ctx Host. @param registry Workspace cache. @returns Handlers resolving workspace from the live authenticated session, never caller cwd. */
9
+ /**
10
+ * @public
11
+ * @author ddj 2026年10月26号 / 2026年10月28号
12
+ * @description: 审查 RPC 处理器。工作区一律由 host 解析,绝不采信调用方 cwd:
13
+ * 读路径优先 live 会话、其次冷读持久化会话头(跨实例只读);写路径仍须 live 会话。
14
+ * @param ctx Host 上下文
15
+ * @param registry 工作区记录桶缓存
16
+ * @returns 审查相关处理器表
17
+ */
10
18
  export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandlerMap> {
11
- /** @private @author ddj 2026年10月26号 @param sessionId Request scope. @returns Migrated workspace, or explicit failure without fallback. */
19
+ /** @private @author ddj 2026年10月28号 @param sessionId 请求作用域 @returns 已迁移工作区(允许跨实例冷读) */
12
20
  async function workspace(sessionId: string): Promise<string> {
13
- const session = sessionOf(ctx, sessionId)
14
- const cwd = cwdOfSession(ctx, session)
15
- if (!session || !cwd) throw new Error('会话不存在或没有工作区')
16
- await bucketOf(registry, ctx, cwd)
17
- return cwd
21
+ const scope = await resolveScope(ctx, sessionId)
22
+ if ('err' in scope) throw new Error('会话不存在或没有工作区')
23
+ await bucketOf(registry, ctx, scope.cwd)
24
+ return scope.cwd
25
+ }
26
+
27
+ /** @private @author ddj 2026年10月28号 @param sessionId 请求作用域 @returns 本端 live 会话的工作区;冷读作用域一律拒绝写入 */
28
+ async function liveWorkspace(sessionId: string): Promise<string> {
29
+ const scope = await resolveScope(ctx, sessionId)
30
+ if ('err' in scope || !scope.session) throw new Error('会话不存在或没有工作区')
31
+ await bucketOf(registry, ctx, scope.cwd)
32
+ return scope.cwd
18
33
  }
19
34
  return {
20
35
  'edrv.review.list': async (args) => {
@@ -27,7 +42,8 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
27
42
  return detail ? { ok: true, detail } : { ok: false, error: '官方审查记录不存在' }
28
43
  },
29
44
  'edrv.review.decide': async (args) => {
30
- const cwd = await workspace(args.sessionId)
45
+ // 写类操作要求 live 会话:避免两端并发写同一工作区审查库,回滚也依赖本端会话内容
46
+ const cwd = await liveWorkspace(args.sessionId)
31
47
  try {
32
48
  const result = args.decision === 'rejected'
33
49
  ? await undoReview(ctx, sessionOf(ctx, args.sessionId), cwd, args)
package/src/rpc.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  autoArchiveStale,
18
18
  policyOf,
19
19
  resolveTarget,
20
+ resolveTargetIn,
20
21
  saveBucket,
21
22
  } from './store.js'
22
23
  import { cp, mkdir, readFile, rename, rm, stat, writeFile, readdir } from 'node:fs/promises'
@@ -25,8 +26,8 @@ import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } fr
25
26
  import { clearRevertState, listArchives, pageArchives, readArchives, revertState, setRevertState, startReverts } from './archiveDb.js'
26
27
  import { hasOfficial } from './reviewDb.js'
27
28
  import { log } from './log.js'
28
- import type { Registry } from './registry.js'
29
- import { bucketOf, cwdOfSession, sessionOf } from './registry.js'
29
+ import type { Registry, SessionScope } from './registry.js'
30
+ import { bucketOf, cwdOfSession, resolveScope, sessionOf } from './registry.js'
30
31
  import type { SearchOrchestrator } from './search/orchestrator.js'
31
32
  import { newSearcher } from './search/orchestrator.js'
32
33
  import type { ContentSearcher } from './search/content.js'
@@ -166,13 +167,13 @@ interface ReadTargetOk {
166
167
  */
167
168
  async function readTargetOf(
168
169
  ctx: Ctx,
169
- sc: { session: Session; cwd: string },
170
+ sc: SessionScope,
170
171
  path: string,
171
172
  ): Promise<ReadTargetOk | { err: string; resolvedPath?: string; code?: 'file-too-large' }> {
172
173
  const fs = ctx.get('fs')
173
174
  if (!fs) return { err: '缺少 fs' }
174
175
  try {
175
- const target = await resolveTarget(ctx, sc.session, path)
176
+ const target = await resolveTargetIn(ctx, sc.cwd, path)
176
177
  debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
177
178
  const info = await fs.stat(target)
178
179
  if (!info || info.type !== 'file') {
@@ -596,7 +597,7 @@ export function buildHandlers(
596
597
  let staleMs = 0
597
598
  let staleChecked = false
598
599
  // #endregion
599
- const sc = await requireSession(ctx, args.sessionId)
600
+ const sc = await resolveScope(ctx, args.sessionId)
600
601
  if ('err' in sc) return { ok: false, error: sc.err }
601
602
  const bucket = await bucketOf(registry, ctx, sc.cwd)
602
603
  // #region debug log
@@ -606,7 +607,8 @@ export function buildHandlers(
606
607
  // 全量轮询时自动清理 stale 幽灵差异(批量决策后的即时刷新可 skipStale 跳过);
607
608
  // 三个组件各自 5s 全量 list,stale 检查节流到 STALE_CHECK_MIN_MS 一次,
608
609
  // 避免每轮都对全部记录读文件 + 算指纹(keepall 期间多组件同时刷新的主要 host 开销)。
609
- if (!want && args.skipStale !== true) {
610
+ // 跨实例冷读没有 live 会话:stale 清理要写侧车与归档,一律跳过(只读语义)。
611
+ if (sc.session && !want && args.skipStale !== true) {
610
612
  const now = Date.now()
611
613
  if (now - (staleCheckedAt.get(sc.cwd) ?? 0) > STALE_CHECK_MIN_MS) {
612
614
  staleCheckedAt.set(sc.cwd, now)
@@ -645,7 +647,7 @@ export function buildHandlers(
645
647
  * @author ddj 2026年09月28号
646
648
  */
647
649
  'edrv.uiState.get': async (args) => {
648
- const sc = await requireSession(ctx, args.sessionId)
650
+ const sc = await resolveScope(ctx, args.sessionId)
649
651
  if ('err' in sc) return { ok: false, error: sc.err }
650
652
  const keys = await readUiState(uiStateFile(sc.cwd))
651
653
  const globals = await readUiState(uiGlobalsFile())
@@ -657,7 +659,7 @@ export function buildHandlers(
657
659
  * @author ddj 2026年09月28号 / 2026年09月29号
658
660
  */
659
661
  'edrv.uiState.set': async (args) => {
660
- const sc = await requireSession(ctx, args.sessionId)
662
+ const sc = await resolveScope(ctx, args.sessionId)
661
663
  if ('err' in sc) return { ok: false, error: sc.err }
662
664
  const tasks: Array<Promise<{ ok: true } | { ok: false; error: string }>> = []
663
665
  if (args.keys && Object.keys(args.keys).length) tasks.push(writeUiState(uiStateFile(sc.cwd), args.keys))
@@ -700,10 +702,10 @@ export function buildHandlers(
700
702
  },
701
703
  /** @author ddj 2026年10月22号 @param args Read-only request. @returns Bounded official range read, or unsupported on older providers; never bypasses Host IO. */
702
704
  'edrv.textPage': async (args) => {
703
- const sc = await requireSession(ctx, args.sessionId)
705
+ const sc = await resolveScope(ctx, args.sessionId)
704
706
  if ('err' in sc) return { ok: false, error: sc.err, reason: 'read-failed' }
705
707
  try {
706
- const target = await resolveTarget(ctx, sc.session, args.path)
708
+ const target = await resolveTargetIn(ctx, sc.cwd, args.path)
707
709
  const fs = ctx.get('fs')
708
710
  if (typeof fs?.readByteRange !== 'function' || typeof fs.stat !== 'function') return readTextPage(undefined, args)
709
711
  // Keys are opaque identities, never decoded into physical paths. Both legacy string and official FsTarget are supported.
@@ -741,7 +743,7 @@ export function buildHandlers(
741
743
  return { ok: false, error: '读取片段文件失败:' + String(error), resolvedPath: snippetTarget }
742
744
  }
743
745
  }
744
- const sc = await requireSession(ctx, args.sessionId)
746
+ const sc = await resolveScope(ctx, args.sessionId)
745
747
  if ('err' in sc) return { ok: false, error: sc.err }
746
748
  const prep = await readTargetOf(ctx, sc, args.path)
747
749
  if ('err' in prep) return targetErrOf(prep)
@@ -763,7 +765,7 @@ export function buildHandlers(
763
765
  // 二进制直读通道(图片/PDF 预览):与 edrv.read base64 分支共用 readTargetOf 前置,
764
766
  // 返回 Uint8Array 信封;routes 接线后以 octet-stream + x-edrv-* 头直出,
765
767
  // 未接线时信封被 JSON 化,由 client 探测到后自动回退 base64 路径(见 shared/rpc readBinaryPreview)。
766
- const sc = await requireSession(ctx, args.sessionId)
768
+ const sc = await resolveScope(ctx, args.sessionId)
767
769
  if ('err' in sc) return { ok: false, error: sc.err }
768
770
  const prep = await readTargetOf(ctx, sc, args.path)
769
771
  if ('err' in prep) return targetErrOf(prep)
@@ -802,12 +804,12 @@ export function buildHandlers(
802
804
  'edrv.original': async (args) => {
803
805
  // 重建"本批次修改前"内容:DiffEditor 原始侧。仅反解 pending 块;
804
806
  // 全部反解失败时回退到最早记录的整体 before(若存在)。
805
- const sc = await requireSession(ctx, args.sessionId)
807
+ const sc = await resolveScope(ctx, args.sessionId)
806
808
  if ('err' in sc) return { ok: false, error: sc.err }
807
809
  const fs = ctx.get('fs')
808
810
  if (!fs) return { ok: false, error: '缺少 fs' }
809
811
  try {
810
- const target = await resolveTarget(ctx, sc.session, args.path)
812
+ const target = await resolveTargetIn(ctx, sc.cwd, args.path)
811
813
  debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(args.path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
812
814
  const info = await fs.stat(target)
813
815
  if (!info || info.type !== 'file') return { ok: false, error: '文件不存在' }
@@ -898,7 +900,7 @@ export function buildHandlers(
898
900
  }
899
901
  },
900
902
  'edrv.archiveList': async (args) => {
901
- const sc = await requireSession(ctx, args.sessionId)
903
+ const sc = await resolveScope(ctx, args.sessionId)
902
904
  if ('err' in sc) return { ok: false, error: sc.err }
903
905
  try {
904
906
  const ws = findRemoteWs(sc.cwd)
@@ -911,7 +913,7 @@ export function buildHandlers(
911
913
  }
912
914
  },
913
915
  'edrv.archivePage': async (args) => {
914
- const sc = await requireSession(ctx, args.sessionId)
916
+ const sc = await resolveScope(ctx, args.sessionId)
915
917
  if ('err' in sc) return { ok: false, error: sc.err }
916
918
  try {
917
919
  const ws = findRemoteWs(sc.cwd)
@@ -924,7 +926,7 @@ export function buildHandlers(
924
926
  }
925
927
  },
926
928
  'edrv.archiveRead': async (args) => {
927
- const sc = await requireSession(ctx, args.sessionId)
929
+ const sc = await resolveScope(ctx, args.sessionId)
928
930
  if ('err' in sc) return { ok: false, error: sc.err }
929
931
  try {
930
932
  if (args.batch !== undefined && (!args.path || (args.batch !== null && !Number.isInteger(args.batch)))) return { ok: false, error: '归档批次参数无效' }
@@ -1064,7 +1066,7 @@ export function buildHandlers(
1064
1066
  'edrv.listDir': async (args) => {
1065
1067
  // 目录树(侧边栏文件管理用):树索引命中直出(内存,0 IO);失效/force 时
1066
1068
  // 走快路径(resolve + listDirCheap + putIndex);同路径在途请求去重复用。
1067
- const sc = await requireSession(ctx, args.sessionId)
1069
+ const sc = await resolveScope(ctx, args.sessionId)
1068
1070
  if ('err' in sc) return { ok: false, error: sc.err }
1069
1071
  const rel = normalizeRel(args.path)
1070
1072
  if (rel === null) return { ok: false, error: '路径不合法' }
package/src/store.ts CHANGED
@@ -65,6 +65,19 @@ export async function resolveTarget(ctx: Ctx, session: Session | undefined, path
65
65
  return fs.resolve(path, cwd ? { cwd } : {})
66
66
  }
67
67
 
68
+ /**
69
+ * 按已知工作区目录解析目标路径:跨实例只读路径在无 live 会话时使用。
70
+ * @author ddj 2026年10月28号
71
+ * @param ctx DSH 上下文
72
+ * @param cwd 工作区绝对路径(live 投影或持久化 header)
73
+ * @param path 客户端请求路径
74
+ * @returns 解析后的目标
75
+ */
76
+ export async function resolveTargetIn(ctx: Ctx, cwd: string, path: string): Promise<string> {
77
+ const fs = ctx.get('fs')
78
+ return fs.resolve(path, cwd ? { cwd } : {})
79
+ }
80
+
68
81
  /** 读取工作区主 sidecar 文本(缺失/失败 → null)。 */
69
82
  async function readSidecarText(ctx: Ctx, cwd: string | null): Promise<string | null> {
70
83
  const fs = ctx.get('fs')