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/lib/core/analyzer.js
CHANGED
|
@@ -1601,9 +1601,11 @@ function applyTombstoneFilter(items, tombstones) {
|
|
|
1601
1601
|
/** PlanItem → 墓碑键({kind,id})映射;非「将写入」的条目(Skip 等)与配置类项返回 null 不过滤 */
|
|
1602
1602
|
function tombstoneKeyForItem(item) {
|
|
1603
1603
|
// 插件本体(plugin:<pkg>):Install / Update / Conflict 均视为「将写入该插件」→ 命中墓碑剔除;
|
|
1604
|
+
// issue #95 的 Create = 「把 host 自带的层声明进 dsh.profile.bundles」,同样是写入该插件,
|
|
1605
|
+
// 必须一起被墓碑剔除(用户删过的插件不得被一份旧备份重新声明回来)。
|
|
1604
1606
|
// patch: 行与 plugins:pnpm-workspace 是配置类项,不按插件墓碑过滤(分区级墓碑可覆盖)。
|
|
1605
1607
|
if (item.adapter === 'plugins' && item.id.startsWith('plugin:')) {
|
|
1606
|
-
if (item.kind !== 'Install' && item.kind !== 'Update' && item.kind !== 'Conflict')
|
|
1608
|
+
if (item.kind !== 'Install' && item.kind !== 'Update' && item.kind !== 'Conflict' && item.kind !== 'Create')
|
|
1607
1609
|
return null;
|
|
1608
1610
|
return { kind: 'plugin', id: item.id.slice('plugin:'.length) };
|
|
1609
1611
|
}
|
package/lib/core/backup.d.ts
CHANGED
|
@@ -38,6 +38,15 @@ export declare function planItemWritesTarget(item: PlanItem, exec?: PlanItemExec
|
|
|
38
38
|
* 行 id 是任意字符串,无法靠形状判断它是不是一个路径。
|
|
39
39
|
*/
|
|
40
40
|
export declare const PLUGIN_PATCH_REF_PREFIX = "patchFile:";
|
|
41
|
+
/**
|
|
42
|
+
* plugins 分区中「profile 的 package.json」这一文件目标的 ref(issue #95)。
|
|
43
|
+
*
|
|
44
|
+
* 声明 host 自带的 bundle 层(`dsh.profile.bundles`)就是写这个文件,因此它必须与
|
|
45
|
+
* `pnpm-workspace.yaml` 同级:进导入前快照 → 回滚能整文件还原,否则一次导入留下的声明
|
|
46
|
+
* 撤不掉(照抄 \`patchFile:\` 的做法,用一个符号 ref 而不是真实文件名——ref 空间里还有
|
|
47
|
+
* 任意 patch 行 id,靠形状判断会撞车)。
|
|
48
|
+
*/
|
|
49
|
+
export declare const PLUGIN_PROFILE_MANIFEST_REF = "profileManifest";
|
|
41
50
|
/**
|
|
42
51
|
* 以**系统 home 为根**的文件类分区(ref 是系统 home 相对路径)。
|
|
43
52
|
*
|
package/lib/core/backup.js
CHANGED
|
@@ -118,6 +118,15 @@ for (const id of SECTION_IDS) {
|
|
|
118
118
|
* 行 id 是任意字符串,无法靠形状判断它是不是一个路径。
|
|
119
119
|
*/
|
|
120
120
|
export const PLUGIN_PATCH_REF_PREFIX = 'patchFile:';
|
|
121
|
+
/**
|
|
122
|
+
* plugins 分区中「profile 的 package.json」这一文件目标的 ref(issue #95)。
|
|
123
|
+
*
|
|
124
|
+
* 声明 host 自带的 bundle 层(`dsh.profile.bundles`)就是写这个文件,因此它必须与
|
|
125
|
+
* `pnpm-workspace.yaml` 同级:进导入前快照 → 回滚能整文件还原,否则一次导入留下的声明
|
|
126
|
+
* 撤不掉(照抄 \`patchFile:\` 的做法,用一个符号 ref 而不是真实文件名——ref 空间里还有
|
|
127
|
+
* 任意 patch 行 id,靠形状判断会撞车)。
|
|
128
|
+
*/
|
|
129
|
+
export const PLUGIN_PROFILE_MANIFEST_REF = 'profileManifest';
|
|
121
130
|
/** profile 目录绝对路径(plugins 分区内文件类目标的基准) */
|
|
122
131
|
function profileDirOf(ctx) {
|
|
123
132
|
const profile = ctx.profile !== undefined && ctx.profile !== '' ? ctx.profile : 'web';
|
|
@@ -167,6 +176,10 @@ export function resolveFileTarget(ctx, adapter, ref) {
|
|
|
167
176
|
if (adapter === 'plugins' && ref.startsWith(PLUGIN_PATCH_REF_PREFIX)) {
|
|
168
177
|
return path.join(profileDirOf(ctx), ref.slice(PLUGIN_PATCH_REF_PREFIX.length));
|
|
169
178
|
}
|
|
179
|
+
// issue #95:声明 host 自带 bundle 层 = 写 profile 的 package.json(快照/回滚必须覆盖它)
|
|
180
|
+
if (adapter === 'plugins' && ref === PLUGIN_PROFILE_MANIFEST_REF) {
|
|
181
|
+
return path.join(profileDirOf(ctx), 'package.json');
|
|
182
|
+
}
|
|
170
183
|
const base = FILE_BASES[adapter] ?? '';
|
|
171
184
|
return path.join(ctx.homeDir, base, ref);
|
|
172
185
|
}
|
|
@@ -188,6 +201,8 @@ export function resolveFileTargetRel(adapter, ref, profile) {
|
|
|
188
201
|
const profileDir = `profiles/${profile !== undefined && profile !== '' ? profile : 'web'}`;
|
|
189
202
|
if (ref === 'pnpm-workspace.yaml')
|
|
190
203
|
return normalizePath(`${profileDir}/pnpm-workspace.yaml`);
|
|
204
|
+
if (ref === PLUGIN_PROFILE_MANIFEST_REF)
|
|
205
|
+
return normalizePath(`${profileDir}/package.json`);
|
|
191
206
|
if (ref.startsWith(PLUGIN_PATCH_REF_PREFIX)) {
|
|
192
207
|
return normalizePath(`${profileDir}/${ref.slice(PLUGIN_PATCH_REF_PREFIX.length)}`);
|
|
193
208
|
}
|
|
@@ -257,7 +272,7 @@ async function engineSnapshotEntry(ctx, target) {
|
|
|
257
272
|
case 'plugins':
|
|
258
273
|
case 'prompts': {
|
|
259
274
|
// plugins 分区的 pnpm-workspace.yaml / patch 文件 → 整文件快照(file 类,回滚可整文件还原)
|
|
260
|
-
if (target.adapter === 'plugins' && (target.ref === 'pnpm-workspace.yaml' || target.ref.startsWith(PLUGIN_PATCH_REF_PREFIX))) {
|
|
275
|
+
if (target.adapter === 'plugins' && (target.ref === 'pnpm-workspace.yaml' || target.ref === PLUGIN_PROFILE_MANIFEST_REF || target.ref.startsWith(PLUGIN_PATCH_REF_PREFIX))) {
|
|
261
276
|
const abs = resolveFileTarget(ctx, target.adapter, target.ref);
|
|
262
277
|
if (!(await ctx.fs.exists(abs))) {
|
|
263
278
|
return { kind: 'file', adapter: target.adapter, ref: target.ref, before: null, existed: false };
|
package/lib/core/messages.d.ts
CHANGED
|
@@ -177,6 +177,7 @@ export declare const zh: {
|
|
|
177
177
|
readonly 'host.sessionStoreShapeUnknown': '宿主会话存储列举(sessionPersistence.list())的元素形状无法解析(探测形状 {shape},{count} 个元素全部认不出):父子关系判定放弃,绝不当作「本机没有父子关系」(请升级 DSH,或把该形状反馈给插件)';
|
|
178
178
|
readonly 'host.sessionStorePurposeReindex': '会话 header 重新索引';
|
|
179
179
|
readonly 'host.sessionStorePurposeParentRelations': '父子关系判定';
|
|
180
|
+
readonly 'host.sessionStorePurposeRewindAttach': '回退子会话归位';
|
|
180
181
|
readonly 'host.sessionStoreListFailed': '会话存储列举失败({purpose}):{reason}';
|
|
181
182
|
readonly 'host.sessionStoreListNotArray': '会话存储列举返回非数组({purpose}):形状未知,按不可用处理';
|
|
182
183
|
readonly 'host.sessionStorePartialUnresolved': '会话存储列举中有 {count}/{total} 个元素形状无法解析({purpose}):已解析的照常使用';
|
|
@@ -246,6 +247,15 @@ export declare const zh: {
|
|
|
246
247
|
readonly 'adapter.pluginInstallOk': '插件 {name} 已安装{suffix}';
|
|
247
248
|
readonly 'adapter.pluginUpdateTriggered': '插件 {name} 已触发更新(npm 最新版){suffix}';
|
|
248
249
|
readonly 'adapter.pluginRestartSuffix': ',重启 dsh 后生效';
|
|
250
|
+
readonly 'adapter.pluginHostBundleDeclare': '声明插件层 {name}(由 DSH 自带提供,不安装)';
|
|
251
|
+
readonly 'adapter.pluginHostBundleSame': '插件层 {name} 已在目标机声明(由 DSH 自带提供)';
|
|
252
|
+
readonly 'adapter.pluginHostBundleDeclared': '已声明插件层 {name},重启 dsh 后生效';
|
|
253
|
+
readonly 'adapter.pluginHostBundleDetail': '该层在源机只出现在 dsh.profile.bundles、dependencies 里没有它,由 DSH 从自己的安装位置解析;导入只声明,不装 npm 版本';
|
|
254
|
+
readonly 'adapter.pluginHostBundleNoManifest': '无法声明插件层 {name}:目标机 profile 缺少 package.json';
|
|
255
|
+
readonly 'adapter.pluginHostBundleManifestUnreadable': '无法声明插件层 {name}:profile 清单读不到或形态不符({reason})';
|
|
256
|
+
readonly 'adapter.pluginHostBundleWriteFailed': '声明插件层 {name} 失败:{reason}';
|
|
257
|
+
readonly 'adapter.pluginHostBundleBadJson': 'package.json 不是合法 JSON';
|
|
258
|
+
readonly 'adapter.pluginHostBundleBadShape': 'dsh.profile.bundles 不是字符串数组';
|
|
249
259
|
readonly 'adapter.pwMissing': '导入数据缺少 pnpm-workspace.yaml 内容';
|
|
250
260
|
readonly 'adapter.pluginInstallFailed': '插件 {name} 安装失败:{msg}。可手动安装:dsh plugin --profile {profile} add {name}(其余配置已导入;可修复依赖后在设置页重试或手动安装)';
|
|
251
261
|
readonly 'adapter.pluginUpdateFailed': '插件 {name} 更新失败:{msg}。可手动安装:dsh plugin --profile {profile} add {name}(其余配置已导入;可修复依赖后在设置页重试或手动安装)';
|
|
@@ -317,6 +327,7 @@ export declare const zh: {
|
|
|
317
327
|
readonly 'adapter.validate.requiredField': '缺少 {subject}';
|
|
318
328
|
readonly 'adapter.validate.sourceNamespace': '缺少来源 namespace';
|
|
319
329
|
readonly 'adapter.validate.number': '{subject} 必须是数字';
|
|
330
|
+
readonly 'adapter.validate.boolean': '{subject} 必须是布尔值';
|
|
320
331
|
readonly 'adapter.validate.nonEmptyRef': '{subject} 记录必须含非空 ref';
|
|
321
332
|
readonly 'adapter.validate.credentialRef': '凭据记录必须含非空 ref';
|
|
322
333
|
readonly 'adapter.validate.fileRelativePath': '文件记录必须含非空 relativePath';
|
package/lib/core/messages.js
CHANGED
|
@@ -166,6 +166,7 @@ export const zh = {
|
|
|
166
166
|
'host.sessionStoreShapeUnknown': '宿主会话存储列举(sessionPersistence.list())的元素形状无法解析(探测形状 {shape},{count} 个元素全部认不出):父子关系判定放弃,绝不当作「本机没有父子关系」(请升级 DSH,或把该形状反馈给插件)',
|
|
167
167
|
'host.sessionStorePurposeReindex': '会话 header 重新索引',
|
|
168
168
|
'host.sessionStorePurposeParentRelations': '父子关系判定',
|
|
169
|
+
'host.sessionStorePurposeRewindAttach': '回退子会话归位',
|
|
169
170
|
'host.sessionStoreListFailed': '会话存储列举失败({purpose}):{reason}',
|
|
170
171
|
'host.sessionStoreListNotArray': '会话存储列举返回非数组({purpose}):形状未知,按不可用处理',
|
|
171
172
|
'host.sessionStorePartialUnresolved': '会话存储列举中有 {count}/{total} 个元素形状无法解析({purpose}):已解析的照常使用',
|
|
@@ -237,6 +238,15 @@ export const zh = {
|
|
|
237
238
|
'adapter.pluginInstallOk': '插件 {name} 已安装{suffix}',
|
|
238
239
|
'adapter.pluginUpdateTriggered': '插件 {name} 已触发更新(npm 最新版){suffix}',
|
|
239
240
|
'adapter.pluginRestartSuffix': ',重启 dsh 后生效',
|
|
241
|
+
'adapter.pluginHostBundleDeclare': '声明插件层 {name}(由 DSH 自带提供,不安装)',
|
|
242
|
+
'adapter.pluginHostBundleSame': '插件层 {name} 已在目标机声明(由 DSH 自带提供)',
|
|
243
|
+
'adapter.pluginHostBundleDeclared': '已声明插件层 {name},重启 dsh 后生效',
|
|
244
|
+
'adapter.pluginHostBundleDetail': '该层在源机只出现在 dsh.profile.bundles、dependencies 里没有它,由 DSH 从自己的安装位置解析;导入只声明,不装 npm 版本',
|
|
245
|
+
'adapter.pluginHostBundleNoManifest': '无法声明插件层 {name}:目标机 profile 缺少 package.json',
|
|
246
|
+
'adapter.pluginHostBundleManifestUnreadable': '无法声明插件层 {name}:profile 清单读不到或形态不符({reason})',
|
|
247
|
+
'adapter.pluginHostBundleWriteFailed': '声明插件层 {name} 失败:{reason}',
|
|
248
|
+
'adapter.pluginHostBundleBadJson': 'package.json 不是合法 JSON',
|
|
249
|
+
'adapter.pluginHostBundleBadShape': 'dsh.profile.bundles 不是字符串数组',
|
|
240
250
|
'adapter.pwMissing': '导入数据缺少 pnpm-workspace.yaml 内容',
|
|
241
251
|
'adapter.pluginInstallFailed': '插件 {name} 安装失败:{msg}。可手动安装:dsh plugin --profile {profile} add {name}(其余配置已导入;可修复依赖后在设置页重试或手动安装)',
|
|
242
252
|
'adapter.pluginUpdateFailed': '插件 {name} 更新失败:{msg}。可手动安装:dsh plugin --profile {profile} add {name}(其余配置已导入;可修复依赖后在设置页重试或手动安装)',
|
|
@@ -310,6 +320,7 @@ export const zh = {
|
|
|
310
320
|
'adapter.validate.requiredField': '缺少 {subject}',
|
|
311
321
|
'adapter.validate.sourceNamespace': '缺少来源 namespace',
|
|
312
322
|
'adapter.validate.number': '{subject} 必须是数字',
|
|
323
|
+
'adapter.validate.boolean': '{subject} 必须是布尔值',
|
|
313
324
|
'adapter.validate.nonEmptyRef': '{subject} 记录必须含非空 ref',
|
|
314
325
|
'adapter.validate.credentialRef': '凭据记录必须含非空 ref',
|
|
315
326
|
'adapter.validate.fileRelativePath': '文件记录必须含非空 relativePath',
|
|
@@ -603,6 +614,7 @@ export const en = {
|
|
|
603
614
|
'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)',
|
|
604
615
|
'host.sessionStorePurposeReindex': 'session header re-index',
|
|
605
616
|
'host.sessionStorePurposeParentRelations': 'parent/child detection',
|
|
617
|
+
'host.sessionStorePurposeRewindAttach': 'rewind child session placement',
|
|
606
618
|
'host.sessionStoreListFailed': 'Session store listing failed ({purpose}): {reason}',
|
|
607
619
|
'host.sessionStoreListNotArray': 'Session store listing returned a non-array ({purpose}): unknown shape, treated as unavailable',
|
|
608
620
|
'host.sessionStorePartialUnresolved': 'Session store listing: {count}/{total} elements have an unrecognized shape ({purpose}); the parsed ones are still used',
|
|
@@ -674,6 +686,15 @@ export const en = {
|
|
|
674
686
|
'adapter.pluginInstallOk': 'Plugin {name} installed{suffix}',
|
|
675
687
|
'adapter.pluginUpdateTriggered': 'Plugin {name} update triggered (npm latest){suffix}',
|
|
676
688
|
'adapter.pluginRestartSuffix': '; takes effect after restarting dsh',
|
|
689
|
+
'adapter.pluginHostBundleDeclare': 'Declare plugin layer {name} (provided by DSH itself; not installed)',
|
|
690
|
+
'adapter.pluginHostBundleSame': 'Plugin layer {name} is already declared on the target (provided by DSH itself)',
|
|
691
|
+
'adapter.pluginHostBundleDeclared': 'Plugin layer {name} declared; takes effect after restarting dsh',
|
|
692
|
+
'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',
|
|
693
|
+
'adapter.pluginHostBundleNoManifest': 'Cannot declare plugin layer {name}: the target profile has no package.json',
|
|
694
|
+
'adapter.pluginHostBundleManifestUnreadable': 'Cannot declare plugin layer {name}: the profile manifest is unreadable or has an unexpected shape ({reason})',
|
|
695
|
+
'adapter.pluginHostBundleWriteFailed': 'Failed to declare plugin layer {name}: {reason}',
|
|
696
|
+
'adapter.pluginHostBundleBadJson': 'package.json is not valid JSON',
|
|
697
|
+
'adapter.pluginHostBundleBadShape': 'dsh.profile.bundles is not an array of strings',
|
|
677
698
|
'adapter.pwMissing': 'Import data is missing pnpm-workspace.yaml content',
|
|
678
699
|
'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)',
|
|
679
700
|
'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)',
|
|
@@ -747,6 +768,7 @@ export const en = {
|
|
|
747
768
|
'adapter.validate.requiredField': 'Missing {subject}',
|
|
748
769
|
'adapter.validate.sourceNamespace': 'Missing source namespace',
|
|
749
770
|
'adapter.validate.number': '{subject} must be a number',
|
|
771
|
+
'adapter.validate.boolean': '{subject} must be a boolean',
|
|
750
772
|
'adapter.validate.nonEmptyRef': '{subject} records must have a non-empty ref',
|
|
751
773
|
'adapter.validate.credentialRef': 'Credential records must have a non-empty ref',
|
|
752
774
|
'adapter.validate.fileRelativePath': 'File records must have a non-empty relativePath',
|
package/lib/core/plugin-cli.d.ts
CHANGED
|
@@ -118,6 +118,32 @@ export declare function reconcileBundles(profileDir: string): boolean;
|
|
|
118
118
|
export declare function cleanupAbortedInstall(profileDir: string, pkg: string): void;
|
|
119
119
|
/** 实时读 profile 已装插件清单(先 reconcile bundles,再逐依赖取版本/bundle 属性)。 */
|
|
120
120
|
export declare function listInstalledPlugins(homeDir: string, profile: string): PluginInfo[];
|
|
121
|
+
/**
|
|
122
|
+
* 「只在 `dsh.profile.bundles` 里声明、`dependencies` 里没有该包」的条目判定(issue #95)。
|
|
123
|
+
*
|
|
124
|
+
* 为什么要一个共享判据:导出端的显式字段(`bundlesOnly`)只存在于新备份;旧备份只能按形状
|
|
125
|
+
* 推断——**有依赖 spec 的绝不是**(那是普通依赖,哪怕本机没装全);**没有 spec 且读不到已装版本**
|
|
126
|
+
* 的才是(本 profile 里没有任何东西提供它,DSH 只能从自己的安装位置解析,issue #95)。
|
|
127
|
+
*/
|
|
128
|
+
export declare function isBundlesOnlyEntry(entry: {
|
|
129
|
+
bundlesOnly?: unknown;
|
|
130
|
+
spec?: unknown;
|
|
131
|
+
version?: unknown;
|
|
132
|
+
}): boolean;
|
|
133
|
+
/**
|
|
134
|
+
* 把 `name` 声明进 profile package.json 的 `dsh.profile.bundles`(issue #95)。
|
|
135
|
+
*
|
|
136
|
+
* 只做「追加一个名字」:其余字段逐字保留,重复声明返回 `changed: false`(幂等,不空写文件)。
|
|
137
|
+
* 形状不可认(JSON 坏 / `dsh.profile` 非对象 / `bundles` 非字符串数组)→ 返回 null:
|
|
138
|
+
* 调用方必须**如实报告**并放弃这次声明,绝不重建一份清单出来(那会抹掉 profile 的其它字段)。
|
|
139
|
+
*
|
|
140
|
+
* `bundles` 字段**缺省也拒绝**:DSH 的语义是「bundles 非空即替换默认插件栈」,凭空造一个只含
|
|
141
|
+
* 一个名字的数组 = 悄悄改掉目标机的装配语义(应当由 DSH 自己写这个字段)。
|
|
142
|
+
*/
|
|
143
|
+
export declare function mergeBundleDeclaration(pkgText: string, name: string): {
|
|
144
|
+
text: string;
|
|
145
|
+
changed: boolean;
|
|
146
|
+
} | null;
|
|
121
147
|
/** 安装目标:非 registry spec 按 spec 安装;其余按裸包名(npm 最新版)。 */
|
|
122
148
|
export declare function installSpecFor(pkg: string, spec?: string): string;
|
|
123
149
|
/** 一次 CLI 调用的启动描述(env 只在需要覆写子进程环境时出现)。 */
|
package/lib/core/plugin-cli.js
CHANGED
|
@@ -327,18 +327,78 @@ export function listInstalledPlugins(homeDir, profile) {
|
|
|
327
327
|
continue;
|
|
328
328
|
if (Object.prototype.hasOwnProperty.call(installed, name))
|
|
329
329
|
continue; // 已在依赖清单里
|
|
330
|
+
const installedVersion = readInstalledVersion(dir, name) ?? '';
|
|
330
331
|
out.push({
|
|
331
332
|
name,
|
|
332
|
-
version:
|
|
333
|
+
version: installedVersion,
|
|
333
334
|
enabled: true,
|
|
334
335
|
isBundle: true,
|
|
335
336
|
// 直接声明的层自己就是 profile 层
|
|
336
337
|
inBundles: [name],
|
|
337
|
-
//
|
|
338
|
+
// issue #95:无依赖声明 + **本 profile 也没有它的落盘副本** ⇒ 这个包只可能由 DSH 从自己的
|
|
339
|
+
// 安装位置解析。导入端只能把它声明进目标机的 bundles,绝不按裸包名走 npm(那会把
|
|
340
|
+
// dist-tag latest 的副本装进 profile,与 host 版本线脱节)。
|
|
341
|
+
// 本地**有**落盘副本的手工层不在此列:那种层得靠安装通道才能在目标机上存在,保持旧行为。
|
|
342
|
+
...(installedVersion === '' ? { bundlesOnly: true } : {}),
|
|
338
343
|
});
|
|
339
344
|
}
|
|
340
345
|
return out;
|
|
341
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* 「只在 `dsh.profile.bundles` 里声明、`dependencies` 里没有该包」的条目判定(issue #95)。
|
|
349
|
+
*
|
|
350
|
+
* 为什么要一个共享判据:导出端的显式字段(`bundlesOnly`)只存在于新备份;旧备份只能按形状
|
|
351
|
+
* 推断——**有依赖 spec 的绝不是**(那是普通依赖,哪怕本机没装全);**没有 spec 且读不到已装版本**
|
|
352
|
+
* 的才是(本 profile 里没有任何东西提供它,DSH 只能从自己的安装位置解析,issue #95)。
|
|
353
|
+
*/
|
|
354
|
+
export function isBundlesOnlyEntry(entry) {
|
|
355
|
+
if (entry.bundlesOnly === true)
|
|
356
|
+
return true;
|
|
357
|
+
if (entry.bundlesOnly !== undefined)
|
|
358
|
+
return false;
|
|
359
|
+
const spec = typeof entry.spec === 'string' ? entry.spec : '';
|
|
360
|
+
const version = typeof entry.version === 'string' ? entry.version : '';
|
|
361
|
+
return spec === '' && version === '';
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* 把 `name` 声明进 profile package.json 的 `dsh.profile.bundles`(issue #95)。
|
|
365
|
+
*
|
|
366
|
+
* 只做「追加一个名字」:其余字段逐字保留,重复声明返回 `changed: false`(幂等,不空写文件)。
|
|
367
|
+
* 形状不可认(JSON 坏 / `dsh.profile` 非对象 / `bundles` 非字符串数组)→ 返回 null:
|
|
368
|
+
* 调用方必须**如实报告**并放弃这次声明,绝不重建一份清单出来(那会抹掉 profile 的其它字段)。
|
|
369
|
+
*
|
|
370
|
+
* `bundles` 字段**缺省也拒绝**:DSH 的语义是「bundles 非空即替换默认插件栈」,凭空造一个只含
|
|
371
|
+
* 一个名字的数组 = 悄悄改掉目标机的装配语义(应当由 DSH 自己写这个字段)。
|
|
372
|
+
*/
|
|
373
|
+
export function mergeBundleDeclaration(pkgText, name) {
|
|
374
|
+
let parsed;
|
|
375
|
+
try {
|
|
376
|
+
parsed = JSON.parse(pkgText);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
382
|
+
return null;
|
|
383
|
+
const pkg = parsed;
|
|
384
|
+
const dsh = pkg.dsh;
|
|
385
|
+
if (dsh === null || typeof dsh !== 'object' || Array.isArray(dsh))
|
|
386
|
+
return null;
|
|
387
|
+
const profile = dsh.profile;
|
|
388
|
+
if (profile === null || typeof profile !== 'object' || Array.isArray(profile))
|
|
389
|
+
return null;
|
|
390
|
+
const raw = profile.bundles;
|
|
391
|
+
if (raw === undefined)
|
|
392
|
+
return null;
|
|
393
|
+
if (!Array.isArray(raw) || raw.some((entry) => typeof entry !== 'string'))
|
|
394
|
+
return null;
|
|
395
|
+
const bundles = raw;
|
|
396
|
+
if (bundles.includes(name))
|
|
397
|
+
return { text: pkgText, changed: false };
|
|
398
|
+
bundles.push(name);
|
|
399
|
+
profile.bundles = bundles;
|
|
400
|
+
return { text: `${JSON.stringify(pkg, null, 2)}\n`, changed: true };
|
|
401
|
+
}
|
|
342
402
|
/* --------------------------------------------------------------- CLI 通道 */
|
|
343
403
|
/** 15 分钟默认上限(慢网络 + git 安装),可用环境变量覆盖(CI/测试)。 */
|
|
344
404
|
const INSTALL_TIMEOUT_MS = Number(process.env.DSH_CONFIG_MANAGER_INSTALL_TIMEOUT_MS) || 15 * 60 * 1000;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** 台账文件名(与回滚点库同目录:dataDir/rollback-points/attribution.json) */
|
|
2
|
+
export declare const ROLLBACK_ATTRIBUTION_FILE_NAME = "attribution.json";
|
|
3
|
+
/** 条目上限(超出按 at 淘汰最旧)—— 台账无界增长会把宿主拖垮(见 issue #89 的教训) */
|
|
4
|
+
export declare const ROLLBACK_ATTRIBUTION_MAX_ENTRIES = 20000;
|
|
5
|
+
/**
|
|
6
|
+
* 认得的**写类**工具名。
|
|
7
|
+
*
|
|
8
|
+
* 为什么用白名单而不是「参数里有 path 就算写」:`read` / `grep` / `ops_inspect_local_file`
|
|
9
|
+
* 的参数里也有 path —— 把它们当写入者会让**读**污染归属,进而让回退误跳过本该恢复的文件。
|
|
10
|
+
*/
|
|
11
|
+
export declare const ROLLBACK_WRITE_TOOL_NAMES: readonly string[];
|
|
12
|
+
/** 一条写入归属:谁(会话)在什么时候用哪个工具写了这个路径 */
|
|
13
|
+
export interface RollbackWriteFact {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
tool: string;
|
|
16
|
+
callId: string;
|
|
17
|
+
/** 毫秒时间戳(本进程时钟) */
|
|
18
|
+
at: number;
|
|
19
|
+
turn: number | null;
|
|
20
|
+
}
|
|
21
|
+
/** 引擎只读这一面 */
|
|
22
|
+
export interface RollbackWriteAttributionPort {
|
|
23
|
+
/** 该路径最后一次的写入归属;undefined = **未判定**(不是「没人写过」) */
|
|
24
|
+
writerOf(target: string): RollbackWriteFact | undefined;
|
|
25
|
+
}
|
|
26
|
+
/** 采集面(只有留点器的 noteEvent 用得上) */
|
|
27
|
+
export interface RollbackWriteRecorder {
|
|
28
|
+
/**
|
|
29
|
+
* 记一次 `tool/call` 事件载荷。返回**记录到的路径条数**:
|
|
30
|
+
* 0 = 不是写类工具 / 解析不出路径 = **未判定**(绝不代表「没写过」)。
|
|
31
|
+
*/
|
|
32
|
+
noteToolCall(sessionId: string, data: unknown): number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 从一次 `tool/call` 事件载荷里抽出**会被写的路径**。
|
|
36
|
+
*
|
|
37
|
+
* `arguments` 在 DSH 的事件里是 **JSON 字符串**(不是对象)——两种形态都收,
|
|
38
|
+
* 解不出来就返回空数组(= 未判定),绝不抛错。
|
|
39
|
+
*/
|
|
40
|
+
export declare function toolCallWritePaths(name: unknown, argumentsValue: unknown): string[];
|
|
41
|
+
export interface RollbackWriteAttributionOptions {
|
|
42
|
+
/** 台账文件绝对路径 */
|
|
43
|
+
file: string;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
maxEntries?: number;
|
|
46
|
+
/** 落盘延迟(毫秒);<= 0 表示不自动落盘(测试用) */
|
|
47
|
+
flushDelayMs?: number;
|
|
48
|
+
warn?: (line: string) => void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 内存台账 + best-effort 落盘。
|
|
52
|
+
*
|
|
53
|
+
* 落盘的作用只有一个:**进程重启后**打开旧会话回退时,归属还在。
|
|
54
|
+
* 因此写入是延迟合并的(一次 tool/call 一行,立即落盘会把 I/O 打成筛子)。
|
|
55
|
+
*/
|
|
56
|
+
export declare class RollbackWriteAttribution implements RollbackWriteAttributionPort, RollbackWriteRecorder {
|
|
57
|
+
private readonly entries;
|
|
58
|
+
private readonly file;
|
|
59
|
+
private readonly now;
|
|
60
|
+
private readonly maxEntries;
|
|
61
|
+
private readonly flushDelayMs;
|
|
62
|
+
private readonly warn;
|
|
63
|
+
private loadPromise;
|
|
64
|
+
private flushTimer;
|
|
65
|
+
private writing;
|
|
66
|
+
constructor(options: RollbackWriteAttributionOptions);
|
|
67
|
+
/** 台账条目数(测试与诊断用) */
|
|
68
|
+
get size(): number;
|
|
69
|
+
/** 惰性读盘(幂等;读失败 = 空表,只 warn) */
|
|
70
|
+
load(): Promise<void>;
|
|
71
|
+
private read;
|
|
72
|
+
/** 记一次 tool/call(返回记录到的路径条数;0 = 不是写类工具或解析不出路径 = 未判定) */
|
|
73
|
+
noteToolCall(sessionId: string, data: unknown): number;
|
|
74
|
+
/** 淘汰最旧条目(按 at 升序删到上限内) */
|
|
75
|
+
private evictOldest;
|
|
76
|
+
writerOf(target: string): RollbackWriteFact | undefined;
|
|
77
|
+
/** 延迟落盘(多次调用只落一次) */
|
|
78
|
+
scheduleFlush(): void;
|
|
79
|
+
/** 立刻落盘(原子:同目录临时文件 + rename;失败只 warn,绝不上抛) */
|
|
80
|
+
flush(): Promise<void>;
|
|
81
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
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
|
+
/** 台账文件名(与回滚点库同目录:dataDir/rollback-points/attribution.json) */
|
|
19
|
+
export const ROLLBACK_ATTRIBUTION_FILE_NAME = 'attribution.json';
|
|
20
|
+
/** 条目上限(超出按 at 淘汰最旧)—— 台账无界增长会把宿主拖垮(见 issue #89 的教训) */
|
|
21
|
+
export const ROLLBACK_ATTRIBUTION_MAX_ENTRIES = 20000;
|
|
22
|
+
/**
|
|
23
|
+
* 认得的**写类**工具名。
|
|
24
|
+
*
|
|
25
|
+
* 为什么用白名单而不是「参数里有 path 就算写」:`read` / `grep` / `ops_inspect_local_file`
|
|
26
|
+
* 的参数里也有 path —— 把它们当写入者会让**读**污染归属,进而让回退误跳过本该恢复的文件。
|
|
27
|
+
*/
|
|
28
|
+
export const ROLLBACK_WRITE_TOOL_NAMES = [
|
|
29
|
+
'write',
|
|
30
|
+
'edit',
|
|
31
|
+
'ops_replace',
|
|
32
|
+
'ops_edit',
|
|
33
|
+
'write_file',
|
|
34
|
+
'str_replace',
|
|
35
|
+
'multi_edit',
|
|
36
|
+
'apply_patch',
|
|
37
|
+
];
|
|
38
|
+
/** 写类工具里代表「目标路径」的参数名(DSH 官方 write/edit 用 file_path;本插件工具用 path) */
|
|
39
|
+
const WRITE_PATH_ARG_KEYS = ['file_path', 'path', 'filePath'];
|
|
40
|
+
/** 路径比较键(与 `rollback-point.ts` 的 pathKey 同一口径:resolve + win32 折叠大小写) */
|
|
41
|
+
function attributionPathKey(value) {
|
|
42
|
+
const resolved = path.resolve(value);
|
|
43
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 从一次 `tool/call` 事件载荷里抽出**会被写的路径**。
|
|
47
|
+
*
|
|
48
|
+
* `arguments` 在 DSH 的事件里是 **JSON 字符串**(不是对象)——两种形态都收,
|
|
49
|
+
* 解不出来就返回空数组(= 未判定),绝不抛错。
|
|
50
|
+
*/
|
|
51
|
+
export function toolCallWritePaths(name, argumentsValue) {
|
|
52
|
+
if (typeof name !== 'string' || !ROLLBACK_WRITE_TOOL_NAMES.includes(name))
|
|
53
|
+
return [];
|
|
54
|
+
let parsed = argumentsValue;
|
|
55
|
+
if (typeof argumentsValue === 'string') {
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(argumentsValue);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
64
|
+
return [];
|
|
65
|
+
const record = parsed;
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const key of WRITE_PATH_ARG_KEYS) {
|
|
68
|
+
const value = record[key];
|
|
69
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
70
|
+
out.push(value);
|
|
71
|
+
}
|
|
72
|
+
// 同一个调用里两个键指向同一路径是常见的(别名)⇒ 去重,避免同一路径记两次
|
|
73
|
+
return [...new Set(out)];
|
|
74
|
+
}
|
|
75
|
+
function readFact(value) {
|
|
76
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
77
|
+
return null;
|
|
78
|
+
const record = value;
|
|
79
|
+
const sessionId = record.sessionId;
|
|
80
|
+
const at = record.at;
|
|
81
|
+
if (typeof sessionId !== 'string' || sessionId === '')
|
|
82
|
+
return null;
|
|
83
|
+
if (typeof at !== 'number' || !Number.isFinite(at))
|
|
84
|
+
return null;
|
|
85
|
+
return {
|
|
86
|
+
sessionId,
|
|
87
|
+
at,
|
|
88
|
+
tool: typeof record.tool === 'string' ? record.tool : '',
|
|
89
|
+
callId: typeof record.callId === 'string' ? record.callId : '',
|
|
90
|
+
turn: typeof record.turn === 'number' && Number.isFinite(record.turn) ? record.turn : null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 内存台账 + best-effort 落盘。
|
|
95
|
+
*
|
|
96
|
+
* 落盘的作用只有一个:**进程重启后**打开旧会话回退时,归属还在。
|
|
97
|
+
* 因此写入是延迟合并的(一次 tool/call 一行,立即落盘会把 I/O 打成筛子)。
|
|
98
|
+
*/
|
|
99
|
+
export class RollbackWriteAttribution {
|
|
100
|
+
entries = new Map();
|
|
101
|
+
file;
|
|
102
|
+
now;
|
|
103
|
+
maxEntries;
|
|
104
|
+
flushDelayMs;
|
|
105
|
+
warn;
|
|
106
|
+
loadPromise = null;
|
|
107
|
+
flushTimer = null;
|
|
108
|
+
writing = null;
|
|
109
|
+
constructor(options) {
|
|
110
|
+
this.file = path.resolve(options.file);
|
|
111
|
+
this.now = options.now ?? (() => Date.now());
|
|
112
|
+
this.maxEntries = Math.max(1, options.maxEntries ?? ROLLBACK_ATTRIBUTION_MAX_ENTRIES);
|
|
113
|
+
this.flushDelayMs = options.flushDelayMs ?? 1000;
|
|
114
|
+
this.warn = options.warn ?? (() => { });
|
|
115
|
+
}
|
|
116
|
+
/** 台账条目数(测试与诊断用) */
|
|
117
|
+
get size() {
|
|
118
|
+
return this.entries.size;
|
|
119
|
+
}
|
|
120
|
+
/** 惰性读盘(幂等;读失败 = 空表,只 warn) */
|
|
121
|
+
async load() {
|
|
122
|
+
if (this.loadPromise === null)
|
|
123
|
+
this.loadPromise = this.read();
|
|
124
|
+
return this.loadPromise;
|
|
125
|
+
}
|
|
126
|
+
async read() {
|
|
127
|
+
try {
|
|
128
|
+
const text = await fs.readFile(this.file, 'utf8');
|
|
129
|
+
const parsed = JSON.parse(text);
|
|
130
|
+
if (parsed.version !== 1 || parsed.entries === null || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
|
|
131
|
+
this.warn('rollback attribution ignored (shape): ' + this.file);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
for (const [key, value] of Object.entries(parsed.entries)) {
|
|
135
|
+
const fact = readFact(value);
|
|
136
|
+
// 读盘期间可能已经有本进程新记的条目 ⇒ **新记录优先**(磁盘只是旧快照)
|
|
137
|
+
if (fact !== null && !this.entries.has(key))
|
|
138
|
+
this.entries.set(key, fact);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
const code = error.code;
|
|
143
|
+
// ENOENT = 还没写过台账(正常);其它错误如实留痕但不抛
|
|
144
|
+
if (code !== 'ENOENT')
|
|
145
|
+
this.warn('rollback attribution unreadable: ' + this.file + ' ' + String(code ?? error));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** 记一次 tool/call(返回记录到的路径条数;0 = 不是写类工具或解析不出路径 = 未判定) */
|
|
149
|
+
noteToolCall(sessionId, data) {
|
|
150
|
+
if (typeof sessionId !== 'string' || sessionId === '')
|
|
151
|
+
return 0;
|
|
152
|
+
if (data === null || typeof data !== 'object')
|
|
153
|
+
return 0;
|
|
154
|
+
const record = data;
|
|
155
|
+
const paths = toolCallWritePaths(record.name, record.arguments);
|
|
156
|
+
if (paths.length === 0)
|
|
157
|
+
return 0;
|
|
158
|
+
const fact = {
|
|
159
|
+
sessionId,
|
|
160
|
+
tool: typeof record.name === 'string' ? record.name : '',
|
|
161
|
+
callId: typeof record.callId === 'string' ? record.callId : '',
|
|
162
|
+
at: this.now(),
|
|
163
|
+
turn: typeof record.turn === 'number' && Number.isFinite(record.turn) ? record.turn : null,
|
|
164
|
+
};
|
|
165
|
+
for (const target of paths)
|
|
166
|
+
this.entries.set(attributionPathKey(target), fact);
|
|
167
|
+
if (this.entries.size > this.maxEntries)
|
|
168
|
+
this.evictOldest();
|
|
169
|
+
this.scheduleFlush();
|
|
170
|
+
return paths.length;
|
|
171
|
+
}
|
|
172
|
+
/** 淘汰最旧条目(按 at 升序删到上限内) */
|
|
173
|
+
evictOldest() {
|
|
174
|
+
const sorted = [...this.entries.entries()].sort((a, b) => a[1].at - b[1].at);
|
|
175
|
+
const over = this.entries.size - this.maxEntries;
|
|
176
|
+
for (let i = 0; i < over; i += 1) {
|
|
177
|
+
const item = sorted[i];
|
|
178
|
+
if (item !== undefined)
|
|
179
|
+
this.entries.delete(item[0]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
writerOf(target) {
|
|
183
|
+
return this.entries.get(attributionPathKey(target));
|
|
184
|
+
}
|
|
185
|
+
/** 延迟落盘(多次调用只落一次) */
|
|
186
|
+
scheduleFlush() {
|
|
187
|
+
if (this.flushDelayMs <= 0 || this.flushTimer !== null)
|
|
188
|
+
return;
|
|
189
|
+
this.flushTimer = setTimeout(() => {
|
|
190
|
+
this.flushTimer = null;
|
|
191
|
+
void this.flush();
|
|
192
|
+
}, this.flushDelayMs);
|
|
193
|
+
this.flushTimer.unref?.();
|
|
194
|
+
}
|
|
195
|
+
/** 立刻落盘(原子:同目录临时文件 + rename;失败只 warn,绝不上抛) */
|
|
196
|
+
async flush() {
|
|
197
|
+
if (this.writing !== null)
|
|
198
|
+
return this.writing;
|
|
199
|
+
this.writing = (async () => {
|
|
200
|
+
try {
|
|
201
|
+
await fs.mkdir(path.dirname(this.file), { recursive: true });
|
|
202
|
+
const entriesSnapshot = Object.fromEntries(this.entries);
|
|
203
|
+
// **写前合并**(2026-10-17 修):本进程内存里只有自己见过的写入,而同一个工作区可能同时被
|
|
204
|
+
// 另一个 DSH 进程写归属 —— 各自「整体覆盖」会把对方的记录抹掉。真机实测:归属里缺了对方
|
|
205
|
+
// 那次编辑,导致回退时识别不到「别人改过这个文件」,进而把对方的改动静默覆盖掉。
|
|
206
|
+
// 同键取 `at` 较新者:时间戳是这里唯一可信的先后判据。
|
|
207
|
+
try {
|
|
208
|
+
const existing = JSON.parse(await fs.readFile(this.file, 'utf8'));
|
|
209
|
+
if (existing.version === 1 && existing.entries !== null && typeof existing.entries === 'object' && !Array.isArray(existing.entries)) {
|
|
210
|
+
for (const [key, value] of Object.entries(existing.entries)) {
|
|
211
|
+
const fact = readFact(value);
|
|
212
|
+
if (fact === null)
|
|
213
|
+
continue;
|
|
214
|
+
const mine = entriesSnapshot[key];
|
|
215
|
+
if (mine === undefined || fact.at > mine.at)
|
|
216
|
+
entriesSnapshot[key] = fact;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch { /* 读不到就当没有别人的记录:只写自己这份(绝不因为读失败而放弃落盘) */ }
|
|
221
|
+
const payload = { version: 1, entries: entriesSnapshot };
|
|
222
|
+
const tmp = this.file + '.tmp';
|
|
223
|
+
await fs.writeFile(tmp, JSON.stringify(payload), 'utf8');
|
|
224
|
+
await fs.rename(tmp, this.file);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
this.warn('rollback attribution write failed: ' + this.file + ' ' + String(error.code ?? error));
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
this.writing = null;
|
|
231
|
+
}
|
|
232
|
+
})();
|
|
233
|
+
return this.writing;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=rollback-attribution.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** 合并结论(`ok:false` 时调用方必须**零写入**) */
|
|
2
|
+
export type ThreeWayMergeReason = 'clean' | 'conflict' | 'degraded' | 'binary';
|
|
3
|
+
export interface ThreeWayMergeResult {
|
|
4
|
+
/** true = 结果可直接写入;false = 必须放弃写入并如实报告 */
|
|
5
|
+
ok: boolean;
|
|
6
|
+
/** 合并结果(`ok:false` 时是 theirs 原文 —— 方便调用方做「保持现状」的比较,绝不写入) */
|
|
7
|
+
text: string;
|
|
8
|
+
reason: ThreeWayMergeReason;
|
|
9
|
+
/** 冲突块数(>0 ⇒ ok=false) */
|
|
10
|
+
conflicts: number;
|
|
11
|
+
/** 本次合并**撤销**的行数(base 有、ours 没有)与**保留**的对方新增行数(诊断用) */
|
|
12
|
+
revertedLines: number;
|
|
13
|
+
/** 依赖风险提示(启发式;空数组 = 未发现明显风险,**不代表安全**) */
|
|
14
|
+
risks: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 三方合并(纯函数、无 I/O)。
|
|
18
|
+
*
|
|
19
|
+
* 语义:结果是 `ours` 上再应用 `(theirs - base)` —— 撤销「我们从 base 改到 ours 的那部分」,
|
|
20
|
+
* 同时保留「别人从 base 改到 theirs 的那部分」。
|
|
21
|
+
*/
|
|
22
|
+
export declare function mergeThreeWay(ours: string, base: string, theirs: string): ThreeWayMergeResult;
|