@mzzsfy/dsh-session-manager 0.7.1 → 0.9.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.
@@ -211,17 +211,6 @@ test('源码契约:client 分页粒度常量与 core ARCHIVE_PAGE_SIZE 同值',
211
211
  assert.ok(guard.test(CLIENT_SRC), 'client 分页粒度常量与 core 漂移')
212
212
  })
213
213
 
214
- test('源码契约:client fetchInputs 必须解包 host 响应信封 { inputs }', () => {
215
- // host 与 client 对信封各自测试自洽时,信封形状漂移只会以运行时 TypeError 暴露;
216
- // 此守卫锁定解包点存在,防消费侧把信封对象当数组使用
217
- const guard = /Array\.isArray\((\w+)\.inputs\)/
218
- assert.ok(guard.test('Array.isArray(payload.inputs)'), '守卫正则必须命中合规样本')
219
- const fetchStart = CLIENT_SRC.indexOf('function fetchInputs(')
220
- const fetchBody = fetchStart >= 0 ? CLIENT_SRC.slice(fetchStart, CLIENT_SRC.indexOf('}', fetchStart)) : ''
221
- assert.ok(fetchBody.includes('api(INPUTS_URL'), '未找到 fetchInputs 的 host 请求')
222
- assert.ok(guard.test(fetchBody), 'fetchInputs 缺少 payload.inputs 数组解包')
223
- })
224
-
225
214
  test('parity 文案:client 确认态文案与 host MESSAGES.unsupportedBackend 同值', () => {
226
215
  const match = INDEX_SRC.match(/unsupportedBackend: '([^']+)'/)
227
216
  assert.ok(match, 'index.js 缺少 unsupportedBackend 文案')
@@ -2,11 +2,11 @@
2
2
 
3
3
  import test from 'node:test'
4
4
  import assert from 'node:assert/strict'
5
- import { mkdtemp, writeFile, rm, access } from 'node:fs/promises'
5
+ import { mkdtemp, mkdir, writeFile, readFile, rm, access } from 'node:fs/promises'
6
6
  import { tmpdir } from 'node:os'
7
7
  import path from 'node:path'
8
8
 
9
- import { TRASH_ENV_NAME, trashCommandFor, trashPath } from '../src/trash.mjs'
9
+ import { TRASH_ENV_NAME, moveToQuarantine, restoreFromQuarantine, trashCommandFor, trashPath } from '../src/trash.mjs'
10
10
 
11
11
  test('Windows 走 PowerShell VisualBasic 回收站,路径经环境变量传递', () => {
12
12
  const cmd = trashCommandFor('win32', 'C:\\logs\\a.jsonl')
@@ -71,3 +71,74 @@ test('trashPath 向执行器传递超时配置', async () => {
71
71
  await trashPath('C:\\x\\a.jsonl', { platform: 'linux', run, timeoutMs: 5 * 1000 })
72
72
  assert.equal(captured[1].timeout, 5 * 1000)
73
73
  })
74
+
75
+ // Given 回收区目录不存在与同名基名, When moveToQuarantine, Then 目录整体搬入回收区
76
+ // 且返回唯一暂存路径(原路径消失,暂存路径存在)
77
+ test('moveToQuarantine:目录整体搬入回收区并返回暂存路径(真实 rename)', { skip: process.platform !== 'win32' && process.platform !== 'linux' && process.platform !== 'darwin' }, async () => {
78
+ const base = await mkdtemp(path.join(tmpdir(), 'dsh-quarantine-'))
79
+ const quarantineDir = path.join(base, 'held')
80
+ const artifactDir = path.join(base, 'sess-1')
81
+ await mkdir(path.join(artifactDir, 'inner'), { recursive: true })
82
+ await writeFile(path.join(artifactDir, 'inner', 'a.jsonl'), '{"header":1}\n')
83
+ try {
84
+ const heldPath = await moveToQuarantine(artifactDir, quarantineDir)
85
+ assert.ok(heldPath.startsWith(quarantineDir), '暂存路径应在回收区目录内')
86
+ await assert.rejects(access(artifactDir), '原路径应消失')
87
+ const moved = await readFile(path.join(heldPath, 'inner', 'a.jsonl'), 'utf8')
88
+ assert.equal(moved, '{"header":1}\n')
89
+ } finally {
90
+ await rm(base, { recursive: true, force: true })
91
+ }
92
+ })
93
+
94
+ // Given rename 因跨设备失败(EXDEV), When moveToQuarantine, Then 回退为复制后删除原路径
95
+ test('moveToQuarantine:EXDEV 回退复制+删除(注入 fs)', async () => {
96
+ const calls = []
97
+ const fakeFs = {
98
+ rename: async (from, to) => {
99
+ calls.push(['rename', from, to])
100
+ throw Object.assign(new Error('cross-device'), { code: 'EXDEV' })
101
+ },
102
+ cp: async (from, to) => { calls.push(['cp', from, to]) },
103
+ rm: async (target) => { calls.push(['rm', target]) },
104
+ mkdir: async () => { calls.push(['mkdir']) },
105
+ }
106
+ const heldPath = await moveToQuarantine('/data/sess-1', '/home/.dsh/held', { fs: fakeFs, now: () => 1234 })
107
+ assert.equal(heldPath, path.join('/home/.dsh/held', 'sess-1-1234'))
108
+ assert.deepEqual(calls, [
109
+ ['mkdir'],
110
+ ['rename', '/data/sess-1', heldPath],
111
+ ['cp', '/data/sess-1', heldPath],
112
+ ['rm', '/data/sess-1'],
113
+ ])
114
+ })
115
+
116
+ // Given 回收区暂存的目录与空缺的原位置, When restoreFromQuarantine, Then 目录回到原位置且暂存消失
117
+ test('restoreFromQuarantine:移回原位置且暂存消失(真实 rename)', { skip: process.platform !== 'win32' && process.platform !== 'linux' && process.platform !== 'darwin' }, async () => {
118
+ const base = await mkdtemp(path.join(tmpdir(), 'dsh-quarantine-'))
119
+ const quarantineDir = path.join(base, 'held')
120
+ const originalDir = path.join(base, 'sess-2')
121
+ await mkdir(path.join(originalDir, 'inner'), { recursive: true })
122
+ await writeFile(path.join(originalDir, 'inner', 'a.jsonl'), '{"header":1}\n')
123
+ try {
124
+ const heldPath = await moveToQuarantine(originalDir, quarantineDir)
125
+ await restoreFromQuarantine(heldPath, originalDir)
126
+ await assert.rejects(access(heldPath), '暂存路径应消失')
127
+ const restored = await readFile(path.join(originalDir, 'inner', 'a.jsonl'), 'utf8')
128
+ assert.equal(restored, '{"header":1}\n')
129
+ } finally {
130
+ await rm(base, { recursive: true, force: true })
131
+ }
132
+ })
133
+
134
+ // Given rename 因原位置已被占用而失败, When restoreFromQuarantine, Then 错误原样抛出由调用方响应
135
+ test('restoreFromQuarantine:原位置已存在时失败上抛(注入 fs)', async () => {
136
+ const occupied = Object.assign(new Error('not empty'), { code: 'ENOTEMPTY' })
137
+ const fakeFs = {
138
+ rename: async () => { throw occupied },
139
+ cp: async () => { throw new Error('不应回退复制') },
140
+ rm: async () => { throw new Error('不应删除') },
141
+ mkdir: async () => {},
142
+ }
143
+ await assert.rejects(restoreFromQuarantine('/held/s1', '/data/s1', { fs: fakeFs }), (error) => error.code === 'ENOTEMPTY')
144
+ })
@@ -1,127 +0,0 @@
1
- // 历史输入工作区缓存:每工作区一个 JSON 文件(~/.dsh/historyPrompt/<工作区>-<hash>.json),
2
- // 记录该工作区聚合后的人类输入(带 sid 溯源)。浮层请求直接读文件(毫秒级)立即返回,
3
- // 对齐在后台解压最新产物与缓存合并写回——解压成本不落在用户等待路径上。
4
- // extracts 段持久化每会话的提取结果与其产物 stat 指纹(mtimeMs+size):
5
- // 对齐时先 stat 比对指纹,产物没变的会话不再解压——判断完全基于磁盘,跨重启生效。
6
- // 文件名 = 工作区路径末段(可辨认)+ 路径哈希前缀(防不同目录同名冲突)
7
-
8
- import { createHash } from 'node:crypto'
9
- import { mkdir, readFile, readdir, stat, writeFile, rename } from 'node:fs/promises'
10
- import { dirname, join, sep } from 'node:path'
11
-
12
- function workspaceFile(dir, cwd) {
13
- const leaf = cwd.split(/[\\/]/).filter(Boolean).pop() || 'root'
14
- const hash = createHash('sha1').update(cwd).digest('hex').slice(0, 8)
15
- return join(dir, `${leaf}-${hash}.json`)
16
- }
17
-
18
- export function ensureCacheDir(dir) {
19
- return mkdir(dir, { recursive: true })
20
- }
21
-
22
- function normalize(parsed, cwd) {
23
- if (!parsed || parsed.cwd !== cwd || !Array.isArray(parsed.entries)) return null
24
- return {
25
- cwd,
26
- entries: parsed.entries,
27
- extracts: parsed.extracts && typeof parsed.extracts === 'object' ? parsed.extracts : {},
28
- }
29
- }
30
-
31
- export async function readWorkspaceCache(dir, cwd) {
32
- try {
33
- return normalize(JSON.parse(await readFile(workspaceFile(dir, cwd), 'utf8')), cwd)
34
- } catch {
35
- return null
36
- }
37
- }
38
-
39
- // 原子写:先写临时文件再改名,进程中断不会留下半截 JSON
40
- export async function writeWorkspaceCache(dir, cwd, cache) {
41
- await ensureCacheDir(dir)
42
- const target = workspaceFile(dir, cwd)
43
- const tmp = target + '.tmp'
44
- await writeFile(tmp, JSON.stringify({ cwd, entries: cache.entries, extracts: cache.extracts }, null, 0), 'utf8')
45
- await rename(tmp, target)
46
- }
47
-
48
- // global 浮层轮询用指纹缓存:每 3s 轮询都全量读取并 JSON.parse 所有工作区缓存文件,
49
- // 单文件 entries 可达数百条,持续打开浮层时是秒级重复大 IO+CPU。以 (mtimeMs,size)
50
- // 指纹复用上次解析结果,产物未变的文件不再读盘;写入侧经临时文件改名,mtime 必变,
51
- // 指纹天然失效。模块级 Map 以全路径为键(防多目录同名文件串扰),条目数有界。
52
- const globalCachePollState = new Map()
53
-
54
- export async function listWorkspaceCachesCached(dir) {
55
- let names
56
- try {
57
- names = await readdir(dir)
58
- } catch {
59
- // 目录缺失/不可读:仅清本目录条目(分隔符锚定,防误清兄弟目录),条目自愈
60
- for (const key of globalCachePollState.keys()) {
61
- if (key.startsWith(dir + sep)) globalCachePollState.delete(key)
62
- }
63
- return []
64
- }
65
- const live = new Set()
66
- const caches = await Promise.all(names
67
- .filter((name) => name.endsWith('.json') && name !== PROMPTS_FILE)
68
- .map(async (name) => {
69
- const file = join(dir, name)
70
- live.add(file)
71
- let fingerprint
72
- try {
73
- const info = await stat(file)
74
- fingerprint = { mtimeMs: info.mtimeMs, size: info.size }
75
- } catch {
76
- globalCachePollState.delete(file)
77
- return null
78
- }
79
- const hit = globalCachePollState.get(file)
80
- if (hit !== undefined && hit.fp.mtimeMs === fingerprint.mtimeMs && hit.fp.size === fingerprint.size) {
81
- return hit.parsed
82
- }
83
- try {
84
- const parsed = JSON.parse(await readFile(file, 'utf8'))
85
- if (!parsed || typeof parsed.cwd !== 'string' || !Array.isArray(parsed.entries)) {
86
- globalCachePollState.delete(file)
87
- return null
88
- }
89
- globalCachePollState.set(file, { fp: fingerprint, parsed })
90
- return parsed
91
- } catch {
92
- globalCachePollState.delete(file)
93
- return null
94
- }
95
- }))
96
- for (const key of globalCachePollState.keys()) {
97
- if (!live.has(key)) globalCachePollState.delete(key)
98
- }
99
- return caches.filter((cache) => cache && typeof cache.cwd === 'string' && Array.isArray(cache.entries))
100
- }
101
-
102
- // 常用提示词:全局单文件(不按工作区),记录用户显式收藏的输入文本。
103
- // 收藏是用户意志,与自动对齐的历史缓存互不覆写
104
- const PROMPTS_FILE = 'prompts.json'
105
-
106
- function normalizePrompts(parsed) {
107
- if (!parsed || !Array.isArray(parsed.items)) return []
108
- return parsed.items
109
- .filter((item) => item && typeof item.text === 'string' && item.text.length > 0)
110
- .map((item) => ({ text: item.text, at: typeof item.at === 'number' ? item.at : 0 }))
111
- }
112
-
113
- export async function readPrompts(dir) {
114
- try {
115
- return normalizePrompts(JSON.parse(await readFile(join(dir, PROMPTS_FILE), 'utf8')))
116
- } catch {
117
- return []
118
- }
119
- }
120
-
121
- export async function writePrompts(dir, items) {
122
- await ensureCacheDir(dir)
123
- const target = join(dir, PROMPTS_FILE)
124
- const tmp = target + '.tmp'
125
- await writeFile(tmp, JSON.stringify({ items }, null, 0), 'utf8')
126
- await rename(tmp, target)
127
- }
@@ -1,84 +0,0 @@
1
- // global 浮层轮询指纹缓存 BDD:stat 指纹命中不重复读盘,产物变更即失效,
2
- // 删除/损坏文件与目录缺失不残留缓存条目。
3
-
4
- import { test } from 'node:test'
5
- import assert from 'node:assert/strict'
6
- import { mkdtemp, writeFile, utimes, rm, readdir, mkdir } from 'node:fs/promises'
7
- import { tmpdir } from 'node:os'
8
- import { join } from 'node:path'
9
-
10
- const { listWorkspaceCachesCached } = await import('../src/history-cache.mjs')
11
-
12
- async function makeDir(t) {
13
- const dir = await mkdtemp(join(tmpdir(), 'sm-fp-cache-'))
14
- t.after(() => rm(dir, { recursive: true, force: true }))
15
- return dir
16
- }
17
-
18
- async function writeCache(dir, name, cwd, entries, mtime) {
19
- const file = join(dir, name)
20
- await writeFile(file, JSON.stringify({ cwd, entries }), 'utf8')
21
- if (mtime !== undefined) await utimes(file, mtime, mtime)
22
- return file
23
- }
24
-
25
- test('指纹命中:产物未变时复用解析结果,内容对象同引用', async (t) => {
26
- const dir = await makeDir(t)
27
- await writeCache(dir, 'a-1.json', 'C:\\a', [{ sid: 's1', text: 'x' }])
28
- const first = await listWorkspaceCachesCached(dir)
29
- assert.equal(first.length, 1)
30
- assert.equal(first[0].cwd, 'C:\\a')
31
- // When 再次读取(文件未动)
32
- const second = await listWorkspaceCachesCached(dir)
33
- // Then 指纹命中,返回缓存中的同一解析对象
34
- assert.equal(second[0], first[0])
35
- })
36
-
37
- test('指纹失效:文件内容变更后重读,新内容可见', async (t) => {
38
- const dir = await makeDir(t)
39
- const stamp = new Date()
40
- const file = await writeCache(dir, 'b-1.json', 'C:\\b', [{ sid: 's1', text: 'old' }], stamp)
41
- const first = await listWorkspaceCachesCached(dir)
42
- assert.equal(first[0].entries[0].text, 'old')
43
- // When 追加条目并推进 mtime(写入侧经临时文件改名,mtime 必变)
44
- await writeFile(file, JSON.stringify({ cwd: 'C:\\b', entries: [{ sid: 's1', text: 'old' }, { sid: 's2', text: 'new' }] }), 'utf8')
45
- const later = new Date(stamp.getTime() + 2000)
46
- await utimes(file, later, later)
47
- const second = await listWorkspaceCachesCached(dir)
48
- // Then 新内容可见
49
- assert.equal(second[0].entries.length, 2)
50
- assert.equal(second[0].entries[1].text, 'new')
51
- })
52
-
53
- test('卫生:文件删除与目录缺失清空对应缓存', async (t) => {
54
- const dir = await makeDir(t)
55
- const file = await writeCache(dir, 'c-1.json', 'C:\\c', [])
56
- await listWorkspaceCachesCached(dir)
57
- // When 删除文件后再读
58
- await rm(file)
59
- const afterRemove = await listWorkspaceCachesCached(dir)
60
- // Then 结果为空(目录仍在,缓存条目清除)
61
- assert.deepEqual(afterRemove, [])
62
- // When 目录整个消失(缓存目录被手工清理)
63
- await rm(dir, { recursive: true, force: true })
64
- const afterDirGone = await listWorkspaceCachesCached(dir)
65
- // Then 返回空数组不抛
66
- assert.deepEqual(afterDirGone, [])
67
- // 目录恢复后缓存可重建
68
- await mkdir(dir, { recursive: true })
69
- assert.deepEqual(await readdir(dir), [])
70
- await writeCache(dir, 'd-1.json', 'C:\\d', [{ sid: 's9', text: 'fresh' }])
71
- const rebuilt = await listWorkspaceCachesCached(dir)
72
- assert.equal(rebuilt.length, 1)
73
- assert.equal(rebuilt[0].entries[0].text, 'fresh')
74
- })
75
-
76
- test('过滤:损坏文件与 prompts.json 不进结果', async (t) => {
77
- const dir = await makeDir(t)
78
- await writeFile(join(dir, 'broken.json'), '{not json', 'utf8')
79
- await writeFile(join(dir, 'prompts.json'), JSON.stringify({ items: [] }), 'utf8')
80
- await writeCache(dir, 'ok-1.json', 'C:\\ok', [])
81
- const caches = await listWorkspaceCachesCached(dir)
82
- assert.equal(caches.length, 1)
83
- assert.equal(caches[0].cwd, 'C:\\ok')
84
- })