dsh-config-manager 0.1.76 → 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.
- 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.js +92 -12
- package/lib/client.d.ts +104 -9
- package/lib/client.js +1796 -714
- package/lib/core/messages.d.ts +1 -0
- package/lib/core/messages.js +2 -0
- 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 +18 -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/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.ts +98 -13
- 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/messages.ts +2 -0
- 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 +19 -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/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
|
@@ -72,6 +72,7 @@ import { EXCLUDED_DEP_DIR_NAMES } from '../utils/recursive-walk.js';
|
|
|
72
72
|
import { diffLines } from '../utils/line-diff.js';
|
|
73
73
|
import { projectKeyOf } from './session-select.js';
|
|
74
74
|
import { ignoreRelativePath, ignoreRuleMatches, loadIgnoreRules } from './rollback-ignore-rules.js';
|
|
75
|
+
import { mergeThreeWay } from './rollback-merge.js';
|
|
75
76
|
import { isSessionLogName } from '../utils/session-log.js';
|
|
76
77
|
import { decodeZstdFrame, scanZstdFrames, zstdAvailable, ZSTD_MAGIC } from '../utils/zstd-frame.js';
|
|
77
78
|
import { cleanupOrphanTempFiles, entrySummaryOf, ROLLBACK_DEFAULT_LEDGER_BYTES, ROLLBACK_UNDO_DIR_NAME, RollbackLedger, } from './rollback-ledger.js';
|
|
@@ -853,6 +854,8 @@ export class RollbackPointEngine {
|
|
|
853
854
|
ledgerSnapshotCache = null;
|
|
854
855
|
/** 台账存储层(v3:清单 + 单条记录 + 内容寻址载荷;issue #89) */
|
|
855
856
|
ledger;
|
|
857
|
+
/** 写入归属(2026-10-17 定案 B)。undefined = 没有归属信息 ⇒ 预览不标 foreign */
|
|
858
|
+
attribution;
|
|
856
859
|
ledgerBytesBudget;
|
|
857
860
|
/**
|
|
858
861
|
* 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS)。**可变**:
|
|
@@ -908,6 +911,7 @@ export class RollbackPointEngine {
|
|
|
908
911
|
this.maxGuards = options.maxGuards ?? ROLLBACK_MAX_GUARDS;
|
|
909
912
|
this.guardPolicy = options.guardPolicy ?? 'warn';
|
|
910
913
|
this.probe = options.storage;
|
|
914
|
+
this.attribution = options.attribution;
|
|
911
915
|
this.forkSeam = options.fork;
|
|
912
916
|
this.writeSeam = options.writeFile;
|
|
913
917
|
this.readSeam = options.readFile;
|
|
@@ -2793,6 +2797,27 @@ export class RollbackPointEngine {
|
|
|
2793
2797
|
return previewCountsUnavailable();
|
|
2794
2798
|
return { added: counts.added, removed: counts.removed, truncated: false };
|
|
2795
2799
|
}
|
|
2800
|
+
/**
|
|
2801
|
+
* **合并结果**的逐文件行级统计(与 `previewFileCounts` 同一套预算与降级判据)。
|
|
2802
|
+
*
|
|
2803
|
+
* 为什么单独一份:合并后要写进去的内容既不是记录里的原文(ours),也不是磁盘现状(theirs)——
|
|
2804
|
+
* 预览若照旧拿 ours 算差异,就会把「其实会被保留的对方改动」显示成「会被删掉」。
|
|
2805
|
+
*/
|
|
2806
|
+
async previewMergeCounts(targetBytes, current, budget) {
|
|
2807
|
+
if (targetBytes.byteLength > this.previewDiffMaxFileBytes || current.byteLength > this.previewDiffMaxFileBytes) {
|
|
2808
|
+
return previewCountsUnavailable();
|
|
2809
|
+
}
|
|
2810
|
+
if (budget.bytes + targetBytes.byteLength + current.byteLength > this.previewDiffMaxTotalBytes) {
|
|
2811
|
+
return previewCountsUnavailable();
|
|
2812
|
+
}
|
|
2813
|
+
budget.bytes += targetBytes.byteLength + current.byteLength;
|
|
2814
|
+
if (looksBinary(targetBytes) || looksBinary(current))
|
|
2815
|
+
return previewCountsUnavailable();
|
|
2816
|
+
const counts = lineDiffCounts(splitDiffLines(DIFF_TEXT_DECODER.decode(current)), splitDiffLines(DIFF_TEXT_DECODER.decode(targetBytes)), this.previewDiffMaxWork, this.previewDiffTotalWork, budget);
|
|
2817
|
+
if (counts === null)
|
|
2818
|
+
return previewCountsUnavailable();
|
|
2819
|
+
return { added: counts.added, removed: counts.removed, truncated: false };
|
|
2820
|
+
}
|
|
2796
2821
|
/** 只读预览:零写入(不过确认门、不拍 guard、不写任何字节)。 */
|
|
2797
2822
|
async preview(id) {
|
|
2798
2823
|
const storage = await this.storageStatus();
|
|
@@ -2848,8 +2873,24 @@ export class RollbackPointEngine {
|
|
|
2848
2873
|
let missing = 0;
|
|
2849
2874
|
// E1:逐文件行级统计 —— **只列会被改动的文件**(内容已一致的一个都不列)
|
|
2850
2875
|
const changedFiles = [];
|
|
2876
|
+
// 写入归属(2026-10-17 定案 B):只有**确知**最后写入者是别的会话、且写入发生在留点**之后**时,
|
|
2877
|
+
// 才敢标 foreign。缺归属 / 归属就是本会话 / 写入早于留点 ⇒ 一律不标
|
|
2878
|
+
// (未判定 ≠ 别的会话写的;宁可少标一次,也不误判一次)。
|
|
2879
|
+
const foreignWriterOf = (target) => {
|
|
2880
|
+
const writer = this.attribution?.writerOf(target);
|
|
2881
|
+
if (writer === undefined)
|
|
2882
|
+
return undefined;
|
|
2883
|
+
if (writer.sessionId === record.sessionId)
|
|
2884
|
+
return undefined;
|
|
2885
|
+
if (writer.at <= wsTrack.capturedAt)
|
|
2886
|
+
return undefined;
|
|
2887
|
+
return writer;
|
|
2888
|
+
};
|
|
2851
2889
|
// 单张预览共享的比对预算(字节 + 步数);只有真的会被改动的条目才消耗它
|
|
2852
2890
|
const diffBudget = { bytes: 0, work: 0 };
|
|
2891
|
+
/** 合并判定的会话身份与 base 缓存(与执行端同一判据:拿目标点的会话当「本会话」) */
|
|
2892
|
+
const previewIsolationSessionId = typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null;
|
|
2893
|
+
const previewBaseCache = new Map();
|
|
2853
2894
|
// T10:stat 的三种结局分开表达 —— 「不存在(ENOENT)」与「存在性未知(其它 errno)」绝不混为一谈
|
|
2854
2895
|
const previewFiles = wsPayload.files;
|
|
2855
2896
|
// 阶段 A(**并发**):逐条一次 bigint lstat + 身份快路径。大仓库这里是预览的大头:
|
|
@@ -2885,7 +2926,12 @@ export class RollbackPointEngine {
|
|
|
2885
2926
|
// 计入 different 的理由:回退一定会尝试写它(restoreWorkspace 里「已一致」判定失败即视为需要写),
|
|
2886
2927
|
// 这样 overwrite = 已一致 + different + missing 的算术仍然成立。
|
|
2887
2928
|
different += 1;
|
|
2888
|
-
|
|
2929
|
+
const foreignMissing = foreignWriterOf(file.path);
|
|
2930
|
+
changedFiles.push({
|
|
2931
|
+
path: file.path, status: 'overwrite',
|
|
2932
|
+
...(foreignMissing === undefined ? {} : { foreign: true, foreignSessionId: foreignMissing.sessionId, foreignAt: foreignMissing.at }),
|
|
2933
|
+
...previewCountsUnavailable(),
|
|
2934
|
+
});
|
|
2889
2935
|
continue;
|
|
2890
2936
|
}
|
|
2891
2937
|
// 身份快路径已证明「与记录一致」⇒ 一个字段都不改(内容一致的文件不列进列表)
|
|
@@ -2907,10 +2953,43 @@ export class RollbackPointEngine {
|
|
|
2907
2953
|
}
|
|
2908
2954
|
if (sizeMatches && current !== null && sha256Hex(current) === file.sha256)
|
|
2909
2955
|
continue;
|
|
2956
|
+
// **合并判定与执行同口径**(2026-10-17 修):预览必须说清「这次到底会写成什么」。
|
|
2957
|
+
// 之前预览只按「覆盖回记录版本」算差异,而执行端会先做三方合并 ⇒ 预览把对方后来的行
|
|
2958
|
+
// 也算成「会被删掉」,用户据此以为会丢别人的改动(真机:两行描述都显示要删,实际只删 A 那行)。
|
|
2959
|
+
const decision = await (async () => {
|
|
2960
|
+
if (current === null || previewIsolationSessionId === null)
|
|
2961
|
+
return { mode: 'overwrite' };
|
|
2962
|
+
let baseText = previewBaseCache.get(pathKey(file.path));
|
|
2963
|
+
if (baseText === undefined) {
|
|
2964
|
+
baseText = await this.baseVersionFor(file.path, wsTrack.capturedAt, previewIsolationSessionId);
|
|
2965
|
+
previewBaseCache.set(pathKey(file.path), baseText);
|
|
2966
|
+
}
|
|
2967
|
+
const writer = this.attribution?.writerOf(file.path);
|
|
2968
|
+
const foreignEvidence = writer !== undefined && writer.sessionId !== previewIsolationSessionId && writer.at > wsTrack.capturedAt;
|
|
2969
|
+
if (baseText === null && !foreignEvidence)
|
|
2970
|
+
return { mode: 'overwrite' };
|
|
2971
|
+
const outcome = await this.mergeForeignRecord(file, baseText, foreignEvidence);
|
|
2972
|
+
if (outcome.kind === 'write')
|
|
2973
|
+
return { mode: 'merged', bytes: outcome.bytes };
|
|
2974
|
+
if (outcome.kind === 'unchanged')
|
|
2975
|
+
return { mode: 'unchanged' };
|
|
2976
|
+
if (outcome.kind === 'blocked')
|
|
2977
|
+
return { mode: 'skip', reason: outcome.reason };
|
|
2978
|
+
return { mode: 'overwrite' };
|
|
2979
|
+
})();
|
|
2980
|
+
// 合并结论是「保持现状」⇒ 它不会被改动,不该出现在「会被改动」的清单里
|
|
2981
|
+
if (decision.mode === 'unchanged')
|
|
2982
|
+
continue;
|
|
2910
2983
|
different += 1;
|
|
2984
|
+
const foreignWriter = foreignWriterOf(file.path);
|
|
2911
2985
|
changedFiles.push({
|
|
2912
2986
|
path: file.path, status: 'overwrite',
|
|
2913
|
-
...(
|
|
2987
|
+
...(foreignWriter === undefined ? {} : { foreign: true, foreignSessionId: foreignWriter.sessionId, foreignAt: foreignWriter.at }),
|
|
2988
|
+
...(decision.mode === 'skip'
|
|
2989
|
+
? { skipped: true, mergeReason: decision.reason, ...previewCountsUnavailable() }
|
|
2990
|
+
: decision.mode === 'merged' && decision.bytes !== undefined && current !== null
|
|
2991
|
+
? await this.previewMergeCounts(decision.bytes, current, diffBudget)
|
|
2992
|
+
: await this.previewFileCounts(file, current, 'overwrite', diffBudget)),
|
|
2914
2993
|
});
|
|
2915
2994
|
}
|
|
2916
2995
|
const sink = { skipped: [], excludedDirs: [], ignoredDirs: [], ignoredDirCount: 0, unreadableDirs: [], skippedCount: 0, skippedTruncated: false };
|
|
@@ -3068,6 +3147,37 @@ export class RollbackPointEngine {
|
|
|
3068
3147
|
else
|
|
3069
3148
|
currentUnreadable = true;
|
|
3070
3149
|
}
|
|
3150
|
+
// **合并判定与执行同口径**(2026-10-17 修):「回退后」一侧必须显示这次**真正会写进去**的内容。
|
|
3151
|
+
// 以前它恒定显示记录里的原文(= 整文件覆盖的样子),于是预览列表写着「+0 −2」,点开「查看差异」
|
|
3152
|
+
// 却还是「−1,4 +1,1」—— 同一件事两个说法(真机 2026-10-17 用户当场发现)。
|
|
3153
|
+
let mergedAfter = null;
|
|
3154
|
+
/** true 只表示「after 侧真的是合并算出来的」——「不会改动」时 after 也是现状,但不算合并 */
|
|
3155
|
+
let mergedFlag = false;
|
|
3156
|
+
let mergeSkippedReason = null;
|
|
3157
|
+
const isolationSessionId = typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null;
|
|
3158
|
+
if (current !== null && snapshot !== null && isolationSessionId !== null) {
|
|
3159
|
+
const baseText = await this.baseVersionFor(file.path, record.workspace.capturedAt, isolationSessionId);
|
|
3160
|
+
const writer = this.attribution?.writerOf(file.path);
|
|
3161
|
+
const foreignEvidence = writer !== undefined && writer.sessionId !== isolationSessionId && writer.at > record.workspace.capturedAt;
|
|
3162
|
+
if (baseText !== null || foreignEvidence) {
|
|
3163
|
+
const outcome = await this.mergeForeignRecord(file, baseText, foreignEvidence);
|
|
3164
|
+
if (outcome.kind === 'write') {
|
|
3165
|
+
mergedAfter = outcome.bytes;
|
|
3166
|
+
mergedFlag = true;
|
|
3167
|
+
}
|
|
3168
|
+
// 合并结论 = 保持现状 ⇒ 「回退后」就是当前内容(该文件根本不会被写)
|
|
3169
|
+
else if (outcome.kind === 'unchanged')
|
|
3170
|
+
mergedAfter = current;
|
|
3171
|
+
else if (outcome.kind === 'blocked') {
|
|
3172
|
+
// 冲突 / 无法安全合并 ⇒ 这次**不会写**它:「回退后」就是现状(两侧相同),
|
|
3173
|
+
// 界面据此说「不会改动」,绝不显示成「会被覆盖成记录原文」。
|
|
3174
|
+
mergeSkippedReason = outcome.reason;
|
|
3175
|
+
mergedAfter = current;
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
/** 回退后一侧的字节:合并结果优先,其次才是记录里的原文 */
|
|
3180
|
+
const afterBytes = mergedAfter ?? snapshot;
|
|
3071
3181
|
const before = {
|
|
3072
3182
|
exists: currentExists, bytes: currentBytes, lines: null,
|
|
3073
3183
|
binary: false, oversized: currentOversized, unreadable: currentUnreadable,
|
|
@@ -3077,17 +3187,19 @@ export class RollbackPointEngine {
|
|
|
3077
3187
|
before.lines = before.binary ? null : splitDiffLines(DIFF_TEXT_DECODER.decode(current)).length;
|
|
3078
3188
|
}
|
|
3079
3189
|
const after = {
|
|
3080
|
-
exists:
|
|
3190
|
+
exists: afterBytes !== null, bytes: afterBytes?.byteLength ?? file.bytes, lines: null,
|
|
3081
3191
|
binary: false, oversized: snapshotOversized,
|
|
3082
|
-
unreadable:
|
|
3192
|
+
unreadable: afterBytes === null && !snapshotOversized,
|
|
3083
3193
|
};
|
|
3084
|
-
if (
|
|
3085
|
-
after.binary = looksBinary(
|
|
3086
|
-
after.lines = after.binary ? null : splitDiffLines(DIFF_TEXT_DECODER.decode(
|
|
3194
|
+
if (afterBytes !== null) {
|
|
3195
|
+
after.binary = looksBinary(afterBytes);
|
|
3196
|
+
after.lines = after.binary ? null : splitDiffLines(DIFF_TEXT_DECODER.decode(afterBytes)).length;
|
|
3087
3197
|
}
|
|
3088
3198
|
const view = {
|
|
3089
3199
|
...base, ok: true, code: 'ok', path: file.path,
|
|
3090
3200
|
status: currentExists ? 'overwrite' : 'create', before, after,
|
|
3201
|
+
...(mergedFlag ? { merged: true } : {}),
|
|
3202
|
+
...(mergeSkippedReason === null ? {} : { mergeSkipped: true, mergeReason: mergeSkippedReason }),
|
|
3091
3203
|
};
|
|
3092
3204
|
// 结构性不可比对:逐条给出原因(有原因 ⇒ hunks 恒为空,绝不给半截差异)
|
|
3093
3205
|
if (snapshotOversized)
|
|
@@ -3100,7 +3212,7 @@ export class RollbackPointEngine {
|
|
|
3100
3212
|
return { ...view, reason: 'too-large' };
|
|
3101
3213
|
if (before.binary || after.binary)
|
|
3102
3214
|
return { ...view, reason: 'binary' };
|
|
3103
|
-
const result = diffLines(current === null ? '' : DIFF_TEXT_DECODER.decode(current), DIFF_TEXT_DECODER.decode(
|
|
3215
|
+
const result = diffLines(current === null ? '' : DIFF_TEXT_DECODER.decode(current), afterBytes === null ? '' : DIFF_TEXT_DECODER.decode(afterBytes), { context: ROLLBACK_FILE_DIFF_LIMITS.context, maxD: ROLLBACK_FILE_DIFF_LIMITS.maxD });
|
|
3104
3216
|
return {
|
|
3105
3217
|
...view,
|
|
3106
3218
|
hunks: result.hunks, added: result.added, removed: result.removed,
|
|
@@ -3214,7 +3326,10 @@ export class RollbackPointEngine {
|
|
|
3214
3326
|
continue;
|
|
3215
3327
|
}
|
|
3216
3328
|
const segment = state === 'workspace'
|
|
3217
|
-
|
|
3329
|
+
// 路径级选择(2026-10-17 定案 A)只在显式给出时透传 —— 缺省口径一字不变
|
|
3330
|
+
? await this.restoreWorkspace(record.workspace, input.onlyPaths === undefined
|
|
3331
|
+
? { removeCreated: input.removeCreated === true, sessionId: record.sessionId ?? '' }
|
|
3332
|
+
: { removeCreated: input.removeCreated === true, onlyPaths: input.onlyPaths, sessionId: record.sessionId ?? '' })
|
|
3218
3333
|
: await this.restoreSession(record.session);
|
|
3219
3334
|
segments.push(segment);
|
|
3220
3335
|
if (segment.childSessionId !== undefined)
|
|
@@ -3303,6 +3418,158 @@ export class RollbackPointEngine {
|
|
|
3303
3418
|
return 'excluded-dir';
|
|
3304
3419
|
return null;
|
|
3305
3420
|
}
|
|
3421
|
+
/**
|
|
3422
|
+
* 把一条「留点之后被**别的会话**写过」的记录做成三方合并结果(2026-10-17 跨对话安全回退)。
|
|
3423
|
+
*
|
|
3424
|
+
* 三方:`ours` = 记录里的内容(回退目标)、`base` = 目标点之后**最早**那份中间版本、
|
|
3425
|
+
* `theirs` = 当前磁盘。结果是「撤销本会话那次改动 + 保留对方后来的改动」。
|
|
3426
|
+
*
|
|
3427
|
+
* 返回:`write`(合并干净且确实要写,带目标哈希 + 期望的当前哈希)、
|
|
3428
|
+
* `unchanged`(合并结果就是当前内容 —— 对方没动这一处,本来就不需要写)、
|
|
3429
|
+
* `blocked`(不能安全处理:冲突 / 没有中间版本 / 二进制 / 差异被降级 / 对方改成了不存在 / 读不出)。
|
|
3430
|
+
*/
|
|
3431
|
+
async mergeForeignRecord(file, baseText, foreignEvidence) {
|
|
3432
|
+
let oursBytes;
|
|
3433
|
+
try {
|
|
3434
|
+
oursBytes = await this.readObject(file.object);
|
|
3435
|
+
if (sha256Hex(oursBytes) !== file.sha256)
|
|
3436
|
+
return { kind: 'blocked', reason: 'ours-unreadable' };
|
|
3437
|
+
}
|
|
3438
|
+
catch {
|
|
3439
|
+
return { kind: 'blocked', reason: 'ours-unreadable' };
|
|
3440
|
+
}
|
|
3441
|
+
let theirsBytes;
|
|
3442
|
+
try {
|
|
3443
|
+
theirsBytes = await fs.readFile(file.path);
|
|
3444
|
+
}
|
|
3445
|
+
catch (error) {
|
|
3446
|
+
return { kind: 'blocked', reason: error.code === 'ENOENT' ? 'theirs-missing' : 'theirs-unreadable' };
|
|
3447
|
+
}
|
|
3448
|
+
const oursText = Buffer.from(oursBytes).toString('utf8');
|
|
3449
|
+
// **base 必须真的携带「本会话改动之后」的信息**:若它与 ours(回退目标)逐字相同,说明它
|
|
3450
|
+
// 提供不了任何「本会话改了什么」的知识 —— 拿它当共同祖先会让合并结论变成「保持当前」,
|
|
3451
|
+
// 于是「回退到该点」成了「什么都不做」(真机 R-25 就是这个形态被抓出来的:guard 之后
|
|
3452
|
+
// 那个点的内容与目标点相同)。
|
|
3453
|
+
if (baseText === null || baseText === oursText) {
|
|
3454
|
+
// 有「别人写过」的旁证却没有可用祖先 ⇒ 无法安全撤销,宁可不动它并如实报告;
|
|
3455
|
+
// 两样都没有 ⇒ 退回旧口径(整文件覆盖回目标版本)—— 那正是「纯本会话回退」的常见形态。
|
|
3456
|
+
if (foreignEvidence)
|
|
3457
|
+
return { kind: 'blocked', reason: 'no-base' };
|
|
3458
|
+
return { kind: 'fallback' };
|
|
3459
|
+
}
|
|
3460
|
+
// 二进制:任何一侧含 NUL 就绝不按文本合并(三方合并算法只对文本有定义)
|
|
3461
|
+
if (oursBytes.includes(0) || theirsBytes.includes(0) || baseText.includes('\u0000'))
|
|
3462
|
+
return { kind: 'blocked', reason: 'binary' };
|
|
3463
|
+
const theirsText = Buffer.from(theirsBytes).toString('utf8');
|
|
3464
|
+
const result = mergeThreeWay(oursText, baseText, theirsText);
|
|
3465
|
+
if (!result.ok) {
|
|
3466
|
+
return { kind: 'blocked', reason: result.reason === 'conflict' ? 'conflict' : result.reason === 'degraded' ? 'degraded' : 'binary' };
|
|
3467
|
+
}
|
|
3468
|
+
// 调用方只在「当前内容 ≠ 记录内容」时才会走到这里 ⇒ `ours === theirs` 只可能是内容恰好相同
|
|
3469
|
+
if (oursText === theirsText)
|
|
3470
|
+
return { kind: 'unchanged' };
|
|
3471
|
+
if (result.text === theirsText) {
|
|
3472
|
+
// **合并结论 = 保持现状**:这次回退对工作区什么也做不了 —— 但 ours 与 theirs 明明不同
|
|
3473
|
+
//(否则上面那条就返回了)。真机 2026-10-17 的形状:base 取到了目标点**之后**的另一份快照
|
|
3474
|
+
//(里面已经含有「要撤销的那部分改动」),于是「应用对方的改动到 ours」恰好又变回现状。
|
|
3475
|
+
// 绝不能把它当成「无需改动」静默跳过 —— 那会让「回退」整个失效、界面上还看不到任何痕迹
|
|
3476
|
+
//(预览报 0 个文件、执行返回 nothing-to-restore)。这里按**无法安全撤销**如实处理:
|
|
3477
|
+
// 一个字节都不写,让用户看到并自己决定。
|
|
3478
|
+
return { kind: 'blocked', reason: 'ambiguous' };
|
|
3479
|
+
}
|
|
3480
|
+
const bytes = Buffer.from(result.text, 'utf8');
|
|
3481
|
+
return { kind: 'write', bytes, sha256: sha256Hex(bytes), expectedCurrent: sha256Hex(theirsBytes) };
|
|
3482
|
+
}
|
|
3483
|
+
/**
|
|
3484
|
+
* 三方合并的 **base**:目标点之后**最早**那份仍含该路径的中间版本(共同祖先)。
|
|
3485
|
+
*
|
|
3486
|
+
* 为什么这么取:回滚点的「工作区态」是回合开始前拍的 ⇒ 别的会话那个回合的点,
|
|
3487
|
+
* 正好是「本会话改动之后、对方改动之前」的版本 —— 标准三方合并的共同祖先。
|
|
3488
|
+
* 取**最早**而不是最近是刻意选安全方向:宁可少撤销,也绝不把对方更晚的改动算进 ours 侧。
|
|
3489
|
+
*
|
|
3490
|
+
* 排除四类:目标点自己(`createdAt > afterAt`)、**救援点 guard**(它在执行回退那一刻拍,
|
|
3491
|
+
* 内容 ≈ 当前磁盘,拿它当 base 会让合并结果直接等于 ours = 整文件覆盖,正是本机制要消灭的形态)、
|
|
3492
|
+
* **本会话的点**(见上)、以及内容读不出来的点(继续往后找 —— 找不到 ⇒ 调用方按「无法安全撤销」
|
|
3493
|
+
* 处理,绝不用近似值代替)。
|
|
3494
|
+
*/
|
|
3495
|
+
async baseVersionFor(target, afterAt, excludeSessionId) {
|
|
3496
|
+
const ledger = await this.readManifest();
|
|
3497
|
+
const key = pathKey(target);
|
|
3498
|
+
const candidates = ledger.entries
|
|
3499
|
+
.filter((entry) => entry.createdAt > afterAt
|
|
3500
|
+
&& entry.kind !== 'guard'
|
|
3501
|
+
// 只认**别的会话**的点:本会话后续回合的点当作 base,会把「本会话后续改动」也一并保留下来,
|
|
3502
|
+
// 那与「回退到该点之前」的直觉冲突;而别人的点正好是「本会话改动之后、对方改动之前」的版本。
|
|
3503
|
+
&& (excludeSessionId === null || entry.sessionId !== excludeSessionId))
|
|
3504
|
+
.sort((left, right) => (left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0));
|
|
3505
|
+
for (const entry of candidates) {
|
|
3506
|
+
const resolved = await this.readFullRecord(entry.id);
|
|
3507
|
+
const payload = resolved.record?.workspace.payload;
|
|
3508
|
+
if (payload === null || payload === undefined)
|
|
3509
|
+
continue;
|
|
3510
|
+
const hit = payload.files.find((item) => pathKey(item.path) === key);
|
|
3511
|
+
if (hit === undefined)
|
|
3512
|
+
continue;
|
|
3513
|
+
try {
|
|
3514
|
+
const bytes = await this.readObject(hit.object);
|
|
3515
|
+
if (sha256Hex(bytes) !== hit.sha256)
|
|
3516
|
+
continue;
|
|
3517
|
+
return Buffer.from(bytes).toString('utf8');
|
|
3518
|
+
}
|
|
3519
|
+
catch {
|
|
3520
|
+
continue;
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
return null;
|
|
3524
|
+
}
|
|
3525
|
+
/**
|
|
3526
|
+
* **别的会话的回合点里记录过的路径集合** —— 删除集保护的第二种证据(不依赖归属台账)。
|
|
3527
|
+
*
|
|
3528
|
+
* 为什么需要第二证据:归属可能整条缺失(工具不在白名单 / 另一个 DSH 进程把 attribution.json
|
|
3529
|
+
* 整体覆盖写 / 采集时机错过),而「别人的点里出现过这个路径」是**点台账本身**记录的事实 ——
|
|
3530
|
+
* 它足以证明「这条路径在留点之后被别的会话所在的回合捕获过」,因此删它就是销毁别人的工作。
|
|
3531
|
+
*
|
|
3532
|
+
* 有界:只扫目标点之后**最早**的 `limit` 个别的会话的点(按时间升序),且只读路径不读内容。
|
|
3533
|
+
* 扫不完不影响安全方向(少保护也会被归属那条兜一部分),但绝不因此声称「已扫全」。
|
|
3534
|
+
*/
|
|
3535
|
+
async foreignRecordedPaths(afterAt, excludeSessionId, limit = 10) {
|
|
3536
|
+
const out = new Set();
|
|
3537
|
+
const ledger = await this.readManifest();
|
|
3538
|
+
const candidates = ledger.entries
|
|
3539
|
+
.filter((entry) => entry.createdAt > afterAt
|
|
3540
|
+
&& entry.kind !== 'guard'
|
|
3541
|
+
&& (excludeSessionId === null || entry.sessionId !== excludeSessionId))
|
|
3542
|
+
.sort((left, right) => (left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0))
|
|
3543
|
+
.slice(0, Math.max(1, limit));
|
|
3544
|
+
for (const entry of candidates) {
|
|
3545
|
+
const resolved = await this.readFullRecord(entry.id);
|
|
3546
|
+
const payload = resolved.record?.workspace.payload;
|
|
3547
|
+
if (payload === null || payload === undefined)
|
|
3548
|
+
continue;
|
|
3549
|
+
const files = payload.files;
|
|
3550
|
+
if (Array.isArray(files)) {
|
|
3551
|
+
for (const item of files) {
|
|
3552
|
+
const target = item.path;
|
|
3553
|
+
if (typeof target === 'string' && target !== '')
|
|
3554
|
+
out.add(pathKey(target));
|
|
3555
|
+
}
|
|
3556
|
+
continue;
|
|
3557
|
+
}
|
|
3558
|
+
const delta = payload.delta;
|
|
3559
|
+
if (delta === null || delta === undefined)
|
|
3560
|
+
continue;
|
|
3561
|
+
for (const bucket of [delta.added, delta.removed]) {
|
|
3562
|
+
if (!Array.isArray(bucket))
|
|
3563
|
+
continue;
|
|
3564
|
+
for (const item of bucket) {
|
|
3565
|
+
const target = typeof item === 'string' ? item : item?.path;
|
|
3566
|
+
if (typeof target === 'string' && target !== '')
|
|
3567
|
+
out.add(pathKey(target));
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
return out;
|
|
3572
|
+
}
|
|
3306
3573
|
async restoreWorkspace(track, options = {}) {
|
|
3307
3574
|
const payload = track.payload;
|
|
3308
3575
|
if (!track.ok || payload === null) {
|
|
@@ -3314,11 +3581,80 @@ export class RollbackPointEngine {
|
|
|
3314
3581
|
if (payload.files.length === 0)
|
|
3315
3582
|
return this.noopSegment('workspace', 'no-recorded-files', 'files=0 nothingToDo=true');
|
|
3316
3583
|
const detail = [];
|
|
3584
|
+
// 路径级选择性恢复(2026-10-17 定案 A):null = 全量(旧口径,行为一字不变)。
|
|
3585
|
+
const selection = options.onlyPaths === undefined ? null : new Set(options.onlyPaths.map((target) => pathKey(target)));
|
|
3586
|
+
/** 因用户没勾选而跳过的记录条目数(只作事实回传,不参与任何安全判据) */
|
|
3587
|
+
let selectionSkipped = 0;
|
|
3588
|
+
/**
|
|
3589
|
+
* 归属隔离(2026-10-17 定案 B):**没有显式点名**时,把「留点之后被别的会话写过」的记录条目
|
|
3590
|
+
* 自动跳过(上层据此如实报告)。只有 attribution 在、且这次回退知道自己属于哪个会话时才可能生效;
|
|
3591
|
+
* 缺任何一样 ⇒ 退化成旧口径(照常恢复)—— **未判定绝不当作别的会话写的**。
|
|
3592
|
+
*/
|
|
3593
|
+
let foreignSkipped = 0;
|
|
3594
|
+
const isolationSessionId = typeof options.sessionId === 'string' && options.sessionId !== '' ? options.sessionId : null;
|
|
3595
|
+
/**
|
|
3596
|
+
* **合并保护**(2026-10-17 修):触发条件**不看归属**,只看「有没有共同祖先可用」。
|
|
3597
|
+
*
|
|
3598
|
+
* 为什么必须这样:归属台账是**旁证**而不是判据 —— 对方的写入可能因为工具不在白名单、
|
|
3599
|
+
* 或另一个 DSH 进程把 attribution.json 整体覆盖写而**没被记到**。真机 2026-10-17:
|
|
3600
|
+
* 两个对话各给 README 加了一行描述,回退其中一个之后**两行都没了**,根因正是
|
|
3601
|
+
* 「归属里只有本会话那次编辑」⇒ 不认为有第三方 ⇒ 退回整文件覆盖。
|
|
3602
|
+
* 只要目标点之后**存在别的会话的回合点**,那它就是共同祖先 ⇒ 必须走三方合并。
|
|
3603
|
+
*/
|
|
3604
|
+
const mergeGuardEnabled = selection === null && isolationSessionId !== null;
|
|
3605
|
+
/** 同一路径的 base 只查一次(合并判定在「确认需要写」之后,命中数很少) */
|
|
3606
|
+
const baseCache = new Map();
|
|
3607
|
+
const sink = { skipped: [], excludedDirs: [], ignoredDirs: [], ignoredDirCount: 0, unreadableDirs: [], skippedCount: 0, skippedTruncated: false };
|
|
3608
|
+
// 删除集与**预览**共用同一份规划(planCreatedRemovals)⇒「预览说会删的」= 「实际删的」。
|
|
3609
|
+
// 为什么提到文件循环**之前**:选择模式要拿它当「合法路径」的第二来源(新建候选也允许被点名),
|
|
3610
|
+
// 而校验必须在**任何写入之前**完成。它是只读走查,提前算不会写任何东西。
|
|
3611
|
+
const plan = await this.planCreatedRemovals(payload, sink);
|
|
3612
|
+
if (selection !== null) {
|
|
3613
|
+
const legal = new Set();
|
|
3614
|
+
for (const file of payload.files)
|
|
3615
|
+
legal.add(pathKey(file.path));
|
|
3616
|
+
for (const target of plan.all)
|
|
3617
|
+
legal.add(pathKey(target));
|
|
3618
|
+
const unknown = [...selection].filter((key) => !legal.has(key));
|
|
3619
|
+
if (unknown.length > 0) {
|
|
3620
|
+
// 点名了一个「既未记录、也不在删除候选」里的路径 ⇒ fail-closed:零写入失败,
|
|
3621
|
+
// 绝不把「用户打错一个路径」变成「静默少恢复几个文件」。
|
|
3622
|
+
pushDetail(detail, 'selection-unknown-path ' + unknown[0] + ' unknown=' + String(unknown.length) + ' zeroWrites=true');
|
|
3623
|
+
return {
|
|
3624
|
+
state: 'workspace', status: 'failed', reasonCode: 'invalid-input', detail,
|
|
3625
|
+
restored: 0, failed: unknown.length,
|
|
3626
|
+
leftovers: plan.all.slice(0, ROLLBACK_LEFTOVER_LIST_LIMIT), leftoversTruncated: plan.truncated,
|
|
3627
|
+
};
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
/**
|
|
3631
|
+
* 待写条目。`sha256` / `expectedCurrent` **只被三方合并路径**使用:
|
|
3632
|
+
* · `sha256` = 这次真正要写进去的内容哈希(普通路径恒等于记录里的 `file.sha256`);
|
|
3633
|
+
* · `expectedCurrent` = 合并时读到的「当前磁盘」哈希 —— 写入前必须仍是它,否则说明
|
|
3634
|
+
* 文件在预览/合并之后又被改过 ⇒ **放弃写入**(并发保护:绝不覆盖比我们更新的内容)。
|
|
3635
|
+
*/
|
|
3317
3636
|
const writable = [];
|
|
3318
3637
|
let failed = 0;
|
|
3319
3638
|
// 「已与记录一致」的条目数(身份快路径证明)—— 后面「0 变更」的文案与统计要用
|
|
3320
3639
|
let unchanged = 0;
|
|
3640
|
+
/** 三方合并成功、写回「撤销本会话 + 保留对方改动」结果的条目数 */
|
|
3641
|
+
let mergedCount = 0;
|
|
3642
|
+
/** 冲突:两侧改到同一处 ⇒ 绝不自动选边,交给用户 */
|
|
3643
|
+
let conflictCount = 0;
|
|
3644
|
+
/** 无法安全撤销:没有中间版本 / 二进制 / 差异被降级 / 对方把它改成了不存在 */
|
|
3645
|
+
let unavailableCount = 0;
|
|
3646
|
+
/** 写入前发现文件又被改过(并发)⇒ 放弃写入 */
|
|
3647
|
+
let concurrentCount = 0;
|
|
3648
|
+
const mergedPaths = [];
|
|
3649
|
+
const conflictPaths = [];
|
|
3650
|
+
const unavailablePaths = [];
|
|
3651
|
+
const concurrentPaths = [];
|
|
3321
3652
|
for (const file of payload.files) {
|
|
3653
|
+
// 路径级选择:没被点名的记录条目**不写**(用户自己的选择,不是安全判据)
|
|
3654
|
+
if (selection !== null && !selection.has(pathKey(file.path))) {
|
|
3655
|
+
selectionSkipped += 1;
|
|
3656
|
+
continue;
|
|
3657
|
+
}
|
|
3322
3658
|
// 门:记录里的路径必须落在记录里的分块内(记录不可成为写原语)
|
|
3323
3659
|
const inChunk = payload.chunks.some((chunk) => path.isAbsolute(chunk) && isInsidePath(file.path, chunk));
|
|
3324
3660
|
if (!path.isAbsolute(file.path) || !inChunk || isInsidePath(file.path, this.root)) {
|
|
@@ -3344,6 +3680,55 @@ export class RollbackPointEngine {
|
|
|
3344
3680
|
pushDetail(detail, 'object-hash-mismatch ' + file.path);
|
|
3345
3681
|
continue;
|
|
3346
3682
|
}
|
|
3683
|
+
// **合并保护**(2026-10-17 修):到这里才确认「这个文件确实需要写」,也才值得去查共同祖先。
|
|
3684
|
+
// 触发不吃归属:只要目标点之后存在**别的会话**的回合点(那它就是共同祖先),就走三方合并。
|
|
3685
|
+
if (mergeGuardEnabled) {
|
|
3686
|
+
let base;
|
|
3687
|
+
const cached = baseCache.get(pathKey(file.path));
|
|
3688
|
+
if (cached === undefined) {
|
|
3689
|
+
base = await this.baseVersionFor(file.path, track.capturedAt, isolationSessionId);
|
|
3690
|
+
baseCache.set(pathKey(file.path), base);
|
|
3691
|
+
}
|
|
3692
|
+
else {
|
|
3693
|
+
base = cached;
|
|
3694
|
+
}
|
|
3695
|
+
const foreignWriter = this.attribution?.writerOf(file.path);
|
|
3696
|
+
const foreignEvidence = foreignWriter !== undefined
|
|
3697
|
+
&& foreignWriter.sessionId !== isolationSessionId
|
|
3698
|
+
&& foreignWriter.at > track.capturedAt;
|
|
3699
|
+
if (base !== null || foreignEvidence) {
|
|
3700
|
+
const outcome = await this.mergeForeignRecord(file, base, foreignEvidence);
|
|
3701
|
+
if (outcome.kind === 'fallback') {
|
|
3702
|
+
// 没有可用祖先、也没有别人的旁证 ⇒ 落到下面的旧口径(整文件覆盖回目标版本)
|
|
3703
|
+
}
|
|
3704
|
+
else {
|
|
3705
|
+
if (outcome.kind === 'write') {
|
|
3706
|
+
writable.push({ file, bytes: outcome.bytes, sha256: outcome.sha256, expectedCurrent: outcome.expectedCurrent });
|
|
3707
|
+
mergedCount += 1;
|
|
3708
|
+
if (mergedPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
3709
|
+
mergedPaths.push(file.path);
|
|
3710
|
+
continue;
|
|
3711
|
+
}
|
|
3712
|
+
if (outcome.kind === 'unchanged') {
|
|
3713
|
+
unchanged += 1;
|
|
3714
|
+
continue;
|
|
3715
|
+
}
|
|
3716
|
+
foreignSkipped += 1;
|
|
3717
|
+
if (outcome.reason === 'conflict') {
|
|
3718
|
+
conflictCount += 1;
|
|
3719
|
+
if (conflictPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
3720
|
+
conflictPaths.push(file.path);
|
|
3721
|
+
}
|
|
3722
|
+
else {
|
|
3723
|
+
unavailableCount += 1;
|
|
3724
|
+
if (unavailablePaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
3725
|
+
unavailablePaths.push(file.path);
|
|
3726
|
+
}
|
|
3727
|
+
pushDetail(detail, 'foreign-' + outcome.reason + ' ' + file.path + ' zeroWrites=true');
|
|
3728
|
+
continue;
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3347
3732
|
writable.push({ file, bytes });
|
|
3348
3733
|
}
|
|
3349
3734
|
catch (error) {
|
|
@@ -3351,10 +3736,20 @@ export class RollbackPointEngine {
|
|
|
3351
3736
|
pushDetail(detail, 'object-missing ' + file.path + ' ' + errorDetail(error));
|
|
3352
3737
|
}
|
|
3353
3738
|
}
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3739
|
+
if (selectionSkipped > 0) {
|
|
3740
|
+
pushDetail(detail, 'selection-skipped=' + String(selectionSkipped) + ' selection=' + String(selection?.size ?? 0));
|
|
3741
|
+
}
|
|
3742
|
+
if (foreignSkipped > 0) {
|
|
3743
|
+
pushDetail(detail, 'foreign-skipped=' + String(foreignSkipped) + ' (records written by other sessions after this point; not overwritten)');
|
|
3744
|
+
}
|
|
3745
|
+
if (mergedCount > 0)
|
|
3746
|
+
pushDetail(detail, 'foreign-merged=' + String(mergedCount) + ' (reverted this session, kept the other side)');
|
|
3747
|
+
if (conflictCount > 0)
|
|
3748
|
+
pushDetail(detail, 'foreign-conflict=' + String(conflictCount) + ' (both sides changed the same place; needs a human)');
|
|
3749
|
+
if (unavailableCount > 0)
|
|
3750
|
+
pushDetail(detail, 'foreign-unavailable=' + String(unavailableCount) + ' (no safe way to revert; left untouched)');
|
|
3751
|
+
if (concurrentCount > 0)
|
|
3752
|
+
pushDetail(detail, 'concurrent-change=' + String(concurrentCount) + ' (changed again after the merge; left untouched)');
|
|
3358
3753
|
const leftovers = {
|
|
3359
3754
|
leftovers: plan.all.slice(0, ROLLBACK_LEFTOVER_LIST_LIMIT),
|
|
3360
3755
|
truncated: plan.truncated,
|
|
@@ -3362,9 +3757,34 @@ export class RollbackPointEngine {
|
|
|
3362
3757
|
};
|
|
3363
3758
|
// 用户 2026-10-11 定案 A:把「回退点之后新建」的路径也退回去(删除)。
|
|
3364
3759
|
// removeCreated !== true ⇒ 只报告(paths 清空);判据仍是同一份 plan。
|
|
3365
|
-
|
|
3760
|
+
// 归属保护同样作用于**删除集**(2026-10-17 跨对话安全回退):某路径若在留点之后被**别的会话**
|
|
3761
|
+
// 写过,删它同样是在销毁别人的工作 ⇒ 拒删(进 createdSkipped;预览与执行共用同一份规划,
|
|
3762
|
+
// 所以界面看到的「不会删」与执行端的「真的没删」仍然一致)。
|
|
3763
|
+
const removalPlan = options.removeCreated === true
|
|
3366
3764
|
? { paths: plan.paths, skipped: plan.skipped, truncated: plan.truncated, refused: plan.refused }
|
|
3367
3765
|
: { paths: [], skipped: [], truncated: false, refused: null };
|
|
3766
|
+
const protectedRemovals = [];
|
|
3767
|
+
if (mergeGuardEnabled && removalPlan.paths.length > 0) {
|
|
3768
|
+
// 两类证据(任一成立就保护):① 归属表明别的会话写过它;② **别的会话的回合点里记录过该路径**。
|
|
3769
|
+
// 后者不依赖归属(归属可能因工具白名单 / 跨进程整体覆盖 / 采集时机而缺失,见 mergeGuardEnabled 的说明)。
|
|
3770
|
+
const foreignPaths = await this.foreignRecordedPaths(track.capturedAt, isolationSessionId);
|
|
3771
|
+
const keep = [];
|
|
3772
|
+
for (const target of removalPlan.paths) {
|
|
3773
|
+
const writer = this.attribution?.writerOf(target);
|
|
3774
|
+
const byWriter = writer !== undefined && writer.sessionId !== isolationSessionId && writer.at > track.capturedAt;
|
|
3775
|
+
if (byWriter || foreignPaths.has(pathKey(target))) {
|
|
3776
|
+
protectedRemovals.push(target);
|
|
3777
|
+
removalPlan.skipped.push(target);
|
|
3778
|
+
continue;
|
|
3779
|
+
}
|
|
3780
|
+
keep.push(target);
|
|
3781
|
+
}
|
|
3782
|
+
removalPlan.paths = keep;
|
|
3783
|
+
}
|
|
3784
|
+
const removal = removalPlan;
|
|
3785
|
+
if (protectedRemovals.length > 0) {
|
|
3786
|
+
pushDetail(detail, 'foreign-removal-protected=' + String(protectedRemovals.length) + ' (written by other sessions; not deleted)');
|
|
3787
|
+
}
|
|
3368
3788
|
if (removal.paths.length > ROLLBACK_MAX_CREATED_REMOVALS) {
|
|
3369
3789
|
detail.push('created-removals=' + String(removal.paths.length) + ' limit=' + String(ROLLBACK_MAX_CREATED_REMOVALS) + ' zeroWrites=true');
|
|
3370
3790
|
return {
|
|
@@ -3389,11 +3809,30 @@ export class RollbackPointEngine {
|
|
|
3389
3809
|
// (否则工作区已与该点一致时也会重写全部文件,白耗 I/O 并抖动 mtime)。
|
|
3390
3810
|
const needed = [];
|
|
3391
3811
|
for (const item of writable) {
|
|
3812
|
+
const targetSha = item.sha256 ?? item.file.sha256;
|
|
3813
|
+
// 合并路径的**并发闸门**:写入前必须确认磁盘上仍是合并时读到的那一份
|
|
3814
|
+
if (item.expectedCurrent !== undefined) {
|
|
3815
|
+
let current = null;
|
|
3816
|
+
try {
|
|
3817
|
+
current = sha256Hex(await fs.readFile(item.file.path));
|
|
3818
|
+
}
|
|
3819
|
+
catch {
|
|
3820
|
+
current = null;
|
|
3821
|
+
}
|
|
3822
|
+
if (current !== item.expectedCurrent) {
|
|
3823
|
+
concurrentCount += 1;
|
|
3824
|
+
if (concurrentPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
3825
|
+
concurrentPaths.push(item.file.path);
|
|
3826
|
+
pushDetail(detail, 'concurrent-change ' + item.file.path + ' zeroWrites=true');
|
|
3827
|
+
continue;
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3392
3830
|
let identical = false;
|
|
3393
3831
|
try {
|
|
3394
3832
|
const stat = await fs.stat(item.file.path);
|
|
3395
|
-
|
|
3396
|
-
|
|
3833
|
+
// 长度比的是**这次要写进去的那份内容**(合并路径下它与记录里的原文长度不一定相同)
|
|
3834
|
+
if (stat.isFile() && stat.size === item.bytes.byteLength) {
|
|
3835
|
+
identical = sha256Hex(await fs.readFile(item.file.path)) === targetSha;
|
|
3397
3836
|
}
|
|
3398
3837
|
}
|
|
3399
3838
|
catch {
|
|
@@ -3415,6 +3854,26 @@ export class RollbackPointEngine {
|
|
|
3415
3854
|
noop.createdSkippedTruncated = removal.truncated || removal.skipped.length > ROLLBACK_LEFTOVER_LIST_LIMIT;
|
|
3416
3855
|
if (removal.refused !== null)
|
|
3417
3856
|
noop.createdRefusedReason = removal.refused;
|
|
3857
|
+
if (selectionSkipped > 0)
|
|
3858
|
+
noop.selectionSkipped = selectionSkipped;
|
|
3859
|
+
if (foreignSkipped > 0)
|
|
3860
|
+
noop.foreignSkipped = foreignSkipped;
|
|
3861
|
+
if (mergedCount > 0) {
|
|
3862
|
+
noop.mergedCount = mergedCount;
|
|
3863
|
+
noop.mergedPaths = mergedPaths;
|
|
3864
|
+
}
|
|
3865
|
+
if (conflictCount > 0) {
|
|
3866
|
+
noop.conflictCount = conflictCount;
|
|
3867
|
+
noop.conflictPaths = conflictPaths;
|
|
3868
|
+
}
|
|
3869
|
+
if (unavailableCount > 0) {
|
|
3870
|
+
noop.unavailableCount = unavailableCount;
|
|
3871
|
+
noop.unavailablePaths = unavailablePaths;
|
|
3872
|
+
}
|
|
3873
|
+
if (concurrentCount > 0) {
|
|
3874
|
+
noop.concurrentCount = concurrentCount;
|
|
3875
|
+
noop.concurrentPaths = concurrentPaths;
|
|
3876
|
+
}
|
|
3418
3877
|
return noop;
|
|
3419
3878
|
}
|
|
3420
3879
|
// 前像(**字节落盘、索引随栈**):失败时要能整体回到恢复前状态 —— 只覆盖**真会被写**的文件。
|
|
@@ -3430,6 +3889,35 @@ export class RollbackPointEngine {
|
|
|
3430
3889
|
leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
|
|
3431
3890
|
};
|
|
3432
3891
|
}
|
|
3892
|
+
// **最终并发校验**(紧邻写入之前):合并/预览之后文件可能又被改过。前一刻的 needed 判定与
|
|
3893
|
+
// 真正写入之间还隔着前像暂存,所以这里再确认一次;不符的条目直接摘掉 ——
|
|
3894
|
+
// 绝不覆盖比我们更新的内容。
|
|
3895
|
+
if (needed.some((item) => item.expectedCurrent !== undefined)) {
|
|
3896
|
+
const keep = [];
|
|
3897
|
+
for (const item of needed) {
|
|
3898
|
+
if (item.expectedCurrent === undefined) {
|
|
3899
|
+
keep.push(item);
|
|
3900
|
+
continue;
|
|
3901
|
+
}
|
|
3902
|
+
let current = null;
|
|
3903
|
+
try {
|
|
3904
|
+
current = sha256Hex(await fs.readFile(item.file.path));
|
|
3905
|
+
}
|
|
3906
|
+
catch {
|
|
3907
|
+
current = null;
|
|
3908
|
+
}
|
|
3909
|
+
if (current !== item.expectedCurrent) {
|
|
3910
|
+
concurrentCount += 1;
|
|
3911
|
+
if (concurrentPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
3912
|
+
concurrentPaths.push(item.file.path);
|
|
3913
|
+
pushDetail(detail, 'concurrent-change ' + item.file.path + ' zeroWrites=true');
|
|
3914
|
+
continue;
|
|
3915
|
+
}
|
|
3916
|
+
keep.push(item);
|
|
3917
|
+
}
|
|
3918
|
+
needed.length = 0;
|
|
3919
|
+
needed.push(...keep);
|
|
3920
|
+
}
|
|
3433
3921
|
const preCount = needed.length;
|
|
3434
3922
|
for (const item of needed) {
|
|
3435
3923
|
let bytes = null;
|
|
@@ -3487,7 +3975,8 @@ export class RollbackPointEngine {
|
|
|
3487
3975
|
for (const item of needed) {
|
|
3488
3976
|
try {
|
|
3489
3977
|
const current = await this.readFileBytes(item.file.path);
|
|
3490
|
-
|
|
3978
|
+
// 合并路径写入的不是记录里的原文,而是合并结果 ⇒ 复核必须比**这次真正写进去的那份**
|
|
3979
|
+
if (sha256Hex(current) !== (item.sha256 ?? item.file.sha256))
|
|
3491
3980
|
mismatched.push(item.file.path);
|
|
3492
3981
|
}
|
|
3493
3982
|
catch {
|
|
@@ -3573,6 +4062,26 @@ export class RollbackPointEngine {
|
|
|
3573
4062
|
createdSkippedTruncated: removal.truncated || removal.skipped.length > ROLLBACK_LEFTOVER_LIST_LIMIT,
|
|
3574
4063
|
writtenBytes,
|
|
3575
4064
|
};
|
|
4065
|
+
if (selectionSkipped > 0)
|
|
4066
|
+
segment.selectionSkipped = selectionSkipped;
|
|
4067
|
+
if (foreignSkipped > 0)
|
|
4068
|
+
segment.foreignSkipped = foreignSkipped;
|
|
4069
|
+
if (mergedCount > 0) {
|
|
4070
|
+
segment.mergedCount = mergedCount;
|
|
4071
|
+
segment.mergedPaths = mergedPaths;
|
|
4072
|
+
}
|
|
4073
|
+
if (conflictCount > 0) {
|
|
4074
|
+
segment.conflictCount = conflictCount;
|
|
4075
|
+
segment.conflictPaths = conflictPaths;
|
|
4076
|
+
}
|
|
4077
|
+
if (unavailableCount > 0) {
|
|
4078
|
+
segment.unavailableCount = unavailableCount;
|
|
4079
|
+
segment.unavailablePaths = unavailablePaths;
|
|
4080
|
+
}
|
|
4081
|
+
if (concurrentCount > 0) {
|
|
4082
|
+
segment.concurrentCount = concurrentCount;
|
|
4083
|
+
segment.concurrentPaths = concurrentPaths;
|
|
4084
|
+
}
|
|
3576
4085
|
// 捕获不完整 ⇒ 一条都没删:如实回报原因(界面据此说「因为捕获不完整,一条都没动」)
|
|
3577
4086
|
if (removal.refused !== null)
|
|
3578
4087
|
segment.createdRefusedReason = removal.refused;
|
|
@@ -3918,6 +4427,7 @@ export class RollbackAutoTracker {
|
|
|
3918
4427
|
pollIntervalMs;
|
|
3919
4428
|
log;
|
|
3920
4429
|
statSessionLog;
|
|
4430
|
+
attribution;
|
|
3921
4431
|
bindings = new Map();
|
|
3922
4432
|
open = new Map();
|
|
3923
4433
|
/**
|
|
@@ -3938,6 +4448,7 @@ export class RollbackAutoTracker {
|
|
|
3938
4448
|
this.pollIntervalMs = Math.min(options.pollIntervalMs ?? ROLLBACK_DEFAULT_POLL_INTERVAL_MS, ROLLBACK_DEFAULT_POLL_INTERVAL_MS);
|
|
3939
4449
|
this.log = options.log;
|
|
3940
4450
|
this.statSessionLog = options.statSessionLog;
|
|
4451
|
+
this.attribution = options.attribution;
|
|
3941
4452
|
}
|
|
3942
4453
|
/** 实际采用的留点机制(output / 诊断用) */
|
|
3943
4454
|
get mechanismKind() {
|
|
@@ -4033,6 +4544,12 @@ export class RollbackAutoTracker {
|
|
|
4033
4544
|
endSeq: null,
|
|
4034
4545
|
});
|
|
4035
4546
|
}
|
|
4547
|
+
if (event.type === 'tool/call') {
|
|
4548
|
+
// 写入归属(2026-10-17 定案 B):**只记,不改任何留点行为**,也不 await 落盘
|
|
4549
|
+
// (落盘由 attribution 自己 debounce;tool/call 洪峰不该在这里等 I/O)。
|
|
4550
|
+
this.attribution?.noteToolCall(event.sessionId, event.data);
|
|
4551
|
+
return { captured: false };
|
|
4552
|
+
}
|
|
4036
4553
|
if (event.type !== 'turn/end')
|
|
4037
4554
|
return { captured: false };
|
|
4038
4555
|
// 回合结束只为「缺省边界」留个记号(轮询/无边界回退用它),不再拍点。
|