dsh-config-manager 0.1.73 → 0.1.77

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.
Files changed (96) hide show
  1. package/lib/adapters/plugin-data-declared.d.ts +12 -0
  2. package/lib/adapters/plugin-data-declared.js +57 -0
  3. package/lib/adapters/plugin-data.d.ts +4 -6
  4. package/lib/adapters/plugin-data.js +8 -36
  5. package/lib/adapters/plugins.js +92 -12
  6. package/lib/client.d.ts +112 -13
  7. package/lib/client.js +2102 -888
  8. package/lib/core/messages.d.ts +1 -0
  9. package/lib/core/messages.js +2 -0
  10. package/lib/core/rollback-attribution.d.ts +81 -0
  11. package/lib/core/rollback-attribution.js +236 -0
  12. package/lib/core/rollback-ledger.d.ts +29 -3
  13. package/lib/core/rollback-ledger.js +125 -7
  14. package/lib/core/rollback-merge.d.ts +22 -0
  15. package/lib/core/rollback-merge.js +203 -0
  16. package/lib/core/rollback-point.d.ts +335 -3
  17. package/lib/core/rollback-point.js +1323 -169
  18. package/lib/core/types.d.ts +18 -0
  19. package/lib/index.d.ts +26 -3
  20. package/lib/index.js +129 -16
  21. package/lib/routes/recovery.js +11 -0
  22. package/lib/schema/section-registry.js +5 -5
  23. package/lib/sync/messages.d.ts +1 -0
  24. package/lib/sync/messages.js +3 -0
  25. package/lib/sync/sync-engine.js +56 -7
  26. package/lib/ui/badge-tone.d.ts +16 -0
  27. package/lib/ui/badge-tone.js +20 -0
  28. package/lib/ui/i18n.d.ts +5 -5
  29. package/lib/ui/i18n.js +10 -10
  30. package/lib/ui/rollback-view.d.ts +95 -0
  31. package/lib/ui/rollback-view.js +55 -0
  32. package/lib/ui/selection-model.d.ts +140 -1
  33. package/lib/ui/selection-model.js +241 -9
  34. package/lib/utils/bundle-scan.d.ts +5 -3
  35. package/lib/utils/bundle-scan.js +5 -3
  36. package/lib/utils/line-diff.d.ts +7 -1
  37. package/lib/utils/line-diff.js +8 -2
  38. package/package.json +1 -1
  39. package/src/adapters/plugin-data-declared.ts +60 -0
  40. package/src/adapters/plugin-data.ts +7 -33
  41. package/src/adapters/plugins.ts +98 -13
  42. package/src/adapters/units.test.ts +26 -0
  43. package/src/client/chat/ChatEntryView.tsx +179 -49
  44. package/src/client/chat/RollbackFileIcon.tsx +7 -45
  45. package/src/client/chat/chat-entry-view.test.ts +440 -1
  46. package/src/client/chat/chat-entry-view.ts +222 -11
  47. package/src/client/chat/chat-node-registration.ts +111 -6
  48. package/src/client/chat/chat-slots.ts +30 -12
  49. package/src/client/common/ContentPicker.tsx +581 -175
  50. package/src/client/common/DiffTable.tsx +12 -23
  51. package/src/client/common/host-primitives.ts +141 -0
  52. package/src/client/common/ui.tsx +17 -3
  53. package/src/client/config-manager.module.css +658 -404
  54. package/src/client/css-scale-guard.test.ts +41 -3
  55. package/src/client/css-token-guard.test.ts +10 -4
  56. package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
  57. package/src/client/export/ExportView.tsx +2 -0
  58. package/src/client/index.ts +22 -4
  59. package/src/client/library/RestorePlanView.tsx +0 -1
  60. package/src/client/locales.ts +54 -12
  61. package/src/client/recovery/RollbackCard.tsx +56 -2
  62. package/src/client/recovery/RollbackDiffPane.tsx +18 -6
  63. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  64. package/src/client/recovery/recovery-locales.ts +24 -4
  65. package/src/client/recovery/rollback-file-changes.test.ts +37 -1
  66. package/src/client/sync/PushPreviewCard.tsx +57 -0
  67. package/src/client/sync/SyncPanel.tsx +2 -37
  68. package/src/client/sync/SyncSettingsView.test.ts +3 -0
  69. package/src/client/sync/sync-push-preview.test.ts +14 -0
  70. package/src/client/sync/sync-view.ts +9 -1
  71. package/src/core/messages.ts +2 -0
  72. package/src/core/rollback-attribution.test.ts +120 -0
  73. package/src/core/rollback-attribution.ts +267 -0
  74. package/src/core/rollback-ledger.ts +123 -7
  75. package/src/core/rollback-merge.test.ts +133 -0
  76. package/src/core/rollback-merge.ts +213 -0
  77. package/src/core/rollback-point.test.ts +1004 -17
  78. package/src/core/rollback-point.ts +1421 -175
  79. package/src/core/types.ts +19 -0
  80. package/src/index.facade.test.ts +60 -0
  81. package/src/index.ts +133 -19
  82. package/src/routes/recovery.ts +11 -0
  83. package/src/schema/section-registry.ts +5 -5
  84. package/src/sync/messages.ts +3 -0
  85. package/src/sync/sync-engine.ts +55 -7
  86. package/src/sync/sync-preview-push.test.ts +158 -0
  87. package/src/ui/badge-tone.test.ts +23 -0
  88. package/src/ui/badge-tone.ts +27 -0
  89. package/src/ui/i18n.ts +10 -10
  90. package/src/ui/rollback-view.test.ts +27 -0
  91. package/src/ui/rollback-view.ts +139 -0
  92. package/src/ui/selection-model.test.ts +240 -2
  93. package/src/ui/selection-model.ts +349 -1
  94. package/src/utils/bundle-scan.ts +5 -3
  95. package/src/utils/line-diff.test.ts +12 -0
  96. package/src/utils/line-diff.ts +9 -3
