dsh-config-manager 0.1.76 → 0.1.78
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/lib/adapters/plugin-data-declared.d.ts +12 -0
- package/lib/adapters/plugin-data-declared.js +57 -0
- package/lib/adapters/plugin-data.d.ts +4 -6
- package/lib/adapters/plugin-data.js +8 -36
- package/lib/adapters/plugins.d.ts +16 -0
- package/lib/adapters/plugins.js +223 -16
- package/lib/client.d.ts +104 -9
- package/lib/client.js +1800 -718
- package/lib/core/analyzer.js +3 -1
- package/lib/core/backup.d.ts +9 -0
- package/lib/core/backup.js +16 -1
- package/lib/core/messages.d.ts +11 -0
- package/lib/core/messages.js +22 -0
- package/lib/core/plugin-cli.d.ts +26 -0
- package/lib/core/plugin-cli.js +62 -2
- package/lib/core/rollback-attribution.d.ts +81 -0
- package/lib/core/rollback-attribution.js +236 -0
- package/lib/core/rollback-merge.d.ts +22 -0
- package/lib/core/rollback-merge.js +203 -0
- package/lib/core/rollback-point.d.ts +114 -0
- package/lib/core/rollback-point.js +534 -17
- package/lib/core/types.d.ts +28 -0
- package/lib/index.d.ts +26 -3
- package/lib/index.js +129 -16
- package/lib/routes/recovery.js +11 -0
- package/lib/schema/section-registry.js +5 -5
- package/lib/schema/types.d.ts +14 -0
- package/lib/ui/badge-tone.d.ts +16 -0
- package/lib/ui/badge-tone.js +20 -0
- package/lib/ui/i18n.d.ts +4 -4
- package/lib/ui/i18n.js +8 -8
- package/lib/ui/rollback-view.d.ts +95 -0
- package/lib/ui/rollback-view.js +55 -0
- package/lib/ui/selection-model.d.ts +140 -1
- package/lib/ui/selection-model.js +241 -9
- package/package.json +1 -1
- package/src/adapters/plugin-data-declared.ts +60 -0
- package/src/adapters/plugin-data.ts +7 -33
- package/src/adapters/plugins.test.ts +162 -0
- package/src/adapters/plugins.ts +236 -17
- package/src/adapters/units.test.ts +26 -0
- package/src/client/chat/ChatEntryView.tsx +147 -46
- package/src/client/chat/chat-entry-view.test.ts +277 -1
- package/src/client/chat/chat-entry-view.ts +98 -4
- package/src/client/chat/chat-node-registration.ts +71 -0
- package/src/client/chat/chat-slots.ts +30 -12
- package/src/client/common/ContentPicker.tsx +581 -175
- package/src/client/common/host-primitives.ts +64 -1
- package/src/client/common/ui.tsx +17 -3
- package/src/client/config-manager.module.css +630 -397
- package/src/client/css-scale-guard.test.ts +41 -3
- package/src/client/css-token-guard.test.ts +10 -4
- package/src/client/export/ExportView.tsx +2 -0
- package/src/client/index.ts +15 -3
- package/src/client/locales.ts +54 -10
- package/src/client/recovery/RollbackCard.tsx +56 -2
- package/src/client/recovery/RollbackDiffPane.tsx +5 -0
- package/src/client/recovery/RollbackFileChanges.tsx +46 -2
- package/src/client/recovery/recovery-locales.ts +22 -0
- package/src/client/recovery/rollback-file-changes.test.ts +1 -1
- package/src/core/analyzer.ts +3 -1
- package/src/core/backup.ts +16 -1
- package/src/core/messages.ts +22 -0
- package/src/core/plugin-cli.fs.test.ts +65 -3
- package/src/core/plugin-cli.ts +55 -2
- package/src/core/rollback-attribution.test.ts +120 -0
- package/src/core/rollback-attribution.ts +267 -0
- package/src/core/rollback-merge.test.ts +133 -0
- package/src/core/rollback-merge.ts +213 -0
- package/src/core/rollback-point.test.ts +412 -0
- package/src/core/rollback-point.ts +560 -20
- package/src/core/types.ts +29 -0
- package/src/index.facade.test.ts +60 -0
- package/src/index.ts +133 -19
- package/src/routes/recovery.ts +11 -0
- package/src/schema/section-registry.ts +5 -5
- package/src/schema/types.ts +14 -0
- package/src/ui/badge-tone.test.ts +23 -0
- package/src/ui/badge-tone.ts +27 -0
- package/src/ui/i18n.ts +8 -8
- package/src/ui/rollback-view.test.ts +27 -0
- package/src/ui/rollback-view.ts +139 -0
- package/src/ui/selection-model.test.ts +240 -2
- package/src/ui/selection-model.ts +349 -1
package/src/core/messages.ts
CHANGED
|
@@ -191,6 +191,7 @@ export const zh = {
|
|
|
191
191
|
'host.sessionStoreShapeUnknown': '宿主会话存储列举(sessionPersistence.list())的元素形状无法解析(探测形状 {shape},{count} 个元素全部认不出):父子关系判定放弃,绝不当作「本机没有父子关系」(请升级 DSH,或把该形状反馈给插件)',
|
|
192
192
|
'host.sessionStorePurposeReindex': '会话 header 重新索引',
|
|
193
193
|
'host.sessionStorePurposeParentRelations': '父子关系判定',
|
|
194
|
+
'host.sessionStorePurposeRewindAttach': '回退子会话归位',
|
|
194
195
|
'host.sessionStoreListFailed': '会话存储列举失败({purpose}):{reason}',
|
|
195
196
|
'host.sessionStoreListNotArray': '会话存储列举返回非数组({purpose}):形状未知,按不可用处理',
|
|
196
197
|
'host.sessionStorePartialUnresolved': '会话存储列举中有 {count}/{total} 个元素形状无法解析({purpose}):已解析的照常使用',
|
|
@@ -264,6 +265,15 @@ export const zh = {
|
|
|
264
265
|
'adapter.pluginInstallOk': '插件 {name} 已安装{suffix}',
|
|
265
266
|
'adapter.pluginUpdateTriggered': '插件 {name} 已触发更新(npm 最新版){suffix}',
|
|
266
267
|
'adapter.pluginRestartSuffix': ',重启 dsh 后生效',
|
|
268
|
+
'adapter.pluginHostBundleDeclare': '声明插件层 {name}(由 DSH 自带提供,不安装)',
|
|
269
|
+
'adapter.pluginHostBundleSame': '插件层 {name} 已在目标机声明(由 DSH 自带提供)',
|
|
270
|
+
'adapter.pluginHostBundleDeclared': '已声明插件层 {name},重启 dsh 后生效',
|
|
271
|
+
'adapter.pluginHostBundleDetail': '该层在源机只出现在 dsh.profile.bundles、dependencies 里没有它,由 DSH 从自己的安装位置解析;导入只声明,不装 npm 版本',
|
|
272
|
+
'adapter.pluginHostBundleNoManifest': '无法声明插件层 {name}:目标机 profile 缺少 package.json',
|
|
273
|
+
'adapter.pluginHostBundleManifestUnreadable': '无法声明插件层 {name}:profile 清单读不到或形态不符({reason})',
|
|
274
|
+
'adapter.pluginHostBundleWriteFailed': '声明插件层 {name} 失败:{reason}',
|
|
275
|
+
'adapter.pluginHostBundleBadJson': 'package.json 不是合法 JSON',
|
|
276
|
+
'adapter.pluginHostBundleBadShape': 'dsh.profile.bundles 不是字符串数组',
|
|
267
277
|
'adapter.pwMissing': '导入数据缺少 pnpm-workspace.yaml 内容',
|
|
268
278
|
'adapter.pluginInstallFailed': '插件 {name} 安装失败:{msg}。可手动安装:dsh plugin --profile {profile} add {name}(其余配置已导入;可修复依赖后在设置页重试或手动安装)',
|
|
269
279
|
'adapter.pluginUpdateFailed': '插件 {name} 更新失败:{msg}。可手动安装:dsh plugin --profile {profile} add {name}(其余配置已导入;可修复依赖后在设置页重试或手动安装)',
|
|
@@ -339,6 +349,7 @@ export const zh = {
|
|
|
339
349
|
'adapter.validate.requiredField': '缺少 {subject}',
|
|
340
350
|
'adapter.validate.sourceNamespace': '缺少来源 namespace',
|
|
341
351
|
'adapter.validate.number': '{subject} 必须是数字',
|
|
352
|
+
'adapter.validate.boolean': '{subject} 必须是布尔值',
|
|
342
353
|
'adapter.validate.nonEmptyRef': '{subject} 记录必须含非空 ref',
|
|
343
354
|
'adapter.validate.credentialRef': '凭据记录必须含非空 ref',
|
|
344
355
|
'adapter.validate.fileRelativePath': '文件记录必须含非空 relativePath',
|
|
@@ -640,6 +651,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
640
651
|
'host.sessionStoreShapeUnknown': 'Cannot parse the elements returned by the host session store (sessionPersistence.list(); probed shape {shape}, none of the {count} elements is recognizable): parent/child detection is abandoned rather than treated as "no parent relations on this machine" (please upgrade DSH or report this shape to the plugin)',
|
|
641
652
|
'host.sessionStorePurposeReindex': 'session header re-index',
|
|
642
653
|
'host.sessionStorePurposeParentRelations': 'parent/child detection',
|
|
654
|
+
'host.sessionStorePurposeRewindAttach': 'rewind child session placement',
|
|
643
655
|
'host.sessionStoreListFailed': 'Session store listing failed ({purpose}): {reason}',
|
|
644
656
|
'host.sessionStoreListNotArray': 'Session store listing returned a non-array ({purpose}): unknown shape, treated as unavailable',
|
|
645
657
|
'host.sessionStorePartialUnresolved': 'Session store listing: {count}/{total} elements have an unrecognized shape ({purpose}); the parsed ones are still used',
|
|
@@ -713,6 +725,15 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
713
725
|
'adapter.pluginInstallOk': 'Plugin {name} installed{suffix}',
|
|
714
726
|
'adapter.pluginUpdateTriggered': 'Plugin {name} update triggered (npm latest){suffix}',
|
|
715
727
|
'adapter.pluginRestartSuffix': '; takes effect after restarting dsh',
|
|
728
|
+
'adapter.pluginHostBundleDeclare': 'Declare plugin layer {name} (provided by DSH itself; not installed)',
|
|
729
|
+
'adapter.pluginHostBundleSame': 'Plugin layer {name} is already declared on the target (provided by DSH itself)',
|
|
730
|
+
'adapter.pluginHostBundleDeclared': 'Plugin layer {name} declared; takes effect after restarting dsh',
|
|
731
|
+
'adapter.pluginHostBundleDetail': 'On the source this layer only appears in dsh.profile.bundles (no dependency declaration) and is resolved by DSH from its own installation; the import only declares it and installs no npm version',
|
|
732
|
+
'adapter.pluginHostBundleNoManifest': 'Cannot declare plugin layer {name}: the target profile has no package.json',
|
|
733
|
+
'adapter.pluginHostBundleManifestUnreadable': 'Cannot declare plugin layer {name}: the profile manifest is unreadable or has an unexpected shape ({reason})',
|
|
734
|
+
'adapter.pluginHostBundleWriteFailed': 'Failed to declare plugin layer {name}: {reason}',
|
|
735
|
+
'adapter.pluginHostBundleBadJson': 'package.json is not valid JSON',
|
|
736
|
+
'adapter.pluginHostBundleBadShape': 'dsh.profile.bundles is not an array of strings',
|
|
716
737
|
'adapter.pwMissing': 'Import data is missing pnpm-workspace.yaml content',
|
|
717
738
|
'adapter.pluginInstallFailed': 'Plugin {name} install failed: {msg}. You can install it manually: dsh plugin --profile {profile} add {name} (the rest of the config was imported; fix dependencies and retry from the settings page, or install by hand)',
|
|
718
739
|
'adapter.pluginUpdateFailed': 'Plugin {name} update failed: {msg}. You can install it manually: dsh plugin --profile {profile} add {name} (the rest of the config was imported; fix dependencies and retry from the settings page, or install by hand)',
|
|
@@ -788,6 +809,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
788
809
|
'adapter.validate.requiredField': 'Missing {subject}',
|
|
789
810
|
'adapter.validate.sourceNamespace': 'Missing source namespace',
|
|
790
811
|
'adapter.validate.number': '{subject} must be a number',
|
|
812
|
+
'adapter.validate.boolean': '{subject} must be a boolean',
|
|
791
813
|
'adapter.validate.nonEmptyRef': '{subject} records must have a non-empty ref',
|
|
792
814
|
'adapter.validate.credentialRef': 'Credential records must have a non-empty ref',
|
|
793
815
|
'adapter.validate.fileRelativePath': 'File records must have a non-empty relativePath',
|
|
@@ -13,8 +13,8 @@ import { tmpdir } from 'node:os';
|
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
-
listInstalledPlugins, readInstalled, readInstalledVersion,
|
|
17
|
-
resolveProfileDir,
|
|
16
|
+
isBundlesOnlyEntry, listInstalledPlugins, mergeBundleDeclaration, readInstalled, readInstalledVersion,
|
|
17
|
+
reconcileBundles, resolveProfileDir,
|
|
18
18
|
} from './plugin-cli.ts';
|
|
19
19
|
import { enterRescueMode } from './boot-rescue.ts';
|
|
20
20
|
import type { PluginInfo } from './types.ts';
|
|
@@ -146,7 +146,9 @@ test('issue #28: 仅由 dsh.profile.bundles 声明的层必须出现在已装清
|
|
|
146
146
|
assert.equal(bundleOnly?.version, '0.3.1', '版本取 node_modules 落盘版本');
|
|
147
147
|
assert.equal(bundleOnly?.isBundle, true);
|
|
148
148
|
assert.deepEqual(bundleOnly?.inBundles, ['@linxin666/dsh-web-ui-all']);
|
|
149
|
-
assert.equal(bundleOnly?.spec, undefined, '无依赖声明 → 无 spec
|
|
149
|
+
assert.equal(bundleOnly?.spec, undefined, '无依赖声明 → 无 spec');
|
|
150
|
+
// issue #95:本机**有**落盘副本的手工层不是「host 自带层」,标记不得出现(保持安装通道行为)
|
|
151
|
+
assert.equal(bundleOnly?.bundlesOnly, undefined, '本机有副本 → 不标记 bundlesOnly');
|
|
150
152
|
// 从未落盘的 bundles 条目:仍列出(它确实是启动时会挂载的层),版本空串不抛
|
|
151
153
|
assert.equal(list.find((p) => p.name === 'stale-pkg')?.version, '');
|
|
152
154
|
// in-box bundle 依然不出现(无论来自 dependencies 还是 bundles)
|
|
@@ -234,3 +236,63 @@ test('reconcileBundles:救援模式下必须停手(不得把收窄掉的用
|
|
|
234
236
|
cleanup();
|
|
235
237
|
}
|
|
236
238
|
});
|
|
239
|
+
|
|
240
|
+
test('issue #95: 只在 bundles 里声明、且本机没有落盘副本的层必须标记 bundlesOnly', () => {
|
|
241
|
+
const { homeDir, profileDir, cleanup } = makeTempProfile(
|
|
242
|
+
{ 'pkg-a': '1.0.0' },
|
|
243
|
+
['@deepseek-ai/dsh-experimental-session-search', '@linxin666/dsh-web-ui-all'],
|
|
244
|
+
);
|
|
245
|
+
try {
|
|
246
|
+
writeInstalledPkg(profileDir, 'pkg-a', '1.0.0');
|
|
247
|
+
writeInstalledPkg(profileDir, '@linxin666/dsh-web-ui-all', '0.3.1', 'patch.yml');
|
|
248
|
+
|
|
249
|
+
const list = listInstalledPlugins(homeDir, 'web');
|
|
250
|
+
const hostLayer = list.find((p) => p.name === '@deepseek-ai/dsh-experimental-session-search');
|
|
251
|
+
assert.equal(hostLayer?.version, '', '本 profile 没装它 → 版本空串');
|
|
252
|
+
assert.equal(hostLayer?.bundlesOnly, true, '无依赖 + 无落盘副本 ⇒ 只能由 DSH 自己提供');
|
|
253
|
+
assert.equal(list.find((p) => p.name === '@linxin666/dsh-web-ui-all')?.bundlesOnly, undefined, '有落盘副本 → 保持旧行为');
|
|
254
|
+
assert.equal(list.find((p) => p.name === 'pkg-a')?.bundlesOnly, undefined, '普通依赖不得带标记');
|
|
255
|
+
} finally {
|
|
256
|
+
cleanup();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('issue #95: isBundlesOnlyEntry —— 显式字段优先,旧备份按形状推断', () => {
|
|
261
|
+
assert.equal(isBundlesOnlyEntry({ bundlesOnly: true, spec: '^1.0.0', version: '1.0.0' }), true, '显式字段优先');
|
|
262
|
+
assert.equal(isBundlesOnlyEntry({ bundlesOnly: false, version: '' }), false, '显式 false 不得被形状覆盖');
|
|
263
|
+
assert.equal(isBundlesOnlyEntry({ version: '' }), true, '旧备份:无 spec 且无已装版本');
|
|
264
|
+
assert.equal(isBundlesOnlyEntry({ spec: '^1.0.0', version: '' }), false, '有依赖声明 → 普通依赖(哪怕本机没装全)');
|
|
265
|
+
assert.equal(isBundlesOnlyEntry({ version: '0.3.1' }), false, '本机有落盘副本 → 手工层');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('issue #95: mergeBundleDeclaration 只追加一个名字;形状不符一律拒绝', () => {
|
|
269
|
+
const base = `${JSON.stringify({ name: 'dsh-profile-web', dependencies: { a: '1.0.0' }, dsh: { profile: { bundles: ['x'] } } }, null, 2)}\n`;
|
|
270
|
+
const merged = mergeBundleDeclaration(base, 'y');
|
|
271
|
+
assert.equal(merged?.changed, true);
|
|
272
|
+
const parsed = JSON.parse(merged!.text) as {
|
|
273
|
+
dependencies: Record<string, string>;
|
|
274
|
+
dsh: { profile: { bundles: string[] } };
|
|
275
|
+
};
|
|
276
|
+
assert.deepEqual(parsed.dsh.profile.bundles, ['x', 'y'], '名字追加在尾部');
|
|
277
|
+
assert.deepEqual(parsed.dependencies, { a: '1.0.0' }, '其余字段逐字保留');
|
|
278
|
+
assert.equal(mergeBundleDeclaration(merged!.text, 'y')?.changed, false, '幂等:重复声明不空写');
|
|
279
|
+
|
|
280
|
+
assert.equal(mergeBundleDeclaration('{', 'y'), null, 'JSON 坏 → null(绝不重建清单)');
|
|
281
|
+
assert.equal(mergeBundleDeclaration('[]', 'y'), null, '顶层非对象 → null');
|
|
282
|
+
assert.equal(mergeBundleDeclaration(JSON.stringify({ name: 'p' }), 'y'), null, '缺 dsh.profile → null');
|
|
283
|
+
assert.equal(
|
|
284
|
+
mergeBundleDeclaration(JSON.stringify({ dsh: { profile: { bundles: 'x' } } }), 'y'),
|
|
285
|
+
null,
|
|
286
|
+
'bundles 非数组 → null',
|
|
287
|
+
);
|
|
288
|
+
assert.equal(
|
|
289
|
+
mergeBundleDeclaration(JSON.stringify({ dsh: { profile: { bundles: ['x', 1] } } }), 'y'),
|
|
290
|
+
null,
|
|
291
|
+
'bundles 含非字符串 → null',
|
|
292
|
+
);
|
|
293
|
+
assert.equal(
|
|
294
|
+
mergeBundleDeclaration(JSON.stringify({ dsh: { profile: {} } }), 'y'),
|
|
295
|
+
null,
|
|
296
|
+
'bundles 字段缺省 → null(非空 bundles 会替换默认插件栈,绝不凭空造)',
|
|
297
|
+
);
|
|
298
|
+
});
|
package/src/core/plugin-cli.ts
CHANGED
|
@@ -340,19 +340,72 @@ export function listInstalledPlugins(homeDir: string, profile: string): PluginIn
|
|
|
340
340
|
for (const name of Array.isArray(declaredBundles) ? declaredBundles : []) {
|
|
341
341
|
if (INBOX_BUNDLES.has(name)) continue
|
|
342
342
|
if (Object.prototype.hasOwnProperty.call(installed, name)) continue // 已在依赖清单里
|
|
343
|
+
const installedVersion = readInstalledVersion(dir, name) ?? ''
|
|
343
344
|
out.push({
|
|
344
345
|
name,
|
|
345
|
-
version:
|
|
346
|
+
version: installedVersion,
|
|
346
347
|
enabled: true,
|
|
347
348
|
isBundle: true,
|
|
348
349
|
// 直接声明的层自己就是 profile 层
|
|
349
350
|
inBundles: [name],
|
|
350
|
-
//
|
|
351
|
+
// issue #95:无依赖声明 + **本 profile 也没有它的落盘副本** ⇒ 这个包只可能由 DSH 从自己的
|
|
352
|
+
// 安装位置解析。导入端只能把它声明进目标机的 bundles,绝不按裸包名走 npm(那会把
|
|
353
|
+
// dist-tag latest 的副本装进 profile,与 host 版本线脱节)。
|
|
354
|
+
// 本地**有**落盘副本的手工层不在此列:那种层得靠安装通道才能在目标机上存在,保持旧行为。
|
|
355
|
+
...(installedVersion === '' ? { bundlesOnly: true } : {}),
|
|
351
356
|
})
|
|
352
357
|
}
|
|
353
358
|
return out
|
|
354
359
|
}
|
|
355
360
|
|
|
361
|
+
/**
|
|
362
|
+
* 「只在 `dsh.profile.bundles` 里声明、`dependencies` 里没有该包」的条目判定(issue #95)。
|
|
363
|
+
*
|
|
364
|
+
* 为什么要一个共享判据:导出端的显式字段(`bundlesOnly`)只存在于新备份;旧备份只能按形状
|
|
365
|
+
* 推断——**有依赖 spec 的绝不是**(那是普通依赖,哪怕本机没装全);**没有 spec 且读不到已装版本**
|
|
366
|
+
* 的才是(本 profile 里没有任何东西提供它,DSH 只能从自己的安装位置解析,issue #95)。
|
|
367
|
+
*/
|
|
368
|
+
export function isBundlesOnlyEntry(entry: { bundlesOnly?: unknown; spec?: unknown; version?: unknown }): boolean {
|
|
369
|
+
if (entry.bundlesOnly === true) return true
|
|
370
|
+
if (entry.bundlesOnly !== undefined) return false
|
|
371
|
+
const spec = typeof entry.spec === 'string' ? entry.spec : ''
|
|
372
|
+
const version = typeof entry.version === 'string' ? entry.version : ''
|
|
373
|
+
return spec === '' && version === ''
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 把 `name` 声明进 profile package.json 的 `dsh.profile.bundles`(issue #95)。
|
|
378
|
+
*
|
|
379
|
+
* 只做「追加一个名字」:其余字段逐字保留,重复声明返回 `changed: false`(幂等,不空写文件)。
|
|
380
|
+
* 形状不可认(JSON 坏 / `dsh.profile` 非对象 / `bundles` 非字符串数组)→ 返回 null:
|
|
381
|
+
* 调用方必须**如实报告**并放弃这次声明,绝不重建一份清单出来(那会抹掉 profile 的其它字段)。
|
|
382
|
+
*
|
|
383
|
+
* `bundles` 字段**缺省也拒绝**:DSH 的语义是「bundles 非空即替换默认插件栈」,凭空造一个只含
|
|
384
|
+
* 一个名字的数组 = 悄悄改掉目标机的装配语义(应当由 DSH 自己写这个字段)。
|
|
385
|
+
*/
|
|
386
|
+
export function mergeBundleDeclaration(pkgText: string, name: string): { text: string; changed: boolean } | null {
|
|
387
|
+
let parsed: unknown
|
|
388
|
+
try {
|
|
389
|
+
parsed = JSON.parse(pkgText)
|
|
390
|
+
} catch {
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
394
|
+
const pkg = parsed as Record<string, unknown>
|
|
395
|
+
const dsh = pkg.dsh
|
|
396
|
+
if (dsh === null || typeof dsh !== 'object' || Array.isArray(dsh)) return null
|
|
397
|
+
const profile = (dsh as Record<string, unknown>).profile
|
|
398
|
+
if (profile === null || typeof profile !== 'object' || Array.isArray(profile)) return null
|
|
399
|
+
const raw = (profile as Record<string, unknown>).bundles
|
|
400
|
+
if (raw === undefined) return null
|
|
401
|
+
if (!Array.isArray(raw) || raw.some((entry) => typeof entry !== 'string')) return null
|
|
402
|
+
const bundles = raw as string[]
|
|
403
|
+
if (bundles.includes(name)) return { text: pkgText, changed: false }
|
|
404
|
+
bundles.push(name)
|
|
405
|
+
;(profile as Record<string, unknown>).bundles = bundles
|
|
406
|
+
return { text: `${JSON.stringify(pkg, null, 2)}\n`, changed: true }
|
|
407
|
+
}
|
|
408
|
+
|
|
356
409
|
/* --------------------------------------------------------------- CLI 通道 */
|
|
357
410
|
|
|
358
411
|
/** 15 分钟默认上限(慢网络 + git 安装),可用环境变量覆盖(CI/测试)。 */
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 写入归属台账(2026-10-17 定案 B)的单元测试。
|
|
3
|
+
*
|
|
4
|
+
* 三条不变量逐条钉住:① 只有写类工具才算写入(读工具里的 path 不算);
|
|
5
|
+
* ② 解析不出来 = **未判定**(空数组,不抛);③ 落盘失败只 warn,绝不影响调用方。
|
|
6
|
+
*/
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import fs from 'node:fs/promises'
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
ROLLBACK_ATTRIBUTION_MAX_ENTRIES,
|
|
15
|
+
RollbackWriteAttribution,
|
|
16
|
+
toolCallWritePaths,
|
|
17
|
+
} from './rollback-attribution.ts'
|
|
18
|
+
|
|
19
|
+
test('B-1 只有写类工具才算写入:读工具的 path 绝不进归属', () => {
|
|
20
|
+
// 白名单之外一律空数组 —— 否则 read/grep 会把「读」记成「写」,回退就会误跳过文件
|
|
21
|
+
assert.deepEqual(toolCallWritePaths('read', '{"file_path":"a.txt"}'), [])
|
|
22
|
+
assert.deepEqual(toolCallWritePaths('grep', '{"path":"a.txt"}'), [])
|
|
23
|
+
assert.deepEqual(toolCallWritePaths('ops_inspect_local_file', '{"path":"a.txt"}'), [])
|
|
24
|
+
assert.deepEqual(toolCallWritePaths('write', '{"file_path":"a.txt"}'), ['a.txt'])
|
|
25
|
+
assert.deepEqual(toolCallWritePaths('edit', { path: 'b.txt' }), ['b.txt'])
|
|
26
|
+
assert.deepEqual(toolCallWritePaths('ops_replace', JSON.stringify({ path: 'c.txt', find: 'x' })), ['c.txt'])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('B-2 参数字符串解不出来 = 未判定(空数组,绝不抛)', () => {
|
|
30
|
+
assert.deepEqual(toolCallWritePaths('write', '{not json'), [])
|
|
31
|
+
assert.deepEqual(toolCallWritePaths('write', null), [])
|
|
32
|
+
assert.deepEqual(toolCallWritePaths('write', '[1,2]'), [])
|
|
33
|
+
assert.deepEqual(toolCallWritePaths('write', '{"file_path":""}'), [])
|
|
34
|
+
assert.deepEqual(toolCallWritePaths(undefined, '{"file_path":"a.txt"}'), [])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('B-3 同一调用里两个路径键指向同一路径 ⇒ 去重', () => {
|
|
38
|
+
assert.deepEqual(toolCallWritePaths('write', '{"file_path":"x.txt","path":"x.txt"}'), ['x.txt'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('B-4 noteToolCall 记录归属:写类工具进表,非写类工具返回 0 且不进表', () => {
|
|
42
|
+
const attr = new RollbackWriteAttribution({ file: path.join(os.tmpdir(), 'attr-never-written.json'), flushDelayMs: 0 })
|
|
43
|
+
assert.equal(attr.noteToolCall('session-a', { name: 'read', arguments: '{"file_path":"r.txt"}' }), 0)
|
|
44
|
+
assert.equal(attr.writerOf(path.resolve('r.txt')), undefined)
|
|
45
|
+
assert.equal(attr.noteToolCall('session-a', { name: 'write', callId: 'call-1', turn: 3, arguments: '{"file_path":"w.txt"}' }), 1)
|
|
46
|
+
const fact = attr.writerOf(path.resolve('w.txt'))
|
|
47
|
+
assert.equal(fact?.sessionId, 'session-a')
|
|
48
|
+
assert.equal(fact?.callId, 'call-1')
|
|
49
|
+
assert.equal(fact?.tool, 'write')
|
|
50
|
+
assert.equal(fact?.turn, 3)
|
|
51
|
+
// 后写者覆盖前写者(台账只关心「最后一次是谁写的」)
|
|
52
|
+
attr.noteToolCall('session-b', { name: 'edit', callId: 'call-2', arguments: '{"path":"w.txt"}' })
|
|
53
|
+
assert.equal(attr.writerOf(path.resolve('w.txt'))?.sessionId, 'session-b')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('B-5 读盘:形状不符当空表;本进程新记录优先于磁盘旧值', async () => {
|
|
57
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'attr-'))
|
|
58
|
+
const file = path.join(dir, 'attribution.json')
|
|
59
|
+
try {
|
|
60
|
+
await fs.writeFile(file, JSON.stringify({ version: 2, entries: { [path.resolve('old.txt')]: { sessionId: 'x', at: 1 } } }), 'utf8')
|
|
61
|
+
const wrong = new RollbackWriteAttribution({ file, flushDelayMs: 0 })
|
|
62
|
+
await wrong.load()
|
|
63
|
+
assert.equal(wrong.size, 0, '未知版本必须当空表,绝不猜字段含义')
|
|
64
|
+
|
|
65
|
+
await fs.writeFile(file, JSON.stringify({
|
|
66
|
+
version: 1,
|
|
67
|
+
entries: { [path.resolve('old.txt')]: { sessionId: 'disk', at: 5, tool: 'write', callId: 'c', turn: null } },
|
|
68
|
+
}), 'utf8')
|
|
69
|
+
const attr = new RollbackWriteAttribution({ file, flushDelayMs: 0 })
|
|
70
|
+
// 读盘之前先记一条同路径的新事实 ⇒ 读盘**不得**把它覆盖回磁盘旧值
|
|
71
|
+
attr.noteToolCall('session-new', { name: 'write', arguments: '{"file_path":"' + path.resolve('old.txt').replace(/\\/g, '\\\\') + '"}' })
|
|
72
|
+
await attr.load()
|
|
73
|
+
assert.equal(attr.writerOf(path.resolve('old.txt'))?.sessionId, 'session-new')
|
|
74
|
+
} finally {
|
|
75
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('B-6 落盘 + 回读:flush 原子写,写完能读回来', async () => {
|
|
80
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'attr-'))
|
|
81
|
+
const file = path.join(dir, 'attribution.json')
|
|
82
|
+
try {
|
|
83
|
+
const attr = new RollbackWriteAttribution({ file, flushDelayMs: 0 })
|
|
84
|
+
attr.noteToolCall('session-x', { name: 'write', callId: 'c9', arguments: '{"file_path":"' + path.join(dir, 'z.txt').replace(/\\/g, '\\\\') + '"}' })
|
|
85
|
+
await attr.flush()
|
|
86
|
+
const reread = new RollbackWriteAttribution({ file })
|
|
87
|
+
await reread.load()
|
|
88
|
+
assert.equal(reread.writerOf(path.join(dir, 'z.txt'))?.callId, 'c9')
|
|
89
|
+
} finally {
|
|
90
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('B-7 超过上限淘汰最旧(台账不得无界增长)', () => {
|
|
95
|
+
const attr = new RollbackWriteAttribution({ file: path.join(os.tmpdir(), 'attr-unused.json'), flushDelayMs: 0, maxEntries: 2 })
|
|
96
|
+
for (const name of ['a', 'b', 'c']) {
|
|
97
|
+
attr.noteToolCall('s', { name: 'write', arguments: '{"file_path":"' + name + '.txt"}' })
|
|
98
|
+
}
|
|
99
|
+
assert.equal(attr.size, 2)
|
|
100
|
+
assert.equal(attr.writerOf(path.resolve('a.txt')), undefined, '最旧的被淘汰')
|
|
101
|
+
assert.equal(attr.writerOf(path.resolve('c.txt'))?.sessionId, 's')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('B-8 落盘失败只 warn,不上抛(台账坏掉绝不能拖垮留点/回退)', async () => {
|
|
105
|
+
const warnings: string[] = []
|
|
106
|
+
// 目录名当成文件路径 ⇒ mkdir/写必然失败
|
|
107
|
+
const attr = new RollbackWriteAttribution({ file: path.join(os.tmpdir(), 'attr-dir-as-file', 'attribution.json'), flushDelayMs: 0, warn: (line) => warnings.push(line) })
|
|
108
|
+
await fs.mkdir(path.join(os.tmpdir(), 'attr-dir-as-file', 'attribution.json'), { recursive: true })
|
|
109
|
+
try {
|
|
110
|
+
attr.noteToolCall('s', { name: 'write', arguments: '{"file_path":"q.txt"}' })
|
|
111
|
+
await attr.flush()
|
|
112
|
+
assert.equal(warnings.length > 0, true, '写不进去必须留痕')
|
|
113
|
+
} finally {
|
|
114
|
+
await fs.rm(path.join(os.tmpdir(), 'attr-dir-as-file'), { recursive: true, force: true })
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('B-9 条目上限常量是有限值(0/Infinity 会让台账无界)', () => {
|
|
119
|
+
assert.equal(Number.isFinite(ROLLBACK_ATTRIBUTION_MAX_ENTRIES) && ROLLBACK_ATTRIBUTION_MAX_ENTRIES > 0, true)
|
|
120
|
+
})
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具调用的**写入归属台账** —— 「这个路径最后一次是哪个会话的哪次工具调用写的」。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要(2026-10-17 用户反馈):回滚点的「工作区态」是**整个工作区**的快照,
|
|
5
|
+
* 回退也按整棵树执行 ⇒ 同一工作区里对话 A 改了文件,对话 B 回退时会把 A 的改动一起撤销。
|
|
6
|
+
* 有了归属,引擎才能在预览里标出「这个文件在留点之后是**别的**会话写的」,
|
|
7
|
+
* 让界面把它默认排除(用户仍可手动勾回来)。
|
|
8
|
+
*
|
|
9
|
+
* 三条不变量(不得放宽):
|
|
10
|
+
* ① 采集**尽力而为**:只有能解析出路径的写类工具(write/edit/ops_replace…)才记。
|
|
11
|
+
* `pwsh` / `run_code` / 外部编辑器写的文件**没有归属** ⇒ 查不到 = **未判定**。
|
|
12
|
+
* ② **未判定 ≠ 别的会话写的**:只有确知最后写入者是别的会话时才敢标 foreign。
|
|
13
|
+
* 拿不准就按老口径(照常恢复)—— 少标一次,比误判一次安全。
|
|
14
|
+
* ③ 落盘失败只 warn:台账丢了只让归属退化成「未判定」,绝不参与留点 / 回退的成败判定。
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs/promises'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
|
|
19
|
+
/** 台账文件名(与回滚点库同目录:dataDir/rollback-points/attribution.json) */
|
|
20
|
+
export const ROLLBACK_ATTRIBUTION_FILE_NAME = 'attribution.json'
|
|
21
|
+
/** 条目上限(超出按 at 淘汰最旧)—— 台账无界增长会把宿主拖垮(见 issue #89 的教训) */
|
|
22
|
+
export const ROLLBACK_ATTRIBUTION_MAX_ENTRIES = 20000
|
|
23
|
+
/**
|
|
24
|
+
* 认得的**写类**工具名。
|
|
25
|
+
*
|
|
26
|
+
* 为什么用白名单而不是「参数里有 path 就算写」:`read` / `grep` / `ops_inspect_local_file`
|
|
27
|
+
* 的参数里也有 path —— 把它们当写入者会让**读**污染归属,进而让回退误跳过本该恢复的文件。
|
|
28
|
+
*/
|
|
29
|
+
export const ROLLBACK_WRITE_TOOL_NAMES: readonly string[] = [
|
|
30
|
+
'write',
|
|
31
|
+
'edit',
|
|
32
|
+
'ops_replace',
|
|
33
|
+
'ops_edit',
|
|
34
|
+
'write_file',
|
|
35
|
+
'str_replace',
|
|
36
|
+
'multi_edit',
|
|
37
|
+
'apply_patch',
|
|
38
|
+
]
|
|
39
|
+
/** 写类工具里代表「目标路径」的参数名(DSH 官方 write/edit 用 file_path;本插件工具用 path) */
|
|
40
|
+
const WRITE_PATH_ARG_KEYS: readonly string[] = ['file_path', 'path', 'filePath']
|
|
41
|
+
|
|
42
|
+
/** 路径比较键(与 `rollback-point.ts` 的 pathKey 同一口径:resolve + win32 折叠大小写) */
|
|
43
|
+
function attributionPathKey(value: string): string {
|
|
44
|
+
const resolved = path.resolve(value)
|
|
45
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 一条写入归属:谁(会话)在什么时候用哪个工具写了这个路径 */
|
|
49
|
+
export interface RollbackWriteFact {
|
|
50
|
+
sessionId: string
|
|
51
|
+
tool: string
|
|
52
|
+
callId: string
|
|
53
|
+
/** 毫秒时间戳(本进程时钟) */
|
|
54
|
+
at: number
|
|
55
|
+
turn: number | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 引擎只读这一面 */
|
|
59
|
+
export interface RollbackWriteAttributionPort {
|
|
60
|
+
/** 该路径最后一次的写入归属;undefined = **未判定**(不是「没人写过」) */
|
|
61
|
+
writerOf(target: string): RollbackWriteFact | undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 采集面(只有留点器的 noteEvent 用得上) */
|
|
65
|
+
export interface RollbackWriteRecorder {
|
|
66
|
+
/**
|
|
67
|
+
* 记一次 `tool/call` 事件载荷。返回**记录到的路径条数**:
|
|
68
|
+
* 0 = 不是写类工具 / 解析不出路径 = **未判定**(绝不代表「没写过」)。
|
|
69
|
+
*/
|
|
70
|
+
noteToolCall(sessionId: string, data: unknown): number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 从一次 `tool/call` 事件载荷里抽出**会被写的路径**。
|
|
75
|
+
*
|
|
76
|
+
* `arguments` 在 DSH 的事件里是 **JSON 字符串**(不是对象)——两种形态都收,
|
|
77
|
+
* 解不出来就返回空数组(= 未判定),绝不抛错。
|
|
78
|
+
*/
|
|
79
|
+
export function toolCallWritePaths(name: unknown, argumentsValue: unknown): string[] {
|
|
80
|
+
if (typeof name !== 'string' || !ROLLBACK_WRITE_TOOL_NAMES.includes(name)) return []
|
|
81
|
+
let parsed: unknown = argumentsValue
|
|
82
|
+
if (typeof argumentsValue === 'string') {
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(argumentsValue)
|
|
85
|
+
} catch {
|
|
86
|
+
return []
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return []
|
|
90
|
+
const record = parsed as Record<string, unknown>
|
|
91
|
+
const out: string[] = []
|
|
92
|
+
for (const key of WRITE_PATH_ARG_KEYS) {
|
|
93
|
+
const value = record[key]
|
|
94
|
+
if (typeof value === 'string' && value.trim() !== '') out.push(value)
|
|
95
|
+
}
|
|
96
|
+
// 同一个调用里两个键指向同一路径是常见的(别名)⇒ 去重,避免同一路径记两次
|
|
97
|
+
return [...new Set(out)]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 台账载荷形状(形状不符一律当空表:读不出 ≠ 归属为某会话) */
|
|
101
|
+
interface AttributionFileV1 {
|
|
102
|
+
version: 1
|
|
103
|
+
entries: Record<string, RollbackWriteFact>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readFact(value: unknown): RollbackWriteFact | null {
|
|
107
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null
|
|
108
|
+
const record = value as Record<string, unknown>
|
|
109
|
+
const sessionId = record.sessionId
|
|
110
|
+
const at = record.at
|
|
111
|
+
if (typeof sessionId !== 'string' || sessionId === '') return null
|
|
112
|
+
if (typeof at !== 'number' || !Number.isFinite(at)) return null
|
|
113
|
+
return {
|
|
114
|
+
sessionId,
|
|
115
|
+
at,
|
|
116
|
+
tool: typeof record.tool === 'string' ? record.tool : '',
|
|
117
|
+
callId: typeof record.callId === 'string' ? record.callId : '',
|
|
118
|
+
turn: typeof record.turn === 'number' && Number.isFinite(record.turn) ? record.turn : null,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface RollbackWriteAttributionOptions {
|
|
123
|
+
/** 台账文件绝对路径 */
|
|
124
|
+
file: string
|
|
125
|
+
now?: () => number
|
|
126
|
+
maxEntries?: number
|
|
127
|
+
/** 落盘延迟(毫秒);<= 0 表示不自动落盘(测试用) */
|
|
128
|
+
flushDelayMs?: number
|
|
129
|
+
warn?: (line: string) => void
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 内存台账 + best-effort 落盘。
|
|
134
|
+
*
|
|
135
|
+
* 落盘的作用只有一个:**进程重启后**打开旧会话回退时,归属还在。
|
|
136
|
+
* 因此写入是延迟合并的(一次 tool/call 一行,立即落盘会把 I/O 打成筛子)。
|
|
137
|
+
*/
|
|
138
|
+
export class RollbackWriteAttribution implements RollbackWriteAttributionPort, RollbackWriteRecorder {
|
|
139
|
+
private readonly entries = new Map<string, RollbackWriteFact>()
|
|
140
|
+
private readonly file: string
|
|
141
|
+
private readonly now: () => number
|
|
142
|
+
private readonly maxEntries: number
|
|
143
|
+
private readonly flushDelayMs: number
|
|
144
|
+
private readonly warn: (line: string) => void
|
|
145
|
+
private loadPromise: Promise<void> | null = null
|
|
146
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
147
|
+
private writing: Promise<void> | null = null
|
|
148
|
+
|
|
149
|
+
constructor(options: RollbackWriteAttributionOptions) {
|
|
150
|
+
this.file = path.resolve(options.file)
|
|
151
|
+
this.now = options.now ?? (() => Date.now())
|
|
152
|
+
this.maxEntries = Math.max(1, options.maxEntries ?? ROLLBACK_ATTRIBUTION_MAX_ENTRIES)
|
|
153
|
+
this.flushDelayMs = options.flushDelayMs ?? 1000
|
|
154
|
+
this.warn = options.warn ?? (() => {})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 台账条目数(测试与诊断用) */
|
|
158
|
+
get size(): number {
|
|
159
|
+
return this.entries.size
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 惰性读盘(幂等;读失败 = 空表,只 warn) */
|
|
163
|
+
async load(): Promise<void> {
|
|
164
|
+
if (this.loadPromise === null) this.loadPromise = this.read()
|
|
165
|
+
return this.loadPromise
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async read(): Promise<void> {
|
|
169
|
+
try {
|
|
170
|
+
const text = await fs.readFile(this.file, 'utf8')
|
|
171
|
+
const parsed = JSON.parse(text) as { version?: unknown; entries?: unknown }
|
|
172
|
+
if (parsed.version !== 1 || parsed.entries === null || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
|
|
173
|
+
this.warn('rollback attribution ignored (shape): ' + this.file)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
for (const [key, value] of Object.entries(parsed.entries as Record<string, unknown>)) {
|
|
177
|
+
const fact = readFact(value)
|
|
178
|
+
// 读盘期间可能已经有本进程新记的条目 ⇒ **新记录优先**(磁盘只是旧快照)
|
|
179
|
+
if (fact !== null && !this.entries.has(key)) this.entries.set(key, fact)
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const code = (error as { code?: unknown }).code
|
|
183
|
+
// ENOENT = 还没写过台账(正常);其它错误如实留痕但不抛
|
|
184
|
+
if (code !== 'ENOENT') this.warn('rollback attribution unreadable: ' + this.file + ' ' + String(code ?? error))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 记一次 tool/call(返回记录到的路径条数;0 = 不是写类工具或解析不出路径 = 未判定) */
|
|
189
|
+
noteToolCall(sessionId: string, data: unknown): number {
|
|
190
|
+
if (typeof sessionId !== 'string' || sessionId === '') return 0
|
|
191
|
+
if (data === null || typeof data !== 'object') return 0
|
|
192
|
+
const record = data as Record<string, unknown>
|
|
193
|
+
const paths = toolCallWritePaths(record.name, record.arguments)
|
|
194
|
+
if (paths.length === 0) return 0
|
|
195
|
+
const fact: RollbackWriteFact = {
|
|
196
|
+
sessionId,
|
|
197
|
+
tool: typeof record.name === 'string' ? record.name : '',
|
|
198
|
+
callId: typeof record.callId === 'string' ? record.callId : '',
|
|
199
|
+
at: this.now(),
|
|
200
|
+
turn: typeof record.turn === 'number' && Number.isFinite(record.turn) ? record.turn : null,
|
|
201
|
+
}
|
|
202
|
+
for (const target of paths) this.entries.set(attributionPathKey(target), fact)
|
|
203
|
+
if (this.entries.size > this.maxEntries) this.evictOldest()
|
|
204
|
+
this.scheduleFlush()
|
|
205
|
+
return paths.length
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 淘汰最旧条目(按 at 升序删到上限内) */
|
|
209
|
+
private evictOldest(): void {
|
|
210
|
+
const sorted = [...this.entries.entries()].sort((a, b) => a[1].at - b[1].at)
|
|
211
|
+
const over = this.entries.size - this.maxEntries
|
|
212
|
+
for (let i = 0; i < over; i += 1) {
|
|
213
|
+
const item = sorted[i]
|
|
214
|
+
if (item !== undefined) this.entries.delete(item[0])
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
writerOf(target: string): RollbackWriteFact | undefined {
|
|
219
|
+
return this.entries.get(attributionPathKey(target))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 延迟落盘(多次调用只落一次) */
|
|
223
|
+
scheduleFlush(): void {
|
|
224
|
+
if (this.flushDelayMs <= 0 || this.flushTimer !== null) return
|
|
225
|
+
this.flushTimer = setTimeout(() => {
|
|
226
|
+
this.flushTimer = null
|
|
227
|
+
void this.flush()
|
|
228
|
+
}, this.flushDelayMs)
|
|
229
|
+
// 台账不该阻止进程退出
|
|
230
|
+
;(this.flushTimer as { unref?: () => void }).unref?.()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 立刻落盘(原子:同目录临时文件 + rename;失败只 warn,绝不上抛) */
|
|
234
|
+
async flush(): Promise<void> {
|
|
235
|
+
if (this.writing !== null) return this.writing
|
|
236
|
+
this.writing = (async () => {
|
|
237
|
+
try {
|
|
238
|
+
await fs.mkdir(path.dirname(this.file), { recursive: true })
|
|
239
|
+
const entriesSnapshot: Record<string, RollbackWriteFact> = Object.fromEntries(this.entries)
|
|
240
|
+
// **写前合并**(2026-10-17 修):本进程内存里只有自己见过的写入,而同一个工作区可能同时被
|
|
241
|
+
// 另一个 DSH 进程写归属 —— 各自「整体覆盖」会把对方的记录抹掉。真机实测:归属里缺了对方
|
|
242
|
+
// 那次编辑,导致回退时识别不到「别人改过这个文件」,进而把对方的改动静默覆盖掉。
|
|
243
|
+
// 同键取 `at` 较新者:时间戳是这里唯一可信的先后判据。
|
|
244
|
+
try {
|
|
245
|
+
const existing = JSON.parse(await fs.readFile(this.file, 'utf8')) as { version?: unknown; entries?: unknown }
|
|
246
|
+
if (existing.version === 1 && existing.entries !== null && typeof existing.entries === 'object' && !Array.isArray(existing.entries)) {
|
|
247
|
+
for (const [key, value] of Object.entries(existing.entries as Record<string, unknown>)) {
|
|
248
|
+
const fact = readFact(value)
|
|
249
|
+
if (fact === null) continue
|
|
250
|
+
const mine = entriesSnapshot[key]
|
|
251
|
+
if (mine === undefined || fact.at > mine.at) entriesSnapshot[key] = fact
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch { /* 读不到就当没有别人的记录:只写自己这份(绝不因为读失败而放弃落盘) */ }
|
|
255
|
+
const payload: AttributionFileV1 = { version: 1, entries: entriesSnapshot }
|
|
256
|
+
const tmp = this.file + '.tmp'
|
|
257
|
+
await fs.writeFile(tmp, JSON.stringify(payload), 'utf8')
|
|
258
|
+
await fs.rename(tmp, this.file)
|
|
259
|
+
} catch (error) {
|
|
260
|
+
this.warn('rollback attribution write failed: ' + this.file + ' ' + String((error as { code?: unknown }).code ?? error))
|
|
261
|
+
} finally {
|
|
262
|
+
this.writing = null
|
|
263
|
+
}
|
|
264
|
+
})()
|
|
265
|
+
return this.writing
|
|
266
|
+
}
|
|
267
|
+
}
|