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
|
@@ -73,6 +73,8 @@ import { EXCLUDED_DEP_DIR_NAMES } from '../utils/recursive-walk.ts'
|
|
|
73
73
|
import { diffLines, type DiffHunk } from '../utils/line-diff.ts'
|
|
74
74
|
import { projectKeyOf } from './session-select.ts'
|
|
75
75
|
import { ignoreRelativePath, ignoreRuleMatches, loadIgnoreRules, type IgnoreRules } from './rollback-ignore-rules.ts'
|
|
76
|
+
import type { RollbackWriteAttributionPort, RollbackWriteFact, RollbackWriteRecorder } from './rollback-attribution.ts'
|
|
77
|
+
import { mergeThreeWay } from './rollback-merge.ts'
|
|
76
78
|
import { isSessionLogName } from '../utils/session-log.ts'
|
|
77
79
|
import { decodeZstdFrame, scanZstdFrames, zstdAvailable, ZSTD_MAGIC } from '../utils/zstd-frame.ts'
|
|
78
80
|
import {
|
|
@@ -746,6 +748,23 @@ export interface RollbackPreviewFile {
|
|
|
746
748
|
* **宁可说「不知道」,也绝不谎报行数。**
|
|
747
749
|
*/
|
|
748
750
|
truncated: boolean
|
|
751
|
+
/**
|
|
752
|
+
* true = 该文件在留点**之后**是**别的会话**写的(写入归属台账证明;见 core/rollback-attribution.ts)。
|
|
753
|
+
* 界面据此默认不勾选它(用户仍可勾上)。缺字段 / false = **没有归属证据**,
|
|
754
|
+
* 而不是「本会话写的」——「未判定」与「确知是本会话」必须分开。
|
|
755
|
+
*/
|
|
756
|
+
foreign?: boolean
|
|
757
|
+
/** 那些「别的会话」的会话 id(仅当 foreign 为 true) */
|
|
758
|
+
foreignSessionId?: string
|
|
759
|
+
/** 那次写入的时间(毫秒;仅当 foreign 为 true) */
|
|
760
|
+
foreignAt?: number
|
|
761
|
+
/**
|
|
762
|
+
* true = 这次回退**不会改动它**:要么与其它对话的改动冲突、要么无法安全合并(缺共同祖先 / 二进制 /
|
|
763
|
+
* 差异被降级 / 对方删了它)。行级统计一律为 null —— 「不改动」不是「0 行变化」。
|
|
764
|
+
*/
|
|
765
|
+
skipped?: boolean
|
|
766
|
+
/** 不改动的机器原因(界面映射成字典键,绝不裸渲染) */
|
|
767
|
+
mergeReason?: string
|
|
749
768
|
}
|
|
750
769
|
|
|
751
770
|
/** 单文件差异里一侧(当前磁盘 / 回退点快照)的展示信息。 */
|
|
@@ -790,6 +809,15 @@ export interface RollbackFileDiffResult {
|
|
|
790
809
|
degraded: boolean
|
|
791
810
|
/** 无法逐行对比的原因(有值时 hunks 为空) */
|
|
792
811
|
reason?: RollbackFileDiffReason
|
|
812
|
+
/** true = 「回退后」一侧显示的是**三方合并**的结果,而不是记录里的原文 */
|
|
813
|
+
merged?: boolean
|
|
814
|
+
/**
|
|
815
|
+
* true = 这次回退**不会改动**它(与其它对话的改动冲突 / 无法安全合并)—— 此时 after 与 before 相同。
|
|
816
|
+
* 界面必须把「不会改动」与「0 行变化」分开说。
|
|
817
|
+
*/
|
|
818
|
+
mergeSkipped?: boolean
|
|
819
|
+
/** 不改动的机器原因(界面映射成字典键,绝不裸渲染) */
|
|
820
|
+
mergeReason?: string
|
|
793
821
|
limits: typeof ROLLBACK_FILE_DIFF_LIMITS
|
|
794
822
|
}
|
|
795
823
|
|
|
@@ -964,6 +992,28 @@ export interface RollbackSegmentResult {
|
|
|
964
992
|
*/
|
|
965
993
|
createdRefusedReason?: 'capture-incomplete'
|
|
966
994
|
writtenBytes?: number
|
|
995
|
+
/**
|
|
996
|
+
* 因路径选择(onlyPaths)而未恢复 / 未删除的条目数。
|
|
997
|
+
* 与 createdSkipped(安全拒删)**分开**:这里的原因是用户自己没勾它,不是安全判据挡下。
|
|
998
|
+
*/
|
|
999
|
+
selectionSkipped?: number
|
|
1000
|
+
/**
|
|
1001
|
+
* 因**归属隔离**(2026-10-17 定案 B)而跳过的记录条目数:该文件在留点之后是**别的会话**写的。
|
|
1002
|
+
* 只在**没有显式点名**(`onlyPaths` 缺省)时生效;显式点名 = 用户明确要它,归属不再拦。
|
|
1003
|
+
*/
|
|
1004
|
+
foreignSkipped?: number
|
|
1005
|
+
/** 三方合并成功、写回「撤销本会话 + 保留对方改动」的条目数(有界清单同给) */
|
|
1006
|
+
mergedCount?: number
|
|
1007
|
+
mergedPaths?: string[]
|
|
1008
|
+
/** 冲突:两侧改到同一处 ⇒ **未写**,交给用户处理 */
|
|
1009
|
+
conflictCount?: number
|
|
1010
|
+
conflictPaths?: string[]
|
|
1011
|
+
/** 无法安全撤销(无中间版本 / 二进制 / 差异被降级 / 对方改成了不存在)⇒ **未写** */
|
|
1012
|
+
unavailableCount?: number
|
|
1013
|
+
unavailablePaths?: string[]
|
|
1014
|
+
/** 写入前发现又被改过(并发)⇒ **未写** */
|
|
1015
|
+
concurrentCount?: number
|
|
1016
|
+
concurrentPaths?: string[]
|
|
967
1017
|
/** 会话段:fork 出来的子会话 id */
|
|
968
1018
|
childSessionId?: string
|
|
969
1019
|
guidance?: RollbackGuidance[]
|
|
@@ -984,6 +1034,16 @@ export interface RollbackRewindInput {
|
|
|
984
1034
|
* (把已写回的文件还原、把已删的文件写回)。拒删判据见 createdRemovalRefusal。
|
|
985
1035
|
*/
|
|
986
1036
|
removeCreated?: boolean
|
|
1037
|
+
/**
|
|
1038
|
+
* **路径级选择性恢复**(2026-10-17 用户定案 A):只恢复 / 只删除这些路径。
|
|
1039
|
+
*
|
|
1040
|
+
* 缺省(undefined)= 既有全量口径,行为一字不变。给了值就:
|
|
1041
|
+
* · 记录在案的文件里**不在**清单中的:不写(计入 selectionSkipped);
|
|
1042
|
+
* · 「留点后新建」的候选里**不在**清单中的:不删(同样计入)。
|
|
1043
|
+
* 清单里出现**既未记录、也不在删除候选**里的路径 ⇒ **fail-closed 拒绝、零写入**(invalid-input):
|
|
1044
|
+
* 绝不把「用户打错一个路径」变成「静默少恢复几个文件」。
|
|
1045
|
+
*/
|
|
1046
|
+
onlyPaths?: readonly string[]
|
|
987
1047
|
}
|
|
988
1048
|
|
|
989
1049
|
export interface RollbackRewindResult {
|
|
@@ -1008,6 +1068,11 @@ export type RollbackStorageProbe = () => Promise<{ ok: true } | { ok: false; det
|
|
|
1008
1068
|
export interface RollbackEngineOptions {
|
|
1009
1069
|
/** 插件数据目录(= $DSH_HOME/dsh-config-manager);回滚点库在 dataDir/rollback-points */
|
|
1010
1070
|
dataDir: string
|
|
1071
|
+
/**
|
|
1072
|
+
* 写入归属台账(2026-10-17 用户定案 B)。缺省 = 没有归属信息 ⇒ 预览不标 foreign,
|
|
1073
|
+
* 回退行为与旧版**逐字一致**(未判定绝不当作「别的会话写的」)。
|
|
1074
|
+
*/
|
|
1075
|
+
attribution?: RollbackWriteAttributionPort
|
|
1011
1076
|
/** DSH home(会话根 = homeDir/sessions) */
|
|
1012
1077
|
homeDir: string
|
|
1013
1078
|
now?: () => number
|
|
@@ -1787,6 +1852,8 @@ export class RollbackPointEngine {
|
|
|
1787
1852
|
private ledgerSnapshotCache: { at: number; entries: RollbackLedgerManifestEntry[] } | null = null
|
|
1788
1853
|
/** 台账存储层(v3:清单 + 单条记录 + 内容寻址载荷;issue #89) */
|
|
1789
1854
|
private readonly ledger: RollbackLedger
|
|
1855
|
+
/** 写入归属(2026-10-17 定案 B)。undefined = 没有归属信息 ⇒ 预览不标 foreign */
|
|
1856
|
+
private readonly attribution: RollbackWriteAttributionPort | undefined
|
|
1790
1857
|
private readonly ledgerBytesBudget: number
|
|
1791
1858
|
/**
|
|
1792
1859
|
* 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS)。**可变**:
|
|
@@ -1843,6 +1910,7 @@ export class RollbackPointEngine {
|
|
|
1843
1910
|
this.maxGuards = options.maxGuards ?? ROLLBACK_MAX_GUARDS
|
|
1844
1911
|
this.guardPolicy = options.guardPolicy ?? 'warn'
|
|
1845
1912
|
this.probe = options.storage
|
|
1913
|
+
this.attribution = options.attribution
|
|
1846
1914
|
this.forkSeam = options.fork
|
|
1847
1915
|
this.writeSeam = options.writeFile
|
|
1848
1916
|
this.readSeam = options.readFile
|
|
@@ -3668,6 +3736,36 @@ export class RollbackPointEngine {
|
|
|
3668
3736
|
return { added: counts.added, removed: counts.removed, truncated: false }
|
|
3669
3737
|
}
|
|
3670
3738
|
|
|
3739
|
+
/**
|
|
3740
|
+
* **合并结果**的逐文件行级统计(与 `previewFileCounts` 同一套预算与降级判据)。
|
|
3741
|
+
*
|
|
3742
|
+
* 为什么单独一份:合并后要写进去的内容既不是记录里的原文(ours),也不是磁盘现状(theirs)——
|
|
3743
|
+
* 预览若照旧拿 ours 算差异,就会把「其实会被保留的对方改动」显示成「会被删掉」。
|
|
3744
|
+
*/
|
|
3745
|
+
private async previewMergeCounts(
|
|
3746
|
+
targetBytes: Uint8Array,
|
|
3747
|
+
current: Uint8Array,
|
|
3748
|
+
budget: { bytes: number; work: number },
|
|
3749
|
+
): Promise<{ added: number | null; removed: number | null; truncated: boolean }> {
|
|
3750
|
+
if (targetBytes.byteLength > this.previewDiffMaxFileBytes || current.byteLength > this.previewDiffMaxFileBytes) {
|
|
3751
|
+
return previewCountsUnavailable()
|
|
3752
|
+
}
|
|
3753
|
+
if (budget.bytes + targetBytes.byteLength + current.byteLength > this.previewDiffMaxTotalBytes) {
|
|
3754
|
+
return previewCountsUnavailable()
|
|
3755
|
+
}
|
|
3756
|
+
budget.bytes += targetBytes.byteLength + current.byteLength
|
|
3757
|
+
if (looksBinary(targetBytes) || looksBinary(current)) return previewCountsUnavailable()
|
|
3758
|
+
const counts = lineDiffCounts(
|
|
3759
|
+
splitDiffLines(DIFF_TEXT_DECODER.decode(current)),
|
|
3760
|
+
splitDiffLines(DIFF_TEXT_DECODER.decode(targetBytes)),
|
|
3761
|
+
this.previewDiffMaxWork,
|
|
3762
|
+
this.previewDiffTotalWork,
|
|
3763
|
+
budget,
|
|
3764
|
+
)
|
|
3765
|
+
if (counts === null) return previewCountsUnavailable()
|
|
3766
|
+
return { added: counts.added, removed: counts.removed, truncated: false }
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3671
3769
|
/** 只读预览:零写入(不过确认门、不拍 guard、不写任何字节)。 */
|
|
3672
3770
|
async preview(id: string): Promise<RollbackPreviewResult> {
|
|
3673
3771
|
const storage = await this.storageStatus()
|
|
@@ -3720,8 +3818,21 @@ export class RollbackPointEngine {
|
|
|
3720
3818
|
let missing = 0
|
|
3721
3819
|
// E1:逐文件行级统计 —— **只列会被改动的文件**(内容已一致的一个都不列)
|
|
3722
3820
|
const changedFiles: RollbackPreviewFile[] = []
|
|
3821
|
+
// 写入归属(2026-10-17 定案 B):只有**确知**最后写入者是别的会话、且写入发生在留点**之后**时,
|
|
3822
|
+
// 才敢标 foreign。缺归属 / 归属就是本会话 / 写入早于留点 ⇒ 一律不标
|
|
3823
|
+
// (未判定 ≠ 别的会话写的;宁可少标一次,也不误判一次)。
|
|
3824
|
+
const foreignWriterOf = (target: string): RollbackWriteFact | undefined => {
|
|
3825
|
+
const writer = this.attribution?.writerOf(target)
|
|
3826
|
+
if (writer === undefined) return undefined
|
|
3827
|
+
if (writer.sessionId === record.sessionId) return undefined
|
|
3828
|
+
if (writer.at <= wsTrack.capturedAt) return undefined
|
|
3829
|
+
return writer
|
|
3830
|
+
}
|
|
3723
3831
|
// 单张预览共享的比对预算(字节 + 步数);只有真的会被改动的条目才消耗它
|
|
3724
3832
|
const diffBudget = { bytes: 0, work: 0 }
|
|
3833
|
+
/** 合并判定的会话身份与 base 缓存(与执行端同一判据:拿目标点的会话当「本会话」) */
|
|
3834
|
+
const previewIsolationSessionId = typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null
|
|
3835
|
+
const previewBaseCache = new Map<string, string | null>()
|
|
3725
3836
|
// T10:stat 的三种结局分开表达 —— 「不存在(ENOENT)」与「存在性未知(其它 errno)」绝不混为一谈
|
|
3726
3837
|
const previewFiles = wsPayload.files
|
|
3727
3838
|
// 阶段 A(**并发**):逐条一次 bigint lstat + 身份快路径。大仓库这里是预览的大头:
|
|
@@ -3759,7 +3870,12 @@ export class RollbackPointEngine {
|
|
|
3759
3870
|
// 计入 different 的理由:回退一定会尝试写它(restoreWorkspace 里「已一致」判定失败即视为需要写),
|
|
3760
3871
|
// 这样 overwrite = 已一致 + different + missing 的算术仍然成立。
|
|
3761
3872
|
different += 1
|
|
3762
|
-
|
|
3873
|
+
const foreignMissing = foreignWriterOf(file.path)
|
|
3874
|
+
changedFiles.push({
|
|
3875
|
+
path: file.path, status: 'overwrite',
|
|
3876
|
+
...(foreignMissing === undefined ? {} : { foreign: true, foreignSessionId: foreignMissing.sessionId, foreignAt: foreignMissing.at }),
|
|
3877
|
+
...previewCountsUnavailable(),
|
|
3878
|
+
})
|
|
3763
3879
|
continue
|
|
3764
3880
|
}
|
|
3765
3881
|
// 身份快路径已证明「与记录一致」⇒ 一个字段都不改(内容一致的文件不列进列表)
|
|
@@ -3778,10 +3894,37 @@ export class RollbackPointEngine {
|
|
|
3778
3894
|
}
|
|
3779
3895
|
}
|
|
3780
3896
|
if (sizeMatches && current !== null && sha256Hex(current) === file.sha256) continue
|
|
3897
|
+
// **合并判定与执行同口径**(2026-10-17 修):预览必须说清「这次到底会写成什么」。
|
|
3898
|
+
// 之前预览只按「覆盖回记录版本」算差异,而执行端会先做三方合并 ⇒ 预览把对方后来的行
|
|
3899
|
+
// 也算成「会被删掉」,用户据此以为会丢别人的改动(真机:两行描述都显示要删,实际只删 A 那行)。
|
|
3900
|
+
const decision = await (async (): Promise<{ mode: 'overwrite' | 'merged' | 'skip' | 'unchanged'; bytes?: Uint8Array; reason?: string }> => {
|
|
3901
|
+
if (current === null || previewIsolationSessionId === null) return { mode: 'overwrite' }
|
|
3902
|
+
let baseText = previewBaseCache.get(pathKey(file.path))
|
|
3903
|
+
if (baseText === undefined) {
|
|
3904
|
+
baseText = await this.baseVersionFor(file.path, wsTrack.capturedAt, previewIsolationSessionId)
|
|
3905
|
+
previewBaseCache.set(pathKey(file.path), baseText)
|
|
3906
|
+
}
|
|
3907
|
+
const writer = this.attribution?.writerOf(file.path)
|
|
3908
|
+
const foreignEvidence = writer !== undefined && writer.sessionId !== previewIsolationSessionId && writer.at > wsTrack.capturedAt
|
|
3909
|
+
if (baseText === null && !foreignEvidence) return { mode: 'overwrite' }
|
|
3910
|
+
const outcome = await this.mergeForeignRecord(file, baseText, foreignEvidence)
|
|
3911
|
+
if (outcome.kind === 'write') return { mode: 'merged', bytes: outcome.bytes }
|
|
3912
|
+
if (outcome.kind === 'unchanged') return { mode: 'unchanged' }
|
|
3913
|
+
if (outcome.kind === 'blocked') return { mode: 'skip', reason: outcome.reason }
|
|
3914
|
+
return { mode: 'overwrite' }
|
|
3915
|
+
})()
|
|
3916
|
+
// 合并结论是「保持现状」⇒ 它不会被改动,不该出现在「会被改动」的清单里
|
|
3917
|
+
if (decision.mode === 'unchanged') continue
|
|
3781
3918
|
different += 1
|
|
3919
|
+
const foreignWriter = foreignWriterOf(file.path)
|
|
3782
3920
|
changedFiles.push({
|
|
3783
3921
|
path: file.path, status: 'overwrite',
|
|
3784
|
-
...(
|
|
3922
|
+
...(foreignWriter === undefined ? {} : { foreign: true, foreignSessionId: foreignWriter.sessionId, foreignAt: foreignWriter.at }),
|
|
3923
|
+
...(decision.mode === 'skip'
|
|
3924
|
+
? { skipped: true, mergeReason: decision.reason, ...previewCountsUnavailable() }
|
|
3925
|
+
: decision.mode === 'merged' && decision.bytes !== undefined && current !== null
|
|
3926
|
+
? await this.previewMergeCounts(decision.bytes, current, diffBudget)
|
|
3927
|
+
: await this.previewFileCounts(file, current, 'overwrite', diffBudget)),
|
|
3785
3928
|
})
|
|
3786
3929
|
}
|
|
3787
3930
|
const sink: ChunkWalkSink = { skipped: [], excludedDirs: [], ignoredDirs: [], ignoredDirCount: 0, unreadableDirs: [], skippedCount: 0, skippedTruncated: false }
|
|
@@ -3929,6 +4072,36 @@ export class RollbackPointEngine {
|
|
|
3929
4072
|
else currentUnreadable = true
|
|
3930
4073
|
}
|
|
3931
4074
|
|
|
4075
|
+
// **合并判定与执行同口径**(2026-10-17 修):「回退后」一侧必须显示这次**真正会写进去**的内容。
|
|
4076
|
+
// 以前它恒定显示记录里的原文(= 整文件覆盖的样子),于是预览列表写着「+0 −2」,点开「查看差异」
|
|
4077
|
+
// 却还是「−1,4 +1,1」—— 同一件事两个说法(真机 2026-10-17 用户当场发现)。
|
|
4078
|
+
let mergedAfter: Uint8Array | null = null
|
|
4079
|
+
/** true 只表示「after 侧真的是合并算出来的」——「不会改动」时 after 也是现状,但不算合并 */
|
|
4080
|
+
let mergedFlag = false
|
|
4081
|
+
let mergeSkippedReason: string | null = null
|
|
4082
|
+
const isolationSessionId = typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null
|
|
4083
|
+
if (current !== null && snapshot !== null && isolationSessionId !== null) {
|
|
4084
|
+
const baseText = await this.baseVersionFor(file.path, record.workspace.capturedAt, isolationSessionId)
|
|
4085
|
+
const writer = this.attribution?.writerOf(file.path)
|
|
4086
|
+
const foreignEvidence = writer !== undefined && writer.sessionId !== isolationSessionId && writer.at > record.workspace.capturedAt
|
|
4087
|
+
if (baseText !== null || foreignEvidence) {
|
|
4088
|
+
const outcome = await this.mergeForeignRecord(file, baseText, foreignEvidence)
|
|
4089
|
+
if (outcome.kind === 'write') {
|
|
4090
|
+
mergedAfter = outcome.bytes
|
|
4091
|
+
mergedFlag = true
|
|
4092
|
+
}
|
|
4093
|
+
// 合并结论 = 保持现状 ⇒ 「回退后」就是当前内容(该文件根本不会被写)
|
|
4094
|
+
else if (outcome.kind === 'unchanged') mergedAfter = current
|
|
4095
|
+
else if (outcome.kind === 'blocked') {
|
|
4096
|
+
// 冲突 / 无法安全合并 ⇒ 这次**不会写**它:「回退后」就是现状(两侧相同),
|
|
4097
|
+
// 界面据此说「不会改动」,绝不显示成「会被覆盖成记录原文」。
|
|
4098
|
+
mergeSkippedReason = outcome.reason
|
|
4099
|
+
mergedAfter = current
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
/** 回退后一侧的字节:合并结果优先,其次才是记录里的原文 */
|
|
4104
|
+
const afterBytes: Uint8Array | null = mergedAfter ?? snapshot
|
|
3932
4105
|
const before: RollbackFileDiffSide = {
|
|
3933
4106
|
exists: currentExists, bytes: currentBytes, lines: null,
|
|
3934
4107
|
binary: false, oversized: currentOversized, unreadable: currentUnreadable,
|
|
@@ -3938,17 +4111,19 @@ export class RollbackPointEngine {
|
|
|
3938
4111
|
before.lines = before.binary ? null : splitDiffLines(DIFF_TEXT_DECODER.decode(current)).length
|
|
3939
4112
|
}
|
|
3940
4113
|
const after: RollbackFileDiffSide = {
|
|
3941
|
-
exists:
|
|
4114
|
+
exists: afterBytes !== null, bytes: afterBytes?.byteLength ?? file.bytes, lines: null,
|
|
3942
4115
|
binary: false, oversized: snapshotOversized,
|
|
3943
|
-
unreadable:
|
|
4116
|
+
unreadable: afterBytes === null && !snapshotOversized,
|
|
3944
4117
|
}
|
|
3945
|
-
if (
|
|
3946
|
-
after.binary = looksBinary(
|
|
3947
|
-
after.lines = after.binary ? null : splitDiffLines(DIFF_TEXT_DECODER.decode(
|
|
4118
|
+
if (afterBytes !== null) {
|
|
4119
|
+
after.binary = looksBinary(afterBytes)
|
|
4120
|
+
after.lines = after.binary ? null : splitDiffLines(DIFF_TEXT_DECODER.decode(afterBytes)).length
|
|
3948
4121
|
}
|
|
3949
4122
|
const view: RollbackFileDiffResult = {
|
|
3950
4123
|
...base, ok: true, code: 'ok', path: file.path,
|
|
3951
4124
|
status: currentExists ? 'overwrite' : 'create', before, after,
|
|
4125
|
+
...(mergedFlag ? { merged: true } : {}),
|
|
4126
|
+
...(mergeSkippedReason === null ? {} : { mergeSkipped: true, mergeReason: mergeSkippedReason }),
|
|
3952
4127
|
}
|
|
3953
4128
|
// 结构性不可比对:逐条给出原因(有原因 ⇒ hunks 恒为空,绝不给半截差异)
|
|
3954
4129
|
if (snapshotOversized) return { ...view, reason: 'too-large' }
|
|
@@ -3958,7 +4133,7 @@ export class RollbackPointEngine {
|
|
|
3958
4133
|
if (before.binary || after.binary) return { ...view, reason: 'binary' }
|
|
3959
4134
|
const result = diffLines(
|
|
3960
4135
|
current === null ? '' : DIFF_TEXT_DECODER.decode(current),
|
|
3961
|
-
DIFF_TEXT_DECODER.decode(
|
|
4136
|
+
afterBytes === null ? '' : DIFF_TEXT_DECODER.decode(afterBytes),
|
|
3962
4137
|
{ context: ROLLBACK_FILE_DIFF_LIMITS.context, maxD: ROLLBACK_FILE_DIFF_LIMITS.maxD },
|
|
3963
4138
|
)
|
|
3964
4139
|
return {
|
|
@@ -4073,7 +4248,10 @@ export class RollbackPointEngine {
|
|
|
4073
4248
|
continue
|
|
4074
4249
|
}
|
|
4075
4250
|
const segment = state === 'workspace'
|
|
4076
|
-
|
|
4251
|
+
// 路径级选择(2026-10-17 定案 A)只在显式给出时透传 —— 缺省口径一字不变
|
|
4252
|
+
? await this.restoreWorkspace(record.workspace, input.onlyPaths === undefined
|
|
4253
|
+
? { removeCreated: input.removeCreated === true, sessionId: record.sessionId ?? '' }
|
|
4254
|
+
: { removeCreated: input.removeCreated === true, onlyPaths: input.onlyPaths, sessionId: record.sessionId ?? '' })
|
|
4077
4255
|
: await this.restoreSession(record.session)
|
|
4078
4256
|
segments.push(segment)
|
|
4079
4257
|
if (segment.childSessionId !== undefined) childSessionId = segment.childSessionId
|
|
@@ -4149,9 +4327,162 @@ export class RollbackPointEngine {
|
|
|
4149
4327
|
return null
|
|
4150
4328
|
}
|
|
4151
4329
|
|
|
4330
|
+
/**
|
|
4331
|
+
* 把一条「留点之后被**别的会话**写过」的记录做成三方合并结果(2026-10-17 跨对话安全回退)。
|
|
4332
|
+
*
|
|
4333
|
+
* 三方:`ours` = 记录里的内容(回退目标)、`base` = 目标点之后**最早**那份中间版本、
|
|
4334
|
+
* `theirs` = 当前磁盘。结果是「撤销本会话那次改动 + 保留对方后来的改动」。
|
|
4335
|
+
*
|
|
4336
|
+
* 返回:`write`(合并干净且确实要写,带目标哈希 + 期望的当前哈希)、
|
|
4337
|
+
* `unchanged`(合并结果就是当前内容 —— 对方没动这一处,本来就不需要写)、
|
|
4338
|
+
* `blocked`(不能安全处理:冲突 / 没有中间版本 / 二进制 / 差异被降级 / 对方改成了不存在 / 读不出)。
|
|
4339
|
+
*/
|
|
4340
|
+
private async mergeForeignRecord(
|
|
4341
|
+
file: RollbackWorkspaceFile,
|
|
4342
|
+
baseText: string | null,
|
|
4343
|
+
foreignEvidence: boolean,
|
|
4344
|
+
): Promise<
|
|
4345
|
+
| { kind: 'write'; bytes: Uint8Array; sha256: string; expectedCurrent: string }
|
|
4346
|
+
| { kind: 'unchanged' }
|
|
4347
|
+
| { kind: 'blocked'; reason: 'conflict' | 'no-base' | 'ambiguous' | 'binary' | 'degraded' | 'theirs-missing' | 'theirs-unreadable' | 'ours-unreadable' }
|
|
4348
|
+
/** 没有可用祖先、也没有「别人写过」的旁证 ⇒ 退回旧口径(整文件覆盖回目标版本) */
|
|
4349
|
+
| { kind: 'fallback' }
|
|
4350
|
+
> {
|
|
4351
|
+
let oursBytes: Uint8Array
|
|
4352
|
+
try {
|
|
4353
|
+
oursBytes = await this.readObject(file.object)
|
|
4354
|
+
if (sha256Hex(oursBytes) !== file.sha256) return { kind: 'blocked', reason: 'ours-unreadable' }
|
|
4355
|
+
} catch {
|
|
4356
|
+
return { kind: 'blocked', reason: 'ours-unreadable' }
|
|
4357
|
+
}
|
|
4358
|
+
let theirsBytes: Uint8Array
|
|
4359
|
+
try {
|
|
4360
|
+
theirsBytes = await fs.readFile(file.path)
|
|
4361
|
+
} catch (error) {
|
|
4362
|
+
return { kind: 'blocked', reason: (error as { code?: unknown }).code === 'ENOENT' ? 'theirs-missing' : 'theirs-unreadable' }
|
|
4363
|
+
}
|
|
4364
|
+
const oursText = Buffer.from(oursBytes).toString('utf8')
|
|
4365
|
+
// **base 必须真的携带「本会话改动之后」的信息**:若它与 ours(回退目标)逐字相同,说明它
|
|
4366
|
+
// 提供不了任何「本会话改了什么」的知识 —— 拿它当共同祖先会让合并结论变成「保持当前」,
|
|
4367
|
+
// 于是「回退到该点」成了「什么都不做」(真机 R-25 就是这个形态被抓出来的:guard 之后
|
|
4368
|
+
// 那个点的内容与目标点相同)。
|
|
4369
|
+
if (baseText === null || baseText === oursText) {
|
|
4370
|
+
// 有「别人写过」的旁证却没有可用祖先 ⇒ 无法安全撤销,宁可不动它并如实报告;
|
|
4371
|
+
// 两样都没有 ⇒ 退回旧口径(整文件覆盖回目标版本)—— 那正是「纯本会话回退」的常见形态。
|
|
4372
|
+
if (foreignEvidence) return { kind: 'blocked', reason: 'no-base' }
|
|
4373
|
+
return { kind: 'fallback' }
|
|
4374
|
+
}
|
|
4375
|
+
// 二进制:任何一侧含 NUL 就绝不按文本合并(三方合并算法只对文本有定义)
|
|
4376
|
+
if (oursBytes.includes(0) || theirsBytes.includes(0) || baseText.includes('\u0000')) return { kind: 'blocked', reason: 'binary' }
|
|
4377
|
+
const theirsText = Buffer.from(theirsBytes).toString('utf8')
|
|
4378
|
+
const result = mergeThreeWay(oursText, baseText, theirsText)
|
|
4379
|
+
if (!result.ok) {
|
|
4380
|
+
return { kind: 'blocked', reason: result.reason === 'conflict' ? 'conflict' : result.reason === 'degraded' ? 'degraded' : 'binary' }
|
|
4381
|
+
}
|
|
4382
|
+
// 调用方只在「当前内容 ≠ 记录内容」时才会走到这里 ⇒ `ours === theirs` 只可能是内容恰好相同
|
|
4383
|
+
if (oursText === theirsText) return { kind: 'unchanged' }
|
|
4384
|
+
if (result.text === theirsText) {
|
|
4385
|
+
// **合并结论 = 保持现状**:这次回退对工作区什么也做不了 —— 但 ours 与 theirs 明明不同
|
|
4386
|
+
//(否则上面那条就返回了)。真机 2026-10-17 的形状:base 取到了目标点**之后**的另一份快照
|
|
4387
|
+
//(里面已经含有「要撤销的那部分改动」),于是「应用对方的改动到 ours」恰好又变回现状。
|
|
4388
|
+
// 绝不能把它当成「无需改动」静默跳过 —— 那会让「回退」整个失效、界面上还看不到任何痕迹
|
|
4389
|
+
//(预览报 0 个文件、执行返回 nothing-to-restore)。这里按**无法安全撤销**如实处理:
|
|
4390
|
+
// 一个字节都不写,让用户看到并自己决定。
|
|
4391
|
+
return { kind: 'blocked', reason: 'ambiguous' }
|
|
4392
|
+
}
|
|
4393
|
+
const bytes = Buffer.from(result.text, 'utf8')
|
|
4394
|
+
return { kind: 'write', bytes, sha256: sha256Hex(bytes), expectedCurrent: sha256Hex(theirsBytes) }
|
|
4395
|
+
}
|
|
4396
|
+
|
|
4397
|
+
/**
|
|
4398
|
+
* 三方合并的 **base**:目标点之后**最早**那份仍含该路径的中间版本(共同祖先)。
|
|
4399
|
+
*
|
|
4400
|
+
* 为什么这么取:回滚点的「工作区态」是回合开始前拍的 ⇒ 别的会话那个回合的点,
|
|
4401
|
+
* 正好是「本会话改动之后、对方改动之前」的版本 —— 标准三方合并的共同祖先。
|
|
4402
|
+
* 取**最早**而不是最近是刻意选安全方向:宁可少撤销,也绝不把对方更晚的改动算进 ours 侧。
|
|
4403
|
+
*
|
|
4404
|
+
* 排除四类:目标点自己(`createdAt > afterAt`)、**救援点 guard**(它在执行回退那一刻拍,
|
|
4405
|
+
* 内容 ≈ 当前磁盘,拿它当 base 会让合并结果直接等于 ours = 整文件覆盖,正是本机制要消灭的形态)、
|
|
4406
|
+
* **本会话的点**(见上)、以及内容读不出来的点(继续往后找 —— 找不到 ⇒ 调用方按「无法安全撤销」
|
|
4407
|
+
* 处理,绝不用近似值代替)。
|
|
4408
|
+
*/
|
|
4409
|
+
private async baseVersionFor(target: string, afterAt: number, excludeSessionId: string | null): Promise<string | null> {
|
|
4410
|
+
const ledger = await this.readManifest()
|
|
4411
|
+
const key = pathKey(target)
|
|
4412
|
+
const candidates = ledger.entries
|
|
4413
|
+
.filter((entry) => entry.createdAt > afterAt
|
|
4414
|
+
&& entry.kind !== 'guard'
|
|
4415
|
+
// 只认**别的会话**的点:本会话后续回合的点当作 base,会把「本会话后续改动」也一并保留下来,
|
|
4416
|
+
// 那与「回退到该点之前」的直觉冲突;而别人的点正好是「本会话改动之后、对方改动之前」的版本。
|
|
4417
|
+
&& (excludeSessionId === null || entry.sessionId !== excludeSessionId))
|
|
4418
|
+
.sort((left, right) => (left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0))
|
|
4419
|
+
for (const entry of candidates) {
|
|
4420
|
+
const resolved = await this.readFullRecord(entry.id)
|
|
4421
|
+
const payload = resolved.record?.workspace.payload
|
|
4422
|
+
if (payload === null || payload === undefined) continue
|
|
4423
|
+
const hit = payload.files.find((item) => pathKey(item.path) === key)
|
|
4424
|
+
if (hit === undefined) continue
|
|
4425
|
+
try {
|
|
4426
|
+
const bytes = await this.readObject(hit.object)
|
|
4427
|
+
if (sha256Hex(bytes) !== hit.sha256) continue
|
|
4428
|
+
return Buffer.from(bytes).toString('utf8')
|
|
4429
|
+
} catch {
|
|
4430
|
+
continue
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
return null
|
|
4434
|
+
}
|
|
4435
|
+
|
|
4436
|
+
/**
|
|
4437
|
+
* **别的会话的回合点里记录过的路径集合** —— 删除集保护的第二种证据(不依赖归属台账)。
|
|
4438
|
+
*
|
|
4439
|
+
* 为什么需要第二证据:归属可能整条缺失(工具不在白名单 / 另一个 DSH 进程把 attribution.json
|
|
4440
|
+
* 整体覆盖写 / 采集时机错过),而「别人的点里出现过这个路径」是**点台账本身**记录的事实 ——
|
|
4441
|
+
* 它足以证明「这条路径在留点之后被别的会话所在的回合捕获过」,因此删它就是销毁别人的工作。
|
|
4442
|
+
*
|
|
4443
|
+
* 有界:只扫目标点之后**最早**的 `limit` 个别的会话的点(按时间升序),且只读路径不读内容。
|
|
4444
|
+
* 扫不完不影响安全方向(少保护也会被归属那条兜一部分),但绝不因此声称「已扫全」。
|
|
4445
|
+
*/
|
|
4446
|
+
private async foreignRecordedPaths(afterAt: number, excludeSessionId: string | null, limit = 10): Promise<Set<string>> {
|
|
4447
|
+
const out = new Set<string>()
|
|
4448
|
+
const ledger = await this.readManifest()
|
|
4449
|
+
const candidates = ledger.entries
|
|
4450
|
+
.filter((entry) => entry.createdAt > afterAt
|
|
4451
|
+
&& entry.kind !== 'guard'
|
|
4452
|
+
&& (excludeSessionId === null || entry.sessionId !== excludeSessionId))
|
|
4453
|
+
.sort((left, right) => (left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0))
|
|
4454
|
+
.slice(0, Math.max(1, limit))
|
|
4455
|
+
for (const entry of candidates) {
|
|
4456
|
+
const resolved = await this.readFullRecord(entry.id)
|
|
4457
|
+
const payload = resolved.record?.workspace.payload as
|
|
4458
|
+
| (RollbackWorkspacePayload & { delta?: { added?: unknown; removed?: unknown } })
|
|
4459
|
+
| null
|
|
4460
|
+
| undefined
|
|
4461
|
+
if (payload === null || payload === undefined) continue
|
|
4462
|
+
const files = (payload as { files?: unknown }).files
|
|
4463
|
+
if (Array.isArray(files)) {
|
|
4464
|
+
for (const item of files) {
|
|
4465
|
+
const target = (item as { path?: unknown }).path
|
|
4466
|
+
if (typeof target === 'string' && target !== '') out.add(pathKey(target))
|
|
4467
|
+
}
|
|
4468
|
+
continue
|
|
4469
|
+
}
|
|
4470
|
+
const delta = payload.delta
|
|
4471
|
+
if (delta === null || delta === undefined) continue
|
|
4472
|
+
for (const bucket of [delta.added, delta.removed]) {
|
|
4473
|
+
if (!Array.isArray(bucket)) continue
|
|
4474
|
+
for (const item of bucket) {
|
|
4475
|
+
const target = typeof item === 'string' ? item : (item as { path?: unknown } | null)?.path
|
|
4476
|
+
if (typeof target === 'string' && target !== '') out.add(pathKey(target))
|
|
4477
|
+
}
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
return out
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4152
4483
|
private async restoreWorkspace(
|
|
4153
4484
|
track: RollbackTrack<RollbackWorkspacePayload>,
|
|
4154
|
-
options: { removeCreated?: boolean } = {},
|
|
4485
|
+
options: { removeCreated?: boolean; onlyPaths?: readonly string[]; sessionId?: string } = {},
|
|
4155
4486
|
): Promise<RollbackSegmentResult> {
|
|
4156
4487
|
const payload = track.payload
|
|
4157
4488
|
if (!track.ok || payload === null) {
|
|
@@ -4162,11 +4493,78 @@ export class RollbackPointEngine {
|
|
|
4162
4493
|
// (界面据此说「该点没记录文件」而不是「工作区已与该点一致」)。
|
|
4163
4494
|
if (payload.files.length === 0) return this.noopSegment('workspace', 'no-recorded-files', 'files=0 nothingToDo=true')
|
|
4164
4495
|
const detail: string[] = []
|
|
4165
|
-
|
|
4496
|
+
// 路径级选择性恢复(2026-10-17 定案 A):null = 全量(旧口径,行为一字不变)。
|
|
4497
|
+
const selection = options.onlyPaths === undefined ? null : new Set(options.onlyPaths.map((target) => pathKey(target)))
|
|
4498
|
+
/** 因用户没勾选而跳过的记录条目数(只作事实回传,不参与任何安全判据) */
|
|
4499
|
+
let selectionSkipped = 0
|
|
4500
|
+
/**
|
|
4501
|
+
* 归属隔离(2026-10-17 定案 B):**没有显式点名**时,把「留点之后被别的会话写过」的记录条目
|
|
4502
|
+
* 自动跳过(上层据此如实报告)。只有 attribution 在、且这次回退知道自己属于哪个会话时才可能生效;
|
|
4503
|
+
* 缺任何一样 ⇒ 退化成旧口径(照常恢复)—— **未判定绝不当作别的会话写的**。
|
|
4504
|
+
*/
|
|
4505
|
+
let foreignSkipped = 0
|
|
4506
|
+
const isolationSessionId = typeof options.sessionId === 'string' && options.sessionId !== '' ? options.sessionId : null
|
|
4507
|
+
/**
|
|
4508
|
+
* **合并保护**(2026-10-17 修):触发条件**不看归属**,只看「有没有共同祖先可用」。
|
|
4509
|
+
*
|
|
4510
|
+
* 为什么必须这样:归属台账是**旁证**而不是判据 —— 对方的写入可能因为工具不在白名单、
|
|
4511
|
+
* 或另一个 DSH 进程把 attribution.json 整体覆盖写而**没被记到**。真机 2026-10-17:
|
|
4512
|
+
* 两个对话各给 README 加了一行描述,回退其中一个之后**两行都没了**,根因正是
|
|
4513
|
+
* 「归属里只有本会话那次编辑」⇒ 不认为有第三方 ⇒ 退回整文件覆盖。
|
|
4514
|
+
* 只要目标点之后**存在别的会话的回合点**,那它就是共同祖先 ⇒ 必须走三方合并。
|
|
4515
|
+
*/
|
|
4516
|
+
const mergeGuardEnabled = selection === null && isolationSessionId !== null
|
|
4517
|
+
/** 同一路径的 base 只查一次(合并判定在「确认需要写」之后,命中数很少) */
|
|
4518
|
+
const baseCache = new Map<string, string | null>()
|
|
4519
|
+
const sink: ChunkWalkSink = { skipped: [], excludedDirs: [], ignoredDirs: [], ignoredDirCount: 0, unreadableDirs: [], skippedCount: 0, skippedTruncated: false }
|
|
4520
|
+
// 删除集与**预览**共用同一份规划(planCreatedRemovals)⇒「预览说会删的」= 「实际删的」。
|
|
4521
|
+
// 为什么提到文件循环**之前**:选择模式要拿它当「合法路径」的第二来源(新建候选也允许被点名),
|
|
4522
|
+
// 而校验必须在**任何写入之前**完成。它是只读走查,提前算不会写任何东西。
|
|
4523
|
+
const plan = await this.planCreatedRemovals(payload, sink)
|
|
4524
|
+
if (selection !== null) {
|
|
4525
|
+
const legal = new Set<string>()
|
|
4526
|
+
for (const file of payload.files) legal.add(pathKey(file.path))
|
|
4527
|
+
for (const target of plan.all) legal.add(pathKey(target))
|
|
4528
|
+
const unknown = [...selection].filter((key) => !legal.has(key))
|
|
4529
|
+
if (unknown.length > 0) {
|
|
4530
|
+
// 点名了一个「既未记录、也不在删除候选」里的路径 ⇒ fail-closed:零写入失败,
|
|
4531
|
+
// 绝不把「用户打错一个路径」变成「静默少恢复几个文件」。
|
|
4532
|
+
pushDetail(detail, 'selection-unknown-path ' + unknown[0] + ' unknown=' + String(unknown.length) + ' zeroWrites=true')
|
|
4533
|
+
return {
|
|
4534
|
+
state: 'workspace', status: 'failed', reasonCode: 'invalid-input', detail,
|
|
4535
|
+
restored: 0, failed: unknown.length,
|
|
4536
|
+
leftovers: plan.all.slice(0, ROLLBACK_LEFTOVER_LIST_LIMIT), leftoversTruncated: plan.truncated,
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4540
|
+
/**
|
|
4541
|
+
* 待写条目。`sha256` / `expectedCurrent` **只被三方合并路径**使用:
|
|
4542
|
+
* · `sha256` = 这次真正要写进去的内容哈希(普通路径恒等于记录里的 `file.sha256`);
|
|
4543
|
+
* · `expectedCurrent` = 合并时读到的「当前磁盘」哈希 —— 写入前必须仍是它,否则说明
|
|
4544
|
+
* 文件在预览/合并之后又被改过 ⇒ **放弃写入**(并发保护:绝不覆盖比我们更新的内容)。
|
|
4545
|
+
*/
|
|
4546
|
+
const writable: { file: RollbackWorkspaceFile; bytes: Uint8Array; sha256?: string; expectedCurrent?: string }[] = []
|
|
4166
4547
|
let failed = 0
|
|
4167
4548
|
// 「已与记录一致」的条目数(身份快路径证明)—— 后面「0 变更」的文案与统计要用
|
|
4168
4549
|
let unchanged = 0
|
|
4550
|
+
/** 三方合并成功、写回「撤销本会话 + 保留对方改动」结果的条目数 */
|
|
4551
|
+
let mergedCount = 0
|
|
4552
|
+
/** 冲突:两侧改到同一处 ⇒ 绝不自动选边,交给用户 */
|
|
4553
|
+
let conflictCount = 0
|
|
4554
|
+
/** 无法安全撤销:没有中间版本 / 二进制 / 差异被降级 / 对方把它改成了不存在 */
|
|
4555
|
+
let unavailableCount = 0
|
|
4556
|
+
/** 写入前发现文件又被改过(并发)⇒ 放弃写入 */
|
|
4557
|
+
let concurrentCount = 0
|
|
4558
|
+
const mergedPaths: string[] = []
|
|
4559
|
+
const conflictPaths: string[] = []
|
|
4560
|
+
const unavailablePaths: string[] = []
|
|
4561
|
+
const concurrentPaths: string[] = []
|
|
4169
4562
|
for (const file of payload.files) {
|
|
4563
|
+
// 路径级选择:没被点名的记录条目**不写**(用户自己的选择,不是安全判据)
|
|
4564
|
+
if (selection !== null && !selection.has(pathKey(file.path))) {
|
|
4565
|
+
selectionSkipped += 1
|
|
4566
|
+
continue
|
|
4567
|
+
}
|
|
4170
4568
|
// 门:记录里的路径必须落在记录里的分块内(记录不可成为写原语)
|
|
4171
4569
|
const inChunk = payload.chunks.some((chunk) => path.isAbsolute(chunk) && isInsidePath(file.path, chunk))
|
|
4172
4570
|
if (!path.isAbsolute(file.path) || !inChunk || isInsidePath(file.path, this.root)) {
|
|
@@ -4192,16 +4590,66 @@ export class RollbackPointEngine {
|
|
|
4192
4590
|
pushDetail(detail, 'object-hash-mismatch ' + file.path)
|
|
4193
4591
|
continue
|
|
4194
4592
|
}
|
|
4593
|
+
// **合并保护**(2026-10-17 修):到这里才确认「这个文件确实需要写」,也才值得去查共同祖先。
|
|
4594
|
+
// 触发不吃归属:只要目标点之后存在**别的会话**的回合点(那它就是共同祖先),就走三方合并。
|
|
4595
|
+
if (mergeGuardEnabled) {
|
|
4596
|
+
let base: string | null
|
|
4597
|
+
const cached = baseCache.get(pathKey(file.path))
|
|
4598
|
+
if (cached === undefined) {
|
|
4599
|
+
base = await this.baseVersionFor(file.path, track.capturedAt, isolationSessionId)
|
|
4600
|
+
baseCache.set(pathKey(file.path), base)
|
|
4601
|
+
} else {
|
|
4602
|
+
base = cached
|
|
4603
|
+
}
|
|
4604
|
+
const foreignWriter = this.attribution?.writerOf(file.path)
|
|
4605
|
+
const foreignEvidence = foreignWriter !== undefined
|
|
4606
|
+
&& foreignWriter.sessionId !== isolationSessionId
|
|
4607
|
+
&& foreignWriter.at > track.capturedAt
|
|
4608
|
+
if (base !== null || foreignEvidence) {
|
|
4609
|
+
const outcome = await this.mergeForeignRecord(file, base, foreignEvidence)
|
|
4610
|
+
if (outcome.kind === 'fallback') {
|
|
4611
|
+
// 没有可用祖先、也没有别人的旁证 ⇒ 落到下面的旧口径(整文件覆盖回目标版本)
|
|
4612
|
+
} else {
|
|
4613
|
+
if (outcome.kind === 'write') {
|
|
4614
|
+
writable.push({ file, bytes: outcome.bytes, sha256: outcome.sha256, expectedCurrent: outcome.expectedCurrent })
|
|
4615
|
+
mergedCount += 1
|
|
4616
|
+
if (mergedPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) mergedPaths.push(file.path)
|
|
4617
|
+
continue
|
|
4618
|
+
}
|
|
4619
|
+
if (outcome.kind === 'unchanged') {
|
|
4620
|
+
unchanged += 1
|
|
4621
|
+
continue
|
|
4622
|
+
}
|
|
4623
|
+
foreignSkipped += 1
|
|
4624
|
+
if (outcome.reason === 'conflict') {
|
|
4625
|
+
conflictCount += 1
|
|
4626
|
+
if (conflictPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) conflictPaths.push(file.path)
|
|
4627
|
+
} else {
|
|
4628
|
+
unavailableCount += 1
|
|
4629
|
+
if (unavailablePaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) unavailablePaths.push(file.path)
|
|
4630
|
+
}
|
|
4631
|
+
pushDetail(detail, 'foreign-' + outcome.reason + ' ' + file.path + ' zeroWrites=true')
|
|
4632
|
+
continue
|
|
4633
|
+
}
|
|
4634
|
+
}
|
|
4635
|
+
}
|
|
4195
4636
|
writable.push({ file, bytes })
|
|
4196
4637
|
} catch (error) {
|
|
4197
4638
|
failed += 1
|
|
4198
4639
|
pushDetail(detail, 'object-missing ' + file.path + ' ' + errorDetail(error))
|
|
4199
4640
|
}
|
|
4200
4641
|
}
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4642
|
+
|
|
4643
|
+
if (selectionSkipped > 0) {
|
|
4644
|
+
pushDetail(detail, 'selection-skipped=' + String(selectionSkipped) + ' selection=' + String(selection?.size ?? 0))
|
|
4645
|
+
}
|
|
4646
|
+
if (foreignSkipped > 0) {
|
|
4647
|
+
pushDetail(detail, 'foreign-skipped=' + String(foreignSkipped) + ' (records written by other sessions after this point; not overwritten)')
|
|
4648
|
+
}
|
|
4649
|
+
if (mergedCount > 0) pushDetail(detail, 'foreign-merged=' + String(mergedCount) + ' (reverted this session, kept the other side)')
|
|
4650
|
+
if (conflictCount > 0) pushDetail(detail, 'foreign-conflict=' + String(conflictCount) + ' (both sides changed the same place; needs a human)')
|
|
4651
|
+
if (unavailableCount > 0) pushDetail(detail, 'foreign-unavailable=' + String(unavailableCount) + ' (no safe way to revert; left untouched)')
|
|
4652
|
+
if (concurrentCount > 0) pushDetail(detail, 'concurrent-change=' + String(concurrentCount) + ' (changed again after the merge; left untouched)')
|
|
4205
4653
|
const leftovers = {
|
|
4206
4654
|
leftovers: plan.all.slice(0, ROLLBACK_LEFTOVER_LIST_LIMIT),
|
|
4207
4655
|
truncated: plan.truncated,
|
|
@@ -4209,9 +4657,34 @@ export class RollbackPointEngine {
|
|
|
4209
4657
|
}
|
|
4210
4658
|
// 用户 2026-10-11 定案 A:把「回退点之后新建」的路径也退回去(删除)。
|
|
4211
4659
|
// removeCreated !== true ⇒ 只报告(paths 清空);判据仍是同一份 plan。
|
|
4212
|
-
|
|
4660
|
+
// 归属保护同样作用于**删除集**(2026-10-17 跨对话安全回退):某路径若在留点之后被**别的会话**
|
|
4661
|
+
// 写过,删它同样是在销毁别人的工作 ⇒ 拒删(进 createdSkipped;预览与执行共用同一份规划,
|
|
4662
|
+
// 所以界面看到的「不会删」与执行端的「真的没删」仍然一致)。
|
|
4663
|
+
const removalPlan = options.removeCreated === true
|
|
4213
4664
|
? { paths: plan.paths, skipped: plan.skipped, truncated: plan.truncated, refused: plan.refused }
|
|
4214
4665
|
: { paths: [] as string[], skipped: [] as string[], truncated: false, refused: null as CreatedRemovalPlan['refused'] }
|
|
4666
|
+
const protectedRemovals: string[] = []
|
|
4667
|
+
if (mergeGuardEnabled && removalPlan.paths.length > 0) {
|
|
4668
|
+
// 两类证据(任一成立就保护):① 归属表明别的会话写过它;② **别的会话的回合点里记录过该路径**。
|
|
4669
|
+
// 后者不依赖归属(归属可能因工具白名单 / 跨进程整体覆盖 / 采集时机而缺失,见 mergeGuardEnabled 的说明)。
|
|
4670
|
+
const foreignPaths = await this.foreignRecordedPaths(track.capturedAt, isolationSessionId)
|
|
4671
|
+
const keep: string[] = []
|
|
4672
|
+
for (const target of removalPlan.paths) {
|
|
4673
|
+
const writer = this.attribution?.writerOf(target)
|
|
4674
|
+
const byWriter = writer !== undefined && writer.sessionId !== isolationSessionId && writer.at > track.capturedAt
|
|
4675
|
+
if (byWriter || foreignPaths.has(pathKey(target))) {
|
|
4676
|
+
protectedRemovals.push(target)
|
|
4677
|
+
removalPlan.skipped.push(target)
|
|
4678
|
+
continue
|
|
4679
|
+
}
|
|
4680
|
+
keep.push(target)
|
|
4681
|
+
}
|
|
4682
|
+
removalPlan.paths = keep
|
|
4683
|
+
}
|
|
4684
|
+
const removal = removalPlan
|
|
4685
|
+
if (protectedRemovals.length > 0) {
|
|
4686
|
+
pushDetail(detail, 'foreign-removal-protected=' + String(protectedRemovals.length) + ' (written by other sessions; not deleted)')
|
|
4687
|
+
}
|
|
4215
4688
|
if (removal.paths.length > ROLLBACK_MAX_CREATED_REMOVALS) {
|
|
4216
4689
|
detail.push('created-removals=' + String(removal.paths.length) + ' limit=' + String(ROLLBACK_MAX_CREATED_REMOVALS) + ' zeroWrites=true')
|
|
4217
4690
|
return {
|
|
@@ -4234,13 +4707,30 @@ export class RollbackPointEngine {
|
|
|
4234
4707
|
}
|
|
4235
4708
|
// E1:内容已与快照一致的条目**不再写** —— 「0 变更」就要是真的 0 次写入
|
|
4236
4709
|
// (否则工作区已与该点一致时也会重写全部文件,白耗 I/O 并抖动 mtime)。
|
|
4237
|
-
const needed: { file: RollbackWorkspaceFile; bytes: Uint8Array }[] = []
|
|
4710
|
+
const needed: { file: RollbackWorkspaceFile; bytes: Uint8Array; sha256?: string; expectedCurrent?: string }[] = []
|
|
4238
4711
|
for (const item of writable) {
|
|
4712
|
+
const targetSha = item.sha256 ?? item.file.sha256
|
|
4713
|
+
// 合并路径的**并发闸门**:写入前必须确认磁盘上仍是合并时读到的那一份
|
|
4714
|
+
if (item.expectedCurrent !== undefined) {
|
|
4715
|
+
let current: string | null = null
|
|
4716
|
+
try {
|
|
4717
|
+
current = sha256Hex(await fs.readFile(item.file.path))
|
|
4718
|
+
} catch {
|
|
4719
|
+
current = null
|
|
4720
|
+
}
|
|
4721
|
+
if (current !== item.expectedCurrent) {
|
|
4722
|
+
concurrentCount += 1
|
|
4723
|
+
if (concurrentPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) concurrentPaths.push(item.file.path)
|
|
4724
|
+
pushDetail(detail, 'concurrent-change ' + item.file.path + ' zeroWrites=true')
|
|
4725
|
+
continue
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4239
4728
|
let identical = false
|
|
4240
4729
|
try {
|
|
4241
4730
|
const stat = await fs.stat(item.file.path)
|
|
4242
|
-
|
|
4243
|
-
|
|
4731
|
+
// 长度比的是**这次要写进去的那份内容**(合并路径下它与记录里的原文长度不一定相同)
|
|
4732
|
+
if (stat.isFile() && stat.size === item.bytes.byteLength) {
|
|
4733
|
+
identical = sha256Hex(await fs.readFile(item.file.path)) === targetSha
|
|
4244
4734
|
}
|
|
4245
4735
|
} catch {
|
|
4246
4736
|
identical = false
|
|
@@ -4260,6 +4750,12 @@ export class RollbackPointEngine {
|
|
|
4260
4750
|
noop.createdSkippedCount = removal.skipped.length
|
|
4261
4751
|
noop.createdSkippedTruncated = removal.truncated || removal.skipped.length > ROLLBACK_LEFTOVER_LIST_LIMIT
|
|
4262
4752
|
if (removal.refused !== null) noop.createdRefusedReason = removal.refused
|
|
4753
|
+
if (selectionSkipped > 0) noop.selectionSkipped = selectionSkipped
|
|
4754
|
+
if (foreignSkipped > 0) noop.foreignSkipped = foreignSkipped
|
|
4755
|
+
if (mergedCount > 0) { noop.mergedCount = mergedCount; noop.mergedPaths = mergedPaths }
|
|
4756
|
+
if (conflictCount > 0) { noop.conflictCount = conflictCount; noop.conflictPaths = conflictPaths }
|
|
4757
|
+
if (unavailableCount > 0) { noop.unavailableCount = unavailableCount; noop.unavailablePaths = unavailablePaths }
|
|
4758
|
+
if (concurrentCount > 0) { noop.concurrentCount = concurrentCount; noop.concurrentPaths = concurrentPaths }
|
|
4263
4759
|
return noop
|
|
4264
4760
|
}
|
|
4265
4761
|
// 前像(**字节落盘、索引随栈**):失败时要能整体回到恢复前状态 —— 只覆盖**真会被写**的文件。
|
|
@@ -4275,6 +4771,30 @@ export class RollbackPointEngine {
|
|
|
4275
4771
|
leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
|
|
4276
4772
|
}
|
|
4277
4773
|
}
|
|
4774
|
+
// **最终并发校验**(紧邻写入之前):合并/预览之后文件可能又被改过。前一刻的 needed 判定与
|
|
4775
|
+
// 真正写入之间还隔着前像暂存,所以这里再确认一次;不符的条目直接摘掉 ——
|
|
4776
|
+
// 绝不覆盖比我们更新的内容。
|
|
4777
|
+
if (needed.some((item) => item.expectedCurrent !== undefined)) {
|
|
4778
|
+
const keep: typeof needed = []
|
|
4779
|
+
for (const item of needed) {
|
|
4780
|
+
if (item.expectedCurrent === undefined) { keep.push(item); continue }
|
|
4781
|
+
let current: string | null = null
|
|
4782
|
+
try {
|
|
4783
|
+
current = sha256Hex(await fs.readFile(item.file.path))
|
|
4784
|
+
} catch {
|
|
4785
|
+
current = null
|
|
4786
|
+
}
|
|
4787
|
+
if (current !== item.expectedCurrent) {
|
|
4788
|
+
concurrentCount += 1
|
|
4789
|
+
if (concurrentPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) concurrentPaths.push(item.file.path)
|
|
4790
|
+
pushDetail(detail, 'concurrent-change ' + item.file.path + ' zeroWrites=true')
|
|
4791
|
+
continue
|
|
4792
|
+
}
|
|
4793
|
+
keep.push(item)
|
|
4794
|
+
}
|
|
4795
|
+
needed.length = 0
|
|
4796
|
+
needed.push(...keep)
|
|
4797
|
+
}
|
|
4278
4798
|
const preCount = needed.length
|
|
4279
4799
|
for (const item of needed) {
|
|
4280
4800
|
let bytes: Uint8Array | null = null
|
|
@@ -4328,7 +4848,8 @@ export class RollbackPointEngine {
|
|
|
4328
4848
|
for (const item of needed) {
|
|
4329
4849
|
try {
|
|
4330
4850
|
const current = await this.readFileBytes(item.file.path)
|
|
4331
|
-
|
|
4851
|
+
// 合并路径写入的不是记录里的原文,而是合并结果 ⇒ 复核必须比**这次真正写进去的那份**
|
|
4852
|
+
if (sha256Hex(current) !== (item.sha256 ?? item.file.sha256)) mismatched.push(item.file.path)
|
|
4332
4853
|
} catch {
|
|
4333
4854
|
mismatched.push(item.file.path)
|
|
4334
4855
|
}
|
|
@@ -4411,6 +4932,12 @@ export class RollbackPointEngine {
|
|
|
4411
4932
|
createdSkippedTruncated: removal.truncated || removal.skipped.length > ROLLBACK_LEFTOVER_LIST_LIMIT,
|
|
4412
4933
|
writtenBytes,
|
|
4413
4934
|
}
|
|
4935
|
+
if (selectionSkipped > 0) segment.selectionSkipped = selectionSkipped
|
|
4936
|
+
if (foreignSkipped > 0) segment.foreignSkipped = foreignSkipped
|
|
4937
|
+
if (mergedCount > 0) { segment.mergedCount = mergedCount; segment.mergedPaths = mergedPaths }
|
|
4938
|
+
if (conflictCount > 0) { segment.conflictCount = conflictCount; segment.conflictPaths = conflictPaths }
|
|
4939
|
+
if (unavailableCount > 0) { segment.unavailableCount = unavailableCount; segment.unavailablePaths = unavailablePaths }
|
|
4940
|
+
if (concurrentCount > 0) { segment.concurrentCount = concurrentCount; segment.concurrentPaths = concurrentPaths }
|
|
4414
4941
|
// 捕获不完整 ⇒ 一条都没删:如实回报原因(界面据此说「因为捕获不完整,一条都没动」)
|
|
4415
4942
|
if (removal.refused !== null) segment.createdRefusedReason = removal.refused
|
|
4416
4943
|
// 前像字节用完即清(成功路径):留在磁盘上只会变成无人认领的残留
|
|
@@ -4646,6 +5173,11 @@ export interface RollbackTrackerOptions {
|
|
|
4646
5173
|
log?: (line: string) => void
|
|
4647
5174
|
/** 会话日志 stat 探针(轮询回退用) */
|
|
4648
5175
|
statSessionLog?: (logPath: string) => Promise<{ size: number; mtimeMs: number } | undefined>
|
|
5176
|
+
/**
|
|
5177
|
+
* 写入归属采集器(2026-10-17 定案 B)。缺省 = 不采集 ⇒ 预览永远没有 foreign 标记,
|
|
5178
|
+
* 回退行为与旧版**逐字一致**。
|
|
5179
|
+
*/
|
|
5180
|
+
attribution?: RollbackWriteRecorder
|
|
4649
5181
|
}
|
|
4650
5182
|
|
|
4651
5183
|
export interface RollbackTrackerResult {
|
|
@@ -4825,6 +5357,7 @@ export class RollbackAutoTracker {
|
|
|
4825
5357
|
private readonly pollIntervalMs: number
|
|
4826
5358
|
private readonly log: ((line: string) => void) | undefined
|
|
4827
5359
|
private readonly statSessionLog: ((logPath: string) => Promise<{ size: number; mtimeMs: number } | undefined>) | undefined
|
|
5360
|
+
private readonly attribution: RollbackWriteRecorder | undefined
|
|
4828
5361
|
private readonly bindings = new Map<string, RollbackSessionBinding>()
|
|
4829
5362
|
private readonly open = new Map<string, { turn: number | null; startSeq: number; boundarySeq: number | null }>()
|
|
4830
5363
|
/**
|
|
@@ -4849,6 +5382,7 @@ export class RollbackAutoTracker {
|
|
|
4849
5382
|
)
|
|
4850
5383
|
this.log = options.log
|
|
4851
5384
|
this.statSessionLog = options.statSessionLog
|
|
5385
|
+
this.attribution = options.attribution
|
|
4852
5386
|
}
|
|
4853
5387
|
|
|
4854
5388
|
/** 实际采用的留点机制(output / 诊断用) */
|
|
@@ -4948,6 +5482,12 @@ export class RollbackAutoTracker {
|
|
|
4948
5482
|
endSeq: null,
|
|
4949
5483
|
})
|
|
4950
5484
|
}
|
|
5485
|
+
if (event.type === 'tool/call') {
|
|
5486
|
+
// 写入归属(2026-10-17 定案 B):**只记,不改任何留点行为**,也不 await 落盘
|
|
5487
|
+
// (落盘由 attribution 自己 debounce;tool/call 洪峰不该在这里等 I/O)。
|
|
5488
|
+
this.attribution?.noteToolCall(event.sessionId, event.data)
|
|
5489
|
+
return { captured: false }
|
|
5490
|
+
}
|
|
4951
5491
|
if (event.type !== 'turn/end') return { captured: false }
|
|
4952
5492
|
// 回合结束只为「缺省边界」留个记号(轮询/无边界回退用它),不再拍点。
|
|
4953
5493
|
this.open.delete(event.sessionId)
|