@@ -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}):已解析的照常使用';
@@ -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}):已解析的照常使用',
@@ -603,6 +604,7 @@ export const en = {
603
604
  '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
605
  'host.sessionStorePurposeReindex': 'session header re-index',
605
606
  'host.sessionStorePurposeParentRelations': 'parent/child detection',
607
+ 'host.sessionStorePurposeRewindAttach': 'rewind child session placement',
606
608
  'host.sessionStoreListFailed': 'Session store listing failed ({purpose}): {reason}',
607
609
  'host.sessionStoreListNotArray': 'Session store listing returned a non-array ({purpose}): unknown shape, treated as unavailable',
608
610
  'host.sessionStorePartialUnresolved': 'Session store listing: {count}/{total} elements have an unrecognized shape ({purpose}); the parsed ones are still used',
@@ -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
@@ -1,4 +1,4 @@
1
- import type { RollbackPointKind, RollbackPointRecord, RollbackPointSummary, RollbackSegmentSummary, RollbackStateKind } from './rollback-point.ts';
1
+ import type { RollbackPointKind, RollbackPointRecord, RollbackPointSummary, RollbackSegmentSummary, RollbackStateKind, RollbackWorkspacePayload } from './rollback-point.ts';
2
2
  /** 清单格式版本(与**记录**的 schemaVersion 无关:后者是记录内容的版本,恒为 2) */
3
3
  export declare const ROLLBACK_LEDGER_SCHEMA_VERSION = 3;
4
4
  /** 单条记录目录名 */
@@ -54,6 +54,12 @@ export interface RollbackLedgerManifestEntry {
54
54
  payload: string | null;
55
55
  /** 载荷文件字节数(**同一哈希只算一次**,预算与统计用) */
56
56
  payloadBytes: number;
57
+ /**
58
+ * 这份点**解析所需的全部载荷哈希**(自身在前、基准依次在后)。
59
+ * 增量载荷依赖基准 ⇒ 回收(prunePayloads)必须按链保活,否则链一断这个点就读不出来了。
60
+ * 老清单没有该字段 ⇒ 视为 `[payload]`(老载荷都是全量、无依赖)。
61
+ */
62
+ payloadChain?: string[];
57
63
  };
58
64
  session: RollbackLedgerTrackHeader & {
59
65
  /** 会话字节副本的对象仓键;null = 没有副本(回收对象时用,不必读记录文件) */
@@ -176,14 +182,34 @@ export declare class RollbackLedger {
176
182
  /** 原子写清单(小文件;条目由调用方决定) */
177
183
  writeManifest(entries: readonly RollbackLedgerManifestEntry[]): Promise<void>;
178
184
  /** 把完整记录拆成「清单条目 + 记录文件内容 + 载荷文件内容」(**不做 IO**,纯函数) */
179
- prepareRecord(record: RollbackPointRecord): RollbackLedgerPrepared;
185
+ prepareRecord(record: RollbackPointRecord, reuse?: {
186
+ hash: string;
187
+ bytes: number;
188
+ chain?: string[];
189
+ }): RollbackLedgerPrepared;
180
190
  /**
181
191
  * 落盘:载荷(内容寻址;同一哈希已存在则**零写入**)+ 记录文件。
182
192
  * 先载荷后记录:中途失败只留一份没人引用的载荷(垃圾,不是坏数据),下次 prune 回收。
183
193
  */
184
194
  writePrepared(prepared: RollbackLedgerPrepared): Promise<void>;
185
195
  /** 读校验过的载荷(哈希不符 / 形状不符 -> error,绝不返回半个) */
186
- private readVerifiedPayload;
196
+ /**
197
+ * **原始**读取一份载荷(形状校验 + 内容寻址自证)。
198
+ * `files` 可能只是「相对基准的增量子集」(`payload.delta` 非空)—— **只给解析器用**,
199
+ * 消费者一律走 {@link resolvePayload} 拿物化后的完整清单。
200
+ */
201
+ private readStoredPayload;
202
+ /**
203
+ * 解析一份载荷:增量视图**物化**成完整 `files`(含 `object` 字段补回),全量视图原样返回。
204
+ *
205
+ * 任何一环不成立(基准缺失 / 哈希不符 / 形状不符 / 链成环)⇒ **如实失败**:绝不返回半个清单 ——
206
+ * 半份清单会让「留点后新建」的判据把绝大多数文件当成新建的,回退就会删掉它们。
207
+ */
208
+ resolvePayload(hash: string): Promise<{
209
+ payload?: RollbackWorkspacePayload;
210
+ error?: string;
211
+ }>;
212
+ private materializePayload;
187
213
  /** 读一条**完整**记录(引用解析对调用方透明);任何一步不成立就如实失败 */
188
214
  readRecord(id: string): Promise<{
189
215
  record?: RollbackPointRecord;
@@ -490,6 +490,7 @@ export function normalizeManifestEntry(value) {
490
490
  return null;
491
491
  const rawRecordBytes = value['recordBytes'];
492
492
  const rawPayloadBytes = isRecord(workspaceRaw) ? workspaceRaw['payloadBytes'] : 0;
493
+ const rawPayloadChain = isRecord(workspaceRaw) ? workspaceRaw['payloadChain'] : null;
493
494
  return {
494
495
  id,
495
496
  kind,
@@ -507,6 +508,10 @@ export function normalizeManifestEntry(value) {
507
508
  ...workspace,
508
509
  payload: typeof wsPayload === 'string' ? wsPayload : null,
509
510
  payloadBytes: typeof rawPayloadBytes === 'number' && Number.isFinite(rawPayloadBytes) && rawPayloadBytes >= 0 ? rawPayloadBytes : 0,
511
+ // 只保留形状合法的哈希;读不回来的链由解析器如实失败(绝不猜)
512
+ ...(Array.isArray(rawPayloadChain)
513
+ ? { payloadChain: rawPayloadChain.filter((h) => typeof h === 'string' && OBJECT_KEY_RE.test(h)) }
514
+ : {}),
510
515
  },
511
516
  session: { ...session, object: typeof sessionObject === 'string' ? sessionObject : null },
512
517
  segments: [first, second],
@@ -532,6 +537,26 @@ function normalizeWorkspacePayload(value) {
532
537
  return null;
533
538
  return value;
534
539
  }
540
+ /**
541
+ * **存盘**形状校验:完整清单(`files` 是数组)或增量视图(`delta` 是对象、`files` 故意留 null)。
542
+ * 与 {@link normalizeWorkspacePayload}(消费侧,要求物化后的完整形状)分开:增量载荷**绝不会**被当成完整清单读出去。
543
+ */
544
+ function normalizeStoredPayload(value) {
545
+ const base = normalizeWorkspacePayload(value);
546
+ if (base !== null)
547
+ return base;
548
+ if (!isRecord(value))
549
+ return null;
550
+ const delta = value['delta'];
551
+ if (!isRecord(delta))
552
+ return null;
553
+ if (typeof delta['base'] !== 'string')
554
+ return null;
555
+ if (!Array.isArray(delta['added']) || !Array.isArray(delta['removed']))
556
+ return null;
557
+ const clone = { ...value, files: [] };
558
+ return normalizeWorkspacePayload(clone);
559
+ }
535
560
  function normalizeSessionPayload(value) {
536
561
  if (!isRecord(value))
537
562
  return null;
@@ -743,7 +768,7 @@ export class RollbackLedger {
743
768
  }
744
769
  /* -------------------------------------------------------- 单条记录 */
745
770
  /** 把完整记录拆成「清单条目 + 记录文件内容 + 载荷文件内容」(**不做 IO**,纯函数) */
746
- prepareRecord(record) {
771
+ prepareRecord(record, reuse) {
747
772
  const summary = this.summarize(record);
748
773
  const workspaceTrack = record.workspace;
749
774
  const sessionTrack = record.session;
@@ -751,7 +776,12 @@ export class RollbackLedger {
751
776
  let payloadJson = null;
752
777
  let payloadHash = null;
753
778
  let payloadBytes = 0;
754
- if (payload !== null) {
779
+ if (payload !== null && reuse !== undefined) {
780
+ // **复用**:这份工作区态与基准点逐条相同 ⇒ 直接引用它已经落盘的载荷(零额外字节,链不增长)
781
+ payloadHash = reuse.hash;
782
+ payloadBytes = reuse.bytes;
783
+ }
784
+ else if (payload !== null) {
755
785
  payloadJson = JSON.stringify(payload);
756
786
  payloadHash = sha256Hex(payloadJson);
757
787
  payloadBytes = byteLength(payloadJson);
@@ -773,6 +803,14 @@ export class RollbackLedger {
773
803
  workspace: { ...trackHeader(workspaceTrack), payload: payloadHash },
774
804
  session: { ...trackHeader(sessionTrack), payload: sessionTrack.payload },
775
805
  });
806
+ // 增量视图 ⇒ 清单条目带上「解析所需的全部载荷哈希」(自身 + 基准链),回收按链保活
807
+ const payloadChain = payloadHash === null
808
+ ? undefined
809
+ : reuse !== undefined
810
+ ? (reuse.chain !== undefined && reuse.chain.length > 0 ? [...reuse.chain] : [payloadHash])
811
+ : payload?.delta === undefined
812
+ ? [payloadHash]
813
+ : [payloadHash, ...payload.delta.baseChain];
776
814
  const entry = {
777
815
  id: record.id,
778
816
  kind: record.kind,
@@ -786,7 +824,7 @@ export class RollbackLedger {
786
824
  turnSeq: record.turnSeq,
787
825
  boundarySeq: record.boundarySeq,
788
826
  recordBytes: byteLength(recordJson),
789
- workspace: { ...trackHeader(workspaceTrack), payload: payloadHash, payloadBytes },
827
+ workspace: { ...trackHeader(workspaceTrack), payload: payloadHash, payloadBytes, ...(payloadChain === undefined ? {} : { payloadChain }) },
790
828
  session: { ...trackHeader(sessionTrack), object: sessionTrack.payload?.object ?? null },
791
829
  segments: summary.segments,
792
830
  };
@@ -813,7 +851,12 @@ export class RollbackLedger {
813
851
  await this.write(recordFile, Buffer.from(prepared.recordJson, 'utf8'));
814
852
  }
815
853
  /** 读校验过的载荷(哈希不符 / 形状不符 -> error,绝不返回半个) */
816
- async readVerifiedPayload(hash) {
854
+ /**
855
+ * **原始**读取一份载荷(形状校验 + 内容寻址自证)。
856
+ * `files` 可能只是「相对基准的增量子集」(`payload.delta` 非空)—— **只给解析器用**,
857
+ * 消费者一律走 {@link resolvePayload} 拿物化后的完整清单。
858
+ */
859
+ async readStoredPayload(hash) {
817
860
  const target = this.payloadPath(hash);
818
861
  if (target === null)
819
862
  return { error: 'record-incomplete: payload ref invalid' };
@@ -834,11 +877,64 @@ export class RollbackLedger {
834
877
  catch (error) {
835
878
  return { error: 'record-incomplete: payload unreadable (' + errorDetail(error) + ')' };
836
879
  }
837
- const payload = normalizeWorkspacePayload(parsed);
880
+ const payload = normalizeStoredPayload(parsed);
838
881
  if (payload === null)
839
882
  return { error: 'record-incomplete: payload shape invalid' };
840
883
  return { payload };
841
884
  }
885
+ /**
886
+ * 解析一份载荷:增量视图**物化**成完整 `files`(含 `object` 字段补回),全量视图原样返回。
887
+ *
888
+ * 任何一环不成立(基准缺失 / 哈希不符 / 形状不符 / 链成环)⇒ **如实失败**:绝不返回半个清单 ——
889
+ * 半份清单会让「留点后新建」的判据把绝大多数文件当成新建的,回退就会删掉它们。
890
+ */
891
+ async resolvePayload(hash) {
892
+ // **不缓存**:载荷被删/被改必须立刻看得见(缓存会把「存储已经坏了」蒙过去),
893
+ // 也守住「一次只驻留一份载荷」的内存护栏(issue #89 的 OOM 现场)。
894
+ return this.materializePayload(hash, new Set([hash]));
895
+ }
896
+ async materializePayload(hash, seen) {
897
+ const stored = await this.readStoredPayload(hash);
898
+ if (stored.payload === undefined)
899
+ return { error: stored.error ?? 'record-incomplete: payload unreadable' };
900
+ const payload = stored.payload;
901
+ const delta = payload.delta;
902
+ if (delta === undefined)
903
+ return { payload: withObjectRefs(payload) };
904
+ if (typeof delta.base !== 'string' || !OBJECT_KEY_RE.test(delta.base)) {
905
+ return { error: 'record-incomplete: delta base ref invalid' };
906
+ }
907
+ if (seen.has(delta.base))
908
+ return { error: 'record-incomplete: delta chain cycle' };
909
+ const next = new Set(seen);
910
+ next.add(delta.base);
911
+ const base = await this.materializePayload(delta.base, next);
912
+ if (base.payload === undefined) {
913
+ return { error: 'record-incomplete: delta base unresolved (' + String(base.error ?? '') + ')' };
914
+ }
915
+ const removed = new Set();
916
+ for (const p of Array.isArray(delta.removed) ? delta.removed : [])
917
+ if (typeof p === 'string')
918
+ removed.add(p);
919
+ const added = withObjectRefs({ ...payload, files: delta.added ?? [], delta: undefined }).files;
920
+ const addedPaths = new Set();
921
+ for (const file of added)
922
+ if (typeof file?.path === 'string')
923
+ addedPaths.add(file.path);
924
+ const files = [];
925
+ for (const file of base.payload.files) {
926
+ if (typeof file?.path !== 'string')
927
+ continue;
928
+ if (removed.has(file.path) || addedPaths.has(file.path))
929
+ continue;
930
+ files.push(file);
931
+ }
932
+ for (const file of added)
933
+ files.push(file);
934
+ // 与全量捕获同序(`payload.files.sort` 用的就是这个比较器)
935
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
936
+ return { payload: { ...payload, files, delta: undefined } };
937
+ }
842
938
  /** 读一条**完整**记录(引用解析对调用方透明);任何一步不成立就如实失败 */
843
939
  async readRecord(id) {
844
940
  const target = this.recordPath(id);
@@ -870,7 +966,7 @@ export class RollbackLedger {
870
966
  if (typeof payloadRef !== 'string' || !OBJECT_KEY_RE.test(payloadRef)) {
871
967
  return { error: 'record-incomplete: payload ref invalid' };
872
968
  }
873
- const resolved = await this.readVerifiedPayload(payloadRef);
969
+ const resolved = await this.resolvePayload(payloadRef);
874
970
  if (resolved.payload === undefined)
875
971
  return { error: resolved.error ?? 'record-incomplete: payload unreadable' };
876
972
  payload = resolved.payload;
@@ -935,6 +1031,13 @@ export class RollbackLedger {
935
1031
  async prunePayloads(entries) {
936
1032
  const referenced = new Set();
937
1033
  for (const entry of entries) {
1034
+ // 增量载荷依赖基准 ⇒ **按链保活**(否则淘汰/删点会把基准删掉,那个点就永远读不回来了)
1035
+ const chain = entry.workspace.payloadChain;
1036
+ if (Array.isArray(chain) && chain.length > 0) {
1037
+ for (const hash of chain)
1038
+ referenced.add(hash);
1039
+ continue;
1040
+ }
938
1041
  if (entry.workspace.payload !== null)
939
1042
  referenced.add(entry.workspace.payload);
940
1043
  }
@@ -986,7 +1089,7 @@ export class RollbackLedger {
986
1089
  for (const entry of entries) {
987
1090
  const hash = entry.workspace.payload;
988
1091
  if (hash !== null) {
989
- const resolved = await this.readVerifiedPayload(hash);
1092
+ const resolved = await this.resolvePayload(hash);
990
1093
  if (resolved.payload === undefined) {
991
1094
  return { removed: 0, bytes: 0, referencedPaths, aborted: resolved.error ?? 'payload unreadable' };
992
1095
  }
@@ -1272,6 +1375,21 @@ function trackHeader(track) {
1272
1375
  reasonDetail: track.reasonDetail,
1273
1376
  };
1274
1377
  }
1378
+ /**
1379
+ * 补回**落盘时省掉的** `object` 字段:内容寻址的名字就是内容哈希 ⇒ `object ≡ sha256`。
1380
+ * 缺 `sha256` 的畸形条目保持原样(消费侧的防御判据照旧生效)。
1381
+ */
1382
+ function withObjectRefs(payload) {
1383
+ for (const file of payload.files) {
1384
+ if (file === null || typeof file !== 'object')
1385
+ continue;
1386
+ if (typeof file.object === 'string')
1387
+ continue;
1388
+ if (typeof file.sha256 === 'string')
1389
+ file.object = file.sha256;
1390
+ }
1391
+ return payload;
1392
+ }
1275
1393
  function errorDetail(error) {
1276
1394
  if (error instanceof Error) {
1277
1395
  const code = error.code;
@@ -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;