dsh-config-manager 0.1.78 → 0.1.79
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/credentials.d.ts +6 -2
- package/lib/adapters/credentials.js +19 -3
- package/lib/adapters/file-collection.js +2 -1
- package/lib/adapters/mcp.d.ts +48 -2
- package/lib/adapters/mcp.js +265 -3
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-files.js +2 -1
- package/lib/adapters/plugins.js +105 -20
- package/lib/adapters/pnpm-workspace.d.ts +32 -0
- package/lib/adapters/pnpm-workspace.js +113 -2
- package/lib/adapters/prompts.d.ts +6 -2
- package/lib/adapters/prompts.js +17 -2
- package/lib/adapters/providers.d.ts +19 -2
- package/lib/adapters/providers.js +59 -3
- package/lib/adapters/settings.d.ts +6 -2
- package/lib/adapters/settings.js +13 -2
- package/lib/adapters/skills.d.ts +25 -1
- package/lib/adapters/skills.js +60 -0
- package/lib/adapters/ui.d.ts +6 -2
- package/lib/adapters/ui.js +13 -2
- package/lib/adapters/units.d.ts +18 -0
- package/lib/adapters/units.js +39 -0
- package/lib/adapters/workspaces.js +2 -1
- package/lib/client.d.ts +400 -112
- package/lib/client.js +2845 -1524
- package/lib/core/backup.js +20 -0
- package/lib/core/mcp-connector-store.d.ts +159 -0
- package/lib/core/mcp-connector-store.js +445 -0
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +20 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +18 -1
- package/lib/core/plugin-cli.d.ts +22 -2
- package/lib/core/plugin-cli.js +22 -3
- package/lib/core/recovery-orchestrator.d.ts +3 -1
- package/lib/core/recovery-orchestrator.js +75 -16
- package/lib/core/rollback-activity.d.ts +89 -0
- package/lib/core/rollback-activity.js +350 -0
- package/lib/core/rollback-ledger.d.ts +0 -9
- package/lib/core/rollback-ledger.js +37 -0
- package/lib/core/rollback-merge.d.ts +22 -2
- package/lib/core/rollback-merge.js +63 -13
- package/lib/core/rollback-point.d.ts +93 -0
- package/lib/core/rollback-point.js +420 -101
- package/lib/core/rollback-undo-journal.d.ts +123 -0
- package/lib/core/rollback-undo-journal.js +517 -0
- package/lib/core/self-update.js +2 -4
- package/lib/core/text-fidelity.d.ts +89 -0
- package/lib/core/text-fidelity.js +176 -0
- package/lib/core/types.d.ts +12 -3
- package/lib/core/update-check.js +2 -4
- package/lib/core/validator.d.ts +9 -0
- package/lib/core/validator.js +12 -0
- package/lib/foreign/bundle.js +42 -3
- package/lib/foreign/claude-code.js +358 -26
- package/lib/foreign/claude-sessions.d.ts +51 -8
- package/lib/foreign/claude-sessions.js +111 -13
- package/lib/foreign/read-claude-code.d.ts +33 -4
- package/lib/foreign/read-claude-code.js +446 -65
- package/lib/foreign/read-claude-plugins.d.ts +36 -0
- package/lib/foreign/read-claude-plugins.js +403 -0
- package/lib/foreign/registry.js +25 -2
- package/lib/foreign/truth-table.js +6 -4
- package/lib/foreign/types.d.ts +170 -2
- package/lib/index.d.ts +14 -5
- package/lib/index.js +60 -16
- package/lib/market/git-file-writer.js +7 -1
- package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
- package/lib/profiles/dsh-profile-launcher.js +58 -1
- package/lib/profiles/dsh-profile-shared.d.ts +8 -2
- package/lib/profiles/dsh-profile-shared.js +8 -2
- package/lib/routes/foreign.js +8 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/sync.js +173 -9
- package/lib/schema/manifest.d.ts +6 -1
- package/lib/schema/manifest.js +55 -0
- package/lib/schema/types.d.ts +47 -2
- package/lib/sync/autosync-config.d.ts +73 -0
- package/lib/sync/autosync-config.js +89 -0
- package/lib/sync/autosync-scheduler.d.ts +80 -6
- package/lib/sync/autosync-scheduler.js +263 -40
- package/lib/sync/gist/gist-transport.d.ts +15 -1
- package/lib/sync/gist/gist-transport.js +66 -5
- package/lib/sync/gist/messages.d.ts +3 -0
- package/lib/sync/gist/messages.js +7 -0
- package/lib/sync/git/git-transport.d.ts +42 -3
- package/lib/sync/git/git-transport.js +146 -3
- package/lib/sync/merge.d.ts +47 -14
- package/lib/sync/merge.js +242 -222
- package/lib/sync/messages.d.ts +8 -0
- package/lib/sync/messages.js +19 -0
- package/lib/sync/risk.d.ts +19 -2
- package/lib/sync/risk.js +10 -3
- package/lib/sync/s3/messages.d.ts +6 -0
- package/lib/sync/s3/messages.js +13 -0
- package/lib/sync/s3/s3-transport.d.ts +9 -1
- package/lib/sync/s3/s3-transport.js +78 -6
- package/lib/sync/sync-engine.d.ts +193 -4
- package/lib/sync/sync-engine.js +450 -37
- package/lib/sync/sync-history.d.ts +11 -0
- package/lib/sync/sync-history.js +20 -0
- package/lib/sync/sync-state.d.ts +43 -0
- package/lib/sync/sync-state.js +44 -0
- package/lib/sync/sync-watcher.d.ts +76 -0
- package/lib/sync/sync-watcher.js +202 -0
- package/lib/sync/text-merge.d.ts +85 -0
- package/lib/sync/text-merge.js +513 -0
- package/lib/sync/transport.d.ts +48 -0
- package/lib/sync/transport.js +12 -0
- package/lib/sync/watch-scope.d.ts +38 -0
- package/lib/sync/watch-scope.js +95 -0
- package/lib/sync/webdav/webdav-transport.d.ts +16 -2
- package/lib/sync/webdav/webdav-transport.js +128 -11
- package/lib/ui/foreign-view.js +10 -0
- package/lib/ui/i18n.d.ts +8 -0
- package/lib/ui/i18n.js +20 -0
- package/lib/ui/rollback-view.d.ts +12 -0
- package/lib/ui/rollback-view.js +2 -0
- package/lib/ui/sync-settings-view.d.ts +47 -0
- package/lib/ui/sync-settings-view.js +24 -0
- package/lib/ui/types.d.ts +21 -0
- package/lib/utils/env-lock.d.ts +1 -0
- package/lib/utils/env-lock.js +7 -4
- package/package.json +6 -6
- package/src/adapters/credentials.ts +22 -4
- package/src/adapters/file-collection.ts +2 -1
- package/src/adapters/mcp.test.ts +235 -1
- package/src/adapters/mcp.ts +276 -5
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-files.ts +2 -1
- package/src/adapters/plugins.test.ts +176 -2
- package/src/adapters/plugins.ts +107 -21
- package/src/adapters/pnpm-workspace.test.ts +89 -1
- package/src/adapters/pnpm-workspace.ts +127 -2
- package/src/adapters/prompts.ts +20 -3
- package/src/adapters/providers.ts +62 -4
- package/src/adapters/settings.ts +16 -3
- package/src/adapters/skills.test.ts +141 -0
- package/src/adapters/skills.ts +58 -2
- package/src/adapters/ui.ts +16 -3
- package/src/adapters/units-entries.test.ts +190 -0
- package/src/adapters/units.ts +50 -0
- package/src/adapters/workspaces.ts +2 -1
- package/src/client/about/PluginUpdateDialog.tsx +4 -2
- package/src/client/about/ReleaseNotesDialog.tsx +4 -2
- package/src/client/chat/RollbackReviewDialog.tsx +3 -2
- package/src/client/common/Skeleton.tsx +32 -0
- package/src/client/common/routes.ts +9 -0
- package/src/client/config-manager.module.css +321 -11
- package/src/client/home/HomePanel.tsx +14 -6
- package/src/client/import/ForeignImportView.tsx +3 -1
- package/src/client/import/import-wizard-steps.tsx +2 -1
- package/src/client/library/ArtifactRow.tsx +12 -2
- package/src/client/library/LibraryActions.tsx +6 -3
- package/src/client/library/LibraryPanel.tsx +21 -3
- package/src/client/library/RestorePlanView.tsx +4 -2
- package/src/client/locales.ts +3 -0
- package/src/client/recovery/RecoveryPanel.tsx +54 -7
- package/src/client/recovery/RollbackCard.tsx +7 -2
- package/src/client/recovery/RollbackDiffPane.tsx +3 -2
- package/src/client/recovery/recovery-locales.ts +20 -2
- package/src/client/recovery/recovery-view.test.ts +39 -1
- package/src/client/recovery/recovery-view.ts +23 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/run-store.ts +4 -0
- package/src/client/sync/AutosyncCard.tsx +49 -6
- package/src/client/sync/MergeModal.tsx +219 -0
- package/src/client/sync/MergePreviewView.tsx +306 -0
- package/src/client/sync/SyncActivityBar.tsx +31 -0
- package/src/client/sync/SyncConfirmView.tsx +2 -1
- package/src/client/sync/SyncPanel.tsx +130 -87
- package/src/client/sync/SyncProbeRow.tsx +162 -0
- package/src/client/sync/SyncSettingsView.test.ts +27 -2
- package/src/client/sync/WorkDirCard.tsx +44 -6
- package/src/client/sync/history-model.ts +4 -1
- package/src/client/sync/merge-view.test.ts +234 -0
- package/src/client/sync/merge-view.ts +371 -0
- package/src/client/sync/probe-view.test.ts +90 -0
- package/src/client/sync/probe-view.ts +63 -0
- package/src/client/sync/remote-device-view.test.ts +35 -0
- package/src/client/sync/remote-device-view.ts +25 -0
- package/src/client/sync/sync-api.ts +212 -2
- package/src/client/sync/sync-entry.test.ts +78 -0
- package/src/client/sync/sync-entry.ts +53 -0
- package/src/client/sync/sync-locales.ts +234 -2
- package/src/client/sync/sync-view.test.ts +18 -1
- package/src/client/sync/sync-view.ts +20 -0
- package/src/core/backup.test.ts +41 -0
- package/src/core/backup.ts +20 -0
- package/src/core/mcp-connector-store.test.ts +265 -0
- package/src/core/mcp-connector-store.ts +516 -0
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +46 -0
- package/src/core/phase3-host.ts +18 -1
- package/src/core/phase3-production-integration.test.ts +4 -0
- package/src/core/plugin-cli.test.ts +26 -0
- package/src/core/plugin-cli.ts +30 -4
- package/src/core/recovery-orchestrator.test.ts +104 -5
- package/src/core/recovery-orchestrator.ts +74 -17
- package/src/core/rollback-activity.test.ts +311 -0
- package/src/core/rollback-activity.ts +367 -0
- package/src/core/rollback-cross-session.test.ts +694 -0
- package/src/core/rollback-interrupted.test.ts +137 -0
- package/src/core/rollback-ledger.ts +32 -0
- package/src/core/rollback-merge.test.ts +130 -0
- package/src/core/rollback-merge.ts +78 -14
- package/src/core/rollback-point.ts +468 -91
- package/src/core/rollback-undo-journal.test.ts +508 -0
- package/src/core/rollback-undo-journal.ts +571 -0
- package/src/core/self-update.ts +2 -5
- package/src/core/text-fidelity.test.ts +189 -0
- package/src/core/text-fidelity.ts +197 -0
- package/src/core/types.ts +11 -3
- package/src/core/update-check.ts +2 -5
- package/src/core/validator.ts +14 -0
- package/src/foreign/bundle-origin.test.ts +265 -0
- package/src/foreign/bundle.ts +45 -5
- package/src/foreign/claude-code.test.ts +268 -0
- package/src/foreign/claude-code.ts +406 -25
- package/src/foreign/claude-plugins.test.ts +234 -0
- package/src/foreign/claude-sessions.test.ts +141 -0
- package/src/foreign/claude-sessions.ts +179 -23
- package/src/foreign/file-budget.test.ts +4 -1
- package/src/foreign/read-claude-code.test.ts +320 -0
- package/src/foreign/read-claude-code.ts +490 -71
- package/src/foreign/read-claude-plugins.ts +465 -0
- package/src/foreign/registry.ts +25 -2
- package/src/foreign/session-ir.test.ts +65 -0
- package/src/foreign/truth-table.test.ts +81 -0
- package/src/foreign/truth-table.ts +6 -3
- package/src/foreign/types.ts +162 -2
- package/src/index.facade.test.ts +26 -4
- package/src/index.ts +76 -19
- package/src/market/git-file-writer.test.ts +4 -0
- package/src/market/git-file-writer.ts +7 -1
- package/src/profiles/dsh-profile-launcher.test.ts +68 -3
- package/src/profiles/dsh-profile-launcher.ts +56 -1
- package/src/profiles/dsh-profile-shared.ts +8 -2
- package/src/routes/foreign.ts +8 -1
- package/src/routes/index.ts +1 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/sync.ts +176 -9
- package/src/schema/manifest-schema.test.ts +87 -2
- package/src/schema/manifest.ts +57 -1
- package/src/schema/types.ts +45 -2
- package/src/sync/autosync-config.test.ts +73 -1
- package/src/sync/autosync-config.ts +124 -0
- package/src/sync/autosync-risk-tier.test.ts +38 -2
- package/src/sync/autosync-scheduler.test.ts +264 -12
- package/src/sync/autosync-scheduler.ts +296 -44
- package/src/sync/gist/gist-conditional-write.test.ts +223 -0
- package/src/sync/gist/gist-transport.ts +73 -7
- package/src/sync/gist/messages.ts +7 -0
- package/src/sync/git/git-probe.test.ts +80 -0
- package/src/sync/git/git-transport.test.ts +185 -0
- package/src/sync/git/git-transport.ts +178 -5
- package/src/sync/merge.test.ts +188 -55
- package/src/sync/merge.ts +282 -224
- package/src/sync/messages.ts +19 -0
- package/src/sync/risk.ts +29 -5
- package/src/sync/s3/messages.ts +13 -0
- package/src/sync/s3/s3-conditional-write.test.ts +330 -0
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +379 -0
- package/src/sync/sync-engine.ts +535 -36
- package/src/sync/sync-history.test.ts +16 -1
- package/src/sync/sync-history.ts +26 -0
- package/src/sync/sync-state.test.ts +64 -0
- package/src/sync/sync-state.ts +79 -0
- package/src/sync/sync-watcher.test.ts +197 -0
- package/src/sync/sync-watcher.ts +246 -0
- package/src/sync/text-merge.test.ts +358 -0
- package/src/sync/text-merge.ts +554 -0
- package/src/sync/transport.ts +57 -0
- package/src/sync/watch-scope.test.ts +122 -0
- package/src/sync/watch-scope.ts +120 -0
- package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
- package/src/sync/webdav/webdav-transport.ts +137 -14
- package/src/ui/foreign-view.test.ts +34 -0
- package/src/ui/foreign-view.ts +10 -0
- package/src/ui/i18n.ts +20 -0
- package/src/ui/rollback-view.ts +14 -0
- package/src/ui/sync-settings-view.test.ts +28 -1
- package/src/ui/sync-settings-view.ts +43 -0
- package/src/ui/types.ts +22 -0
- package/src/utils/env-lock.ts +8 -5
|
@@ -75,6 +75,8 @@ import { projectKeyOf } from './session-select.ts'
|
|
|
75
75
|
import { ignoreRelativePath, ignoreRuleMatches, loadIgnoreRules, type IgnoreRules } from './rollback-ignore-rules.ts'
|
|
76
76
|
import type { RollbackWriteAttributionPort, RollbackWriteFact, RollbackWriteRecorder } from './rollback-attribution.ts'
|
|
77
77
|
import { mergeThreeWay } from './rollback-merge.ts'
|
|
78
|
+
import { decodeTextBytes, encodeTextBytes } from './text-fidelity.ts'
|
|
79
|
+
import { UndoJournalWriter, findInterruptedUndos, readUndoJournal, replayUndoJournal, type InterruptedUndo, type UndoJournalEntry } from './rollback-undo-journal.ts'
|
|
78
80
|
import { isSessionLogName } from '../utils/session-log.ts'
|
|
79
81
|
import { decodeZstdFrame, scanZstdFrames, zstdAvailable, ZSTD_MAGIC } from '../utils/zstd-frame.ts'
|
|
80
82
|
import {
|
|
@@ -88,6 +90,74 @@ import {
|
|
|
88
90
|
type RollbackLedgerPrepared,
|
|
89
91
|
} from './rollback-ledger.ts'
|
|
90
92
|
|
|
93
|
+
/* ------------------------------------------------ 跨会话回退的归属证据 */
|
|
94
|
+
|
|
95
|
+
/** 合并被拒绝的机器原因(界面映射成字典键,绝不裸渲染) */
|
|
96
|
+
type MergeBlockReason =
|
|
97
|
+
| 'conflict' | 'no-base' | 'ambiguous' | 'binary' | 'non-utf8' | 'degraded'
|
|
98
|
+
| 'theirs-missing' | 'theirs-unreadable' | 'ours-unreadable'
|
|
99
|
+
/** 落点无人认领:改这条路径的那一刻没有任何会话活跃(手改 / 外部工具 / 另一个进程) */
|
|
100
|
+
| 'unclaimed'
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 「这条路径**是不是本会话写的**」—— 只读旁证,**绝不从内容猜**(2026-10-18)。
|
|
104
|
+
*
|
|
105
|
+
* · 'target' = 有证据表明是**本会话**写的(归属台账命中本会话,或 mtime 落在本会话活跃窗口内)
|
|
106
|
+
* ⇒ 允许按旧口径撤销;
|
|
107
|
+
* · 'foreign' = 有证据表明是**别的会话**写的(归属命中别的会话,或 mtime 落在别的会话窗口内)
|
|
108
|
+
* ⇒ 必须合并或拒绝,**绝不整文件覆盖**;
|
|
109
|
+
* · 'unclaimed' = **有覆盖**(时间窗数据可用)却没有任何会话在那一刻活跃 ⇒ 拒绝覆盖;
|
|
110
|
+
* · 'unknown' = 完全拿不到证据(老数据 / 未接时间窗 / stat 失败)⇒ **不得据此拦截**,退回既有口径。
|
|
111
|
+
*
|
|
112
|
+
* 为什么要时间窗:归属台账只从 tool/call 的参数里抽路径,bash / 脚本 / 外部编辑器写的路径拿不到归属
|
|
113
|
+
*(2026-10-17 真机「B 对话加了描述、回退 A 时把 B 那行也抹掉」的根因之一就是归属缺了 B 那次写入)。
|
|
114
|
+
* mtime 落在谁的活跃窗口内与工具参数无关,因此那类写入同样能被判出来。
|
|
115
|
+
*/
|
|
116
|
+
type WriteProvenance = 'target' | 'foreign' | 'unclaimed' | 'unknown'
|
|
117
|
+
|
|
118
|
+
/** 一次合并判定所需的全部旁证(调用方算好,本模块只消费) */
|
|
119
|
+
interface ForeignEvidence {
|
|
120
|
+
provenance: WriteProvenance
|
|
121
|
+
/** 别的会话的回合点里**没有**这条路径 ⇒ 当前内容是那个点之后才出现的(新建 / 重建) */
|
|
122
|
+
absentAtForeignPoint: boolean
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 活跃时间窗的**读**端口(core/rollback-activity.ts 的实现注入;缺省 = 没有时间证据) */
|
|
126
|
+
export interface RollbackActivityPort {
|
|
127
|
+
hasDataFor(sessionId: string): boolean
|
|
128
|
+
sessionsActiveAt(at: number, toleranceMs?: number): string[]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 活跃时间窗的**写**端口(tracker 用;缺省 = 不采集) */
|
|
132
|
+
export interface RollbackActivityRecorder {
|
|
133
|
+
noteActivity(sessionId: string, at?: number): void
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 逐字节相等(救援点当 base 的自证用) */
|
|
137
|
+
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
138
|
+
if (left.length !== right.length) return false
|
|
139
|
+
for (let i = 0; i < left.length; i += 1) if (left[i] !== right[i]) return false
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 前 8 KiB 是否含 NUL(与 text-fidelity / line-diff 同口径) */
|
|
144
|
+
function hasNulByte(bytes: Uint8Array): boolean {
|
|
145
|
+
const head = bytes.subarray(0, 8192)
|
|
146
|
+
for (let i = 0; i < head.length; i += 1) if (head[i] === 0) return true
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* latin1 视图:**字节的双射**(0..255 ↔ U+0000..U+00FF,逐字节一一对应)。
|
|
152
|
+
*
|
|
153
|
+
* 用途:三方合并的输入必须是「文本」,而非 UTF-8 文件(GBK / Latin-1 / 半截序列)用 UTF-8 解码
|
|
154
|
+
* 会把非法字节变成 U+FFFD ⇒ 写回即内容损毁(2026-10-18 真机形状:caf\u00E9 的 0xE9 被写成 EF BF BD)。
|
|
155
|
+
* latin1 视图下「字符串相等」= 「字节相等」,行级 diff / 合并因此**等价于按字节做**:非法字节逐字保留。
|
|
156
|
+
*/
|
|
157
|
+
function latin1Of(bytes: Uint8Array): string {
|
|
158
|
+
return Buffer.from(bytes).toString('latin1')
|
|
159
|
+
}
|
|
160
|
+
|
|
91
161
|
/* ------------------------------------------------------------------ 常量 */
|
|
92
162
|
|
|
93
163
|
/** 增量链的最大长度:到顶就落一个**全量锚点**(重建最多读 1 份全量 + 几份小增量) */
|
|
@@ -884,6 +954,25 @@ export interface RollbackPreviewWorkspace {
|
|
|
884
954
|
skippedCount: number
|
|
885
955
|
/** 捕获是否被上限截断(超单文件上限 / 字节预算 / 文件数预算任一命中即为 true) */
|
|
886
956
|
truncated: boolean
|
|
957
|
+
/**
|
|
958
|
+
* **冲突**:两侧改到同一处(含同一插入点)⇒ 回退会**跳过**该文件(零写入)。
|
|
959
|
+
* 与执行阶段的 `conflictCount` 同一判据(调用同一份 `mergeForeignRecord`)⇒「预览说有冲突的」=「执行时真的跳过」。
|
|
960
|
+
*/
|
|
961
|
+
conflictCount: number
|
|
962
|
+
/** 冲突路径逐条清单(上限 {@link ROLLBACK_LEFTOVER_LIST_LIMIT}) */
|
|
963
|
+
conflictPaths: string[]
|
|
964
|
+
/**
|
|
965
|
+
* **无法安全撤销**而会被跳过的文件总数(冲突之外的成因:没有共同祖先 / 二进制 / 差异被降级 /
|
|
966
|
+
* 对方把它改成了不存在 / 读不出)。预览把它们**列出来**而不是静默略过 —— 逐条原因在
|
|
967
|
+
* {@link files} 的 `mergeReason` 上。
|
|
968
|
+
*/
|
|
969
|
+
unavailableCount: number
|
|
970
|
+
/**
|
|
971
|
+
* **落点无人认领**而会被跳过的文件总数(时间窗归属判定:文件在目标会话静默之后被改过,
|
|
972
|
+
* 且当时没有任何会话处于活跃窗口 —— 手改 / 外部工具 / 另一个进程)。
|
|
973
|
+
* 成因逐条在 {@link files} 的 `mergeReason`(`external-edits`)上。
|
|
974
|
+
*/
|
|
975
|
+
externalCount: number
|
|
887
976
|
}
|
|
888
977
|
|
|
889
978
|
export interface RollbackPreviewSession {
|
|
@@ -1002,6 +1091,14 @@ export interface RollbackSegmentResult {
|
|
|
1002
1091
|
* 只在**没有显式点名**(`onlyPaths` 缺省)时生效;显式点名 = 用户明确要它,归属不再拦。
|
|
1003
1092
|
*/
|
|
1004
1093
|
foreignSkipped?: number
|
|
1094
|
+
/**
|
|
1095
|
+
* **落点无人认领**而未写回的文件总数(时间窗归属判定:文件在目标会话静默之后被改过,
|
|
1096
|
+
* 而那一刻没有任何会话处于活跃窗口 ⇒ 手改 / 外部工具 / 另一个 DSH 进程)。
|
|
1097
|
+
* 与 conflictCount / unavailableCount 一样是「故意没退」,绝不能并进「0 变更」。
|
|
1098
|
+
*/
|
|
1099
|
+
externalCount?: number
|
|
1100
|
+
/** 无人认领而未写回的路径逐条清单(上限 {@link ROLLBACK_LEFTOVER_LIST_LIMIT}) */
|
|
1101
|
+
externalPaths?: string[]
|
|
1005
1102
|
/** 三方合并成功、写回「撤销本会话 + 保留对方改动」的条目数(有界清单同给) */
|
|
1006
1103
|
mergedCount?: number
|
|
1007
1104
|
mergedPaths?: string[]
|
|
@@ -1073,6 +1170,11 @@ export interface RollbackEngineOptions {
|
|
|
1073
1170
|
* 回退行为与旧版**逐字一致**(未判定绝不当作「别的会话写的」)。
|
|
1074
1171
|
*/
|
|
1075
1172
|
attribution?: RollbackWriteAttributionPort
|
|
1173
|
+
/**
|
|
1174
|
+
* 会话活跃时间窗(2026-10-18 新增)。缺省 = 没有时间证据 ⇒ 归属判定退回归属台账那一条,
|
|
1175
|
+
* 回退行为与加它之前**逐字一致**(未判定绝不当作「别的会话写的」,也绝不凭空拦截)。
|
|
1176
|
+
*/
|
|
1177
|
+
activity?: RollbackActivityPort
|
|
1076
1178
|
/** DSH home(会话根 = homeDir/sessions) */
|
|
1077
1179
|
homeDir: string
|
|
1078
1180
|
now?: () => number
|
|
@@ -1695,24 +1797,30 @@ interface RewindDraft {
|
|
|
1695
1797
|
* 目录名带 pid,崩溃残留由 rollback-ledger 的 `cleanupOrphanTempFiles` 在下次启动回收。
|
|
1696
1798
|
*/
|
|
1697
1799
|
class UndoStager {
|
|
1698
|
-
private readonly entries: { path: string; file: string | null }[] = []
|
|
1699
1800
|
private readonly dir: string
|
|
1700
|
-
private
|
|
1801
|
+
private readonly journal: UndoJournalWriter
|
|
1701
1802
|
|
|
1702
|
-
constructor(dir: string) {
|
|
1803
|
+
constructor(dir: string, journal: UndoJournalWriter) {
|
|
1703
1804
|
this.dir = dir
|
|
1805
|
+
this.journal = journal
|
|
1704
1806
|
}
|
|
1705
1807
|
|
|
1706
|
-
/**
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
this.
|
|
1714
|
-
|
|
1715
|
-
|
|
1808
|
+
/**
|
|
1809
|
+
* 记一条前像:`bytes === null` = 该路径当时**不存在**(回滚时删掉它)。
|
|
1810
|
+
*
|
|
1811
|
+
* `after` = 本条**回退动作会写成什么**(写入 = 内容 sha256;删除 = deleted)。它同时是崩溃恢复
|
|
1812
|
+
* 的安全判据:只有「当前内容仍等于 after 镜像」时重放才被允许(绝不覆盖后来又被改过的文件)。
|
|
1813
|
+
*/
|
|
1814
|
+
async stage(target: string, bytes: Uint8Array | null, after: { sha256: string } | { deleted: true }): Promise<void> {
|
|
1815
|
+
await this.journal.record(target, bytes, after)
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/**
|
|
1819
|
+
* 全部写入 / 删除都成功之后才调用:落 `done` 标记 ⇒ 这条前像不再算「中断的回退」。
|
|
1820
|
+
* 崩在它之前 = 启动期能认出「半回退现场」并按 after 镜像安全重放(见 interruptedRollbacks)。
|
|
1821
|
+
*/
|
|
1822
|
+
async finish(): Promise<void> {
|
|
1823
|
+
await this.journal.finish()
|
|
1716
1824
|
}
|
|
1717
1825
|
|
|
1718
1826
|
/**
|
|
@@ -1720,11 +1828,21 @@ class UndoStager {
|
|
|
1720
1828
|
* 返回失败清单 —— 回滚失败绝不静默。
|
|
1721
1829
|
*/
|
|
1722
1830
|
async rollback(writer: (target: string, bytes: Uint8Array) => Promise<void>): Promise<string[]> {
|
|
1831
|
+
// 判据来源改成 **journal 本身**(不再是进程内的内存列表):内存路径与崩溃后的磁盘路径共用
|
|
1832
|
+
// 同一份条目,不会各存一套、也不会各有一套口径。
|
|
1833
|
+
let entries: UndoJournalEntry[]
|
|
1834
|
+
try {
|
|
1835
|
+
const snapshot = await readUndoJournal(this.dir)
|
|
1836
|
+
entries = snapshot === null ? [] : snapshot.entries
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
// 前像读不出来 ⇒ **必须报失败**,绝不静默当成「没有要回滚的」
|
|
1839
|
+
return ['undo-journal-unreadable ' + errorDetail(error)]
|
|
1840
|
+
}
|
|
1723
1841
|
const failed: string[] = []
|
|
1724
|
-
for (const entry of [...
|
|
1842
|
+
for (const entry of [...entries].reverse()) {
|
|
1725
1843
|
try {
|
|
1726
|
-
if (entry.
|
|
1727
|
-
else await writer(entry.path, await fs.readFile(entry.
|
|
1844
|
+
if (entry.bin === null) await fs.rm(entry.path, { force: true })
|
|
1845
|
+
else await writer(entry.path, await fs.readFile(path.join(this.dir, entry.bin)))
|
|
1728
1846
|
} catch (error) {
|
|
1729
1847
|
failed.push(entry.path + ' ' + errorDetail(error))
|
|
1730
1848
|
}
|
|
@@ -1854,6 +1972,7 @@ export class RollbackPointEngine {
|
|
|
1854
1972
|
private readonly ledger: RollbackLedger
|
|
1855
1973
|
/** 写入归属(2026-10-17 定案 B)。undefined = 没有归属信息 ⇒ 预览不标 foreign */
|
|
1856
1974
|
private readonly attribution: RollbackWriteAttributionPort | undefined
|
|
1975
|
+
private readonly activity: RollbackActivityPort | undefined
|
|
1857
1976
|
private readonly ledgerBytesBudget: number
|
|
1858
1977
|
/**
|
|
1859
1978
|
* 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS)。**可变**:
|
|
@@ -1911,6 +2030,7 @@ export class RollbackPointEngine {
|
|
|
1911
2030
|
this.guardPolicy = options.guardPolicy ?? 'warn'
|
|
1912
2031
|
this.probe = options.storage
|
|
1913
2032
|
this.attribution = options.attribution
|
|
2033
|
+
this.activity = options.activity
|
|
1914
2034
|
this.forkSeam = options.fork
|
|
1915
2035
|
this.writeSeam = options.writeFile
|
|
1916
2036
|
this.readSeam = options.readFile
|
|
@@ -1994,7 +2114,16 @@ export class RollbackPointEngine {
|
|
|
1994
2114
|
this.warn('rollback undo staging unavailable: ' + errorDetail(error))
|
|
1995
2115
|
return null
|
|
1996
2116
|
}
|
|
1997
|
-
|
|
2117
|
+
// 前像 journal(2026-10-18):**先落 bin → 再 append 一行索引 + fsync**;全部写完才落 done。
|
|
2118
|
+
// 崩在写入中途时这个目录会被启动期认出是「中断的回退」并保持可重放,
|
|
2119
|
+
// 而不会被 cleanupOrphanTempFiles 当垃圾删掉(见 rollback-ledger 的同名判据)。
|
|
2120
|
+
try {
|
|
2121
|
+
return new UndoStager(dir, await UndoJournalWriter.create(dir, { now: () => this.stamp() }))
|
|
2122
|
+
} catch (error) {
|
|
2123
|
+
// journal 建不出来 = 没有安全网 ⇒ **零写入失败**(与「暂存目录建不出来」同一条判据)
|
|
2124
|
+
this.warn('rollback undo journal unavailable: ' + errorDetail(error))
|
|
2125
|
+
return null
|
|
2126
|
+
}
|
|
1998
2127
|
}
|
|
1999
2128
|
|
|
2000
2129
|
private async writeFile(target: string, bytes: Uint8Array): Promise<void> {
|
|
@@ -3766,6 +3895,67 @@ export class RollbackPointEngine {
|
|
|
3766
3895
|
return { added: counts.added, removed: counts.removed, truncated: false }
|
|
3767
3896
|
}
|
|
3768
3897
|
|
|
3898
|
+
/**
|
|
3899
|
+
* **中断的回退**(2026-10-18):进程在「回退写到一半」时没了 ⇒ 工作区停在半回退状态。
|
|
3900
|
+
*
|
|
3901
|
+
* 判据 = 目录名形态(`<pid>-<ts36>-<8hex>`)+ 有 journal 索引 + **没有 done** + 持锁进程已死
|
|
3902
|
+
*(全部在 rollback-undo-journal.ts 里,可注入 isAlive 以便测试)。
|
|
3903
|
+
*
|
|
3904
|
+
* 只读:不改工作区、不删现场;是否重放由用户决定(见 recoverInterruptedRollback)。
|
|
3905
|
+
* 这也是 cleanupOrphanTempFiles 不再整目录删它的原因 —— 删了就等于把半回退现场变成
|
|
3906
|
+
* 「无人知晓的部分修改」,正是任务要求里禁止留下的东西。
|
|
3907
|
+
*/
|
|
3908
|
+
async interruptedRollbacks(): Promise<InterruptedUndo[]> {
|
|
3909
|
+
try {
|
|
3910
|
+
return await findInterruptedUndos(path.join(this.root, ROLLBACK_UNDO_DIR_NAME))
|
|
3911
|
+
} catch (error) {
|
|
3912
|
+
this.warn('rollback interrupted scan failed: ' + errorDetail(error))
|
|
3913
|
+
return []
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
/**
|
|
3918
|
+
* 把一条中断的回退**退回回退前**(重放前像),返回逐条结果。
|
|
3919
|
+
*
|
|
3920
|
+
* 安全边界全在 replayUndoJournal 里:只有「当前内容仍等于 after 镜像」的条目才写回 ——
|
|
3921
|
+
* 之后发生的任何人工 / 外部 / 别的会话的改动一律**跳过**并如实回报(绝不覆盖)。
|
|
3922
|
+
* 只有全部条目都成功(没有 skipped / failed)才清理现场;否则现场留着,可以再看、再试。
|
|
3923
|
+
*/
|
|
3924
|
+
async recoverInterruptedRollback(dir: string): Promise<{ ok: boolean; restored: number; skipped: string[]; failed: string[]; detail: string }> {
|
|
3925
|
+
const list = await this.interruptedRollbacks()
|
|
3926
|
+
const hit = list.find((item) => pathKey(item.dir) === pathKey(dir))
|
|
3927
|
+
if (hit === undefined) return { ok: false, restored: 0, skipped: [], failed: [], detail: 'not-an-interrupted-rollback' }
|
|
3928
|
+
let result: { restored: number; skipped: string[]; failed: string[] }
|
|
3929
|
+
try {
|
|
3930
|
+
result = await replayUndoJournal(hit, {
|
|
3931
|
+
readCurrent: async (target) => {
|
|
3932
|
+
try {
|
|
3933
|
+
return await this.readFileBytes(target)
|
|
3934
|
+
} catch (error) {
|
|
3935
|
+
// 读不出来(EACCES 等)**不等于不存在**:抛出去让 replay 记 failed,绝不写
|
|
3936
|
+
if ((error as { code?: unknown }).code === 'ENOENT') return null
|
|
3937
|
+
throw error
|
|
3938
|
+
}
|
|
3939
|
+
},
|
|
3940
|
+
write: (target, bytes) => this.writeFile(target, bytes),
|
|
3941
|
+
})
|
|
3942
|
+
} catch (error) {
|
|
3943
|
+
return { ok: false, restored: 0, skipped: [], failed: [], detail: errorDetail(error) }
|
|
3944
|
+
}
|
|
3945
|
+
const ok = result.failed.length === 0 && result.skipped.length === 0
|
|
3946
|
+
if (ok) {
|
|
3947
|
+
try {
|
|
3948
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
3949
|
+
} catch {
|
|
3950
|
+
/* 现场清理失败不影响「已经恢复好了」这个事实 */
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
return {
|
|
3954
|
+
ok, restored: result.restored, skipped: result.skipped, failed: result.failed,
|
|
3955
|
+
detail: 'restored=' + String(result.restored) + ' skipped=' + String(result.skipped.length) + ' failed=' + String(result.failed.length),
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3769
3959
|
/** 只读预览:零写入(不过确认门、不拍 guard、不写任何字节)。 */
|
|
3770
3960
|
async preview(id: string): Promise<RollbackPreviewResult> {
|
|
3771
3961
|
const storage = await this.storageStatus()
|
|
@@ -3778,6 +3968,7 @@ export class RollbackPointEngine {
|
|
|
3778
3968
|
removals: [], removalsCount: 0, removalsTruncated: false,
|
|
3779
3969
|
createdSkipped: [], createdSkippedCount: 0, createdSkippedTruncated: false,
|
|
3780
3970
|
createdRefusedReason: null,
|
|
3971
|
+
conflictCount: 0, conflictPaths: [], unavailableCount: 0, externalCount: 0,
|
|
3781
3972
|
chunks: [], available: false, reasonCode: null,
|
|
3782
3973
|
skippedCount: 0, truncated: false,
|
|
3783
3974
|
}
|
|
@@ -3831,8 +4022,13 @@ export class RollbackPointEngine {
|
|
|
3831
4022
|
// 单张预览共享的比对预算(字节 + 步数);只有真的会被改动的条目才消耗它
|
|
3832
4023
|
const diffBudget = { bytes: 0, work: 0 }
|
|
3833
4024
|
/** 合并判定的会话身份与 base 缓存(与执行端同一判据:拿目标点的会话当「本会话」) */
|
|
3834
|
-
|
|
3835
|
-
|
|
4025
|
+
// 救援点(guard)是「回到回退前那一刻」的**精确恢复**语义 ⇒ 关掉隔离与三方合并
|
|
4026
|
+
// (sessionId='' ⇒ isolationSessionId=null)。R-25 冻结了这条口径:回退救援点必须把工作区
|
|
4027
|
+
// **逐字**写回那一刻;让它走合并会变成「什么都不做」(内容与当前磁盘一致)。
|
|
4028
|
+
const previewIsolationSessionId = record.kind === 'guard'
|
|
4029
|
+
? null
|
|
4030
|
+
: (typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null)
|
|
4031
|
+
const previewBaseCache = new Map<string, { bytes: Uint8Array; fromGuard: boolean } | null>()
|
|
3836
4032
|
// T10:stat 的三种结局分开表达 —— 「不存在(ENOENT)」与「存在性未知(其它 errno)」绝不混为一谈
|
|
3837
4033
|
const previewFiles = wsPayload.files
|
|
3838
4034
|
// 阶段 A(**并发**):逐条一次 bigint lstat + 身份快路径。大仓库这里是预览的大头:
|
|
@@ -3899,15 +4095,18 @@ export class RollbackPointEngine {
|
|
|
3899
4095
|
// 也算成「会被删掉」,用户据此以为会丢别人的改动(真机:两行描述都显示要删,实际只删 A 那行)。
|
|
3900
4096
|
const decision = await (async (): Promise<{ mode: 'overwrite' | 'merged' | 'skip' | 'unchanged'; bytes?: Uint8Array; reason?: string }> => {
|
|
3901
4097
|
if (current === null || previewIsolationSessionId === null) return { mode: 'overwrite' }
|
|
3902
|
-
let
|
|
3903
|
-
if (
|
|
3904
|
-
|
|
3905
|
-
previewBaseCache.set(pathKey(file.path),
|
|
4098
|
+
let base = previewBaseCache.get(pathKey(file.path))
|
|
4099
|
+
if (base === undefined) {
|
|
4100
|
+
base = await this.baseFor(file.path, wsTrack.capturedAt, previewIsolationSessionId)
|
|
4101
|
+
previewBaseCache.set(pathKey(file.path), base)
|
|
4102
|
+
}
|
|
4103
|
+
const evidence: ForeignEvidence = {
|
|
4104
|
+
provenance: await this.provenanceOf(file.path, wsTrack.capturedAt, previewIsolationSessionId),
|
|
4105
|
+
// 「别人的回合点里没有它」只在**拿不到祖先**时才问(有祖先就不必再读一遍台账)
|
|
4106
|
+
absentAtForeignPoint: base === null
|
|
4107
|
+
&& await this.absentAtForeignPointOf(file.path, wsTrack.capturedAt, previewIsolationSessionId),
|
|
3906
4108
|
}
|
|
3907
|
-
const
|
|
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)
|
|
4109
|
+
const outcome = await this.mergeForeignRecord(file, base, evidence)
|
|
3911
4110
|
if (outcome.kind === 'write') return { mode: 'merged', bytes: outcome.bytes }
|
|
3912
4111
|
if (outcome.kind === 'unchanged') return { mode: 'unchanged' }
|
|
3913
4112
|
if (outcome.kind === 'blocked') return { mode: 'skip', reason: outcome.reason }
|
|
@@ -3954,6 +4153,17 @@ export class RollbackPointEngine {
|
|
|
3954
4153
|
// 捕获期的跳过计数与截断标记如实回传(列表被记录上限截断时 skippedCount 仍是真实总数)
|
|
3955
4154
|
workspace.skippedCount = wsPayload.skippedCount
|
|
3956
4155
|
workspace.truncated = wsPayload.truncated
|
|
4156
|
+
// 冲突 / 无法安全撤销 / 落点无人认领 —— **预览逐条列出**,不是静默略过(任务要求:
|
|
4157
|
+
// 「回退前显示预计撤销的修改、保留的后续修改及潜在冲突」)。三者与执行阶段**同源**:
|
|
4158
|
+
// 都来自同一份 mergeForeignRecord 的 blocked 原因,写进逐文件的 mergeReason。
|
|
4159
|
+
const blockedFiles = changedFiles.filter((item) => item.skipped === true)
|
|
4160
|
+
workspace.conflictCount = blockedFiles.filter((item) => item.mergeReason === 'conflict').length
|
|
4161
|
+
workspace.conflictPaths = blockedFiles
|
|
4162
|
+
.filter((item) => item.mergeReason === 'conflict')
|
|
4163
|
+
.map((item) => item.path)
|
|
4164
|
+
.slice(0, ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
4165
|
+
workspace.externalCount = blockedFiles.filter((item) => item.mergeReason === 'external-edits').length
|
|
4166
|
+
workspace.unavailableCount = blockedFiles.length - workspace.conflictCount - workspace.externalCount
|
|
3957
4167
|
workspaceRestorable = wsPayload.files.length > 0
|
|
3958
4168
|
}
|
|
3959
4169
|
|
|
@@ -4081,11 +4291,14 @@ export class RollbackPointEngine {
|
|
|
4081
4291
|
let mergeSkippedReason: string | null = null
|
|
4082
4292
|
const isolationSessionId = typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null
|
|
4083
4293
|
if (current !== null && snapshot !== null && isolationSessionId !== null) {
|
|
4084
|
-
const
|
|
4085
|
-
const
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4294
|
+
const base = await this.baseFor(file.path, record.workspace.capturedAt, isolationSessionId)
|
|
4295
|
+
const evidence: ForeignEvidence = {
|
|
4296
|
+
provenance: await this.provenanceOf(file.path, record.workspace.capturedAt, isolationSessionId),
|
|
4297
|
+
absentAtForeignPoint: base === null
|
|
4298
|
+
&& await this.absentAtForeignPointOf(file.path, record.workspace.capturedAt, isolationSessionId),
|
|
4299
|
+
}
|
|
4300
|
+
if (base !== null || evidence.provenance !== 'unknown' || evidence.absentAtForeignPoint) {
|
|
4301
|
+
const outcome = await this.mergeForeignRecord(file, base, evidence)
|
|
4089
4302
|
if (outcome.kind === 'write') {
|
|
4090
4303
|
mergedAfter = outcome.bytes
|
|
4091
4304
|
mergedFlag = true
|
|
@@ -4250,8 +4463,11 @@ export class RollbackPointEngine {
|
|
|
4250
4463
|
const segment = state === 'workspace'
|
|
4251
4464
|
// 路径级选择(2026-10-17 定案 A)只在显式给出时透传 —— 缺省口径一字不变
|
|
4252
4465
|
? await this.restoreWorkspace(record.workspace, input.onlyPaths === undefined
|
|
4253
|
-
|
|
4254
|
-
|
|
4466
|
+
// 救援点(guard)是「回到回退前那一刻」的**精确恢复**语义 ⇒ 关掉隔离与三方合并
|
|
4467
|
+
// (sessionId='' ⇒ isolationSessionId=null)。R-25 冻结了这条口径:回退救援点必须把工作区
|
|
4468
|
+
// **逐字**写回那一刻;让它走合并会变成「什么都不做」(内容与当前磁盘一致)。
|
|
4469
|
+
? { removeCreated: input.removeCreated === true, sessionId: record.kind === 'guard' ? '' : (record.sessionId ?? '') }
|
|
4470
|
+
: { removeCreated: input.removeCreated === true, onlyPaths: input.onlyPaths, sessionId: record.kind === 'guard' ? '' : (record.sessionId ?? '') })
|
|
4255
4471
|
: await this.restoreSession(record.session)
|
|
4256
4472
|
segments.push(segment)
|
|
4257
4473
|
if (segment.childSessionId !== undefined) childSessionId = segment.childSessionId
|
|
@@ -4272,9 +4488,20 @@ export class RollbackPointEngine {
|
|
|
4272
4488
|
// 「0 变更」必须真的是 0 变更:**删了文件的段不算无事可做**(restored=0 只表示没写回记录在案的
|
|
4273
4489
|
// 路径;truncated 的点回退时常常「一条都不用写、但删掉了几个新建文件」—— 那种情形说
|
|
4274
4490
|
// 「没有可恢复的内容」就是假尾句。真机端到端实测:removed=2 而 code 曾为 nothing-to-restore)
|
|
4491
|
+
/**
|
|
4492
|
+
* **被安全判据主动跳过**的文件总数(冲突 / 无法安全撤销 / 落点无人认领)。
|
|
4493
|
+
*
|
|
4494
|
+
* 与「无事可做」是两件事:用户要求回退,这些文件**是故意没有回退的**。
|
|
4495
|
+
* 把它们算进「成功、0 变更 / 没有可恢复的内容」就是假尾句 —— 竞品口径可以直接引用:
|
|
4496
|
+
* Claude Code 的原文是 Restored the code, but skipped N files(官方文档 errors 一节)。
|
|
4497
|
+
* 判据只读段上已经如实回传的三个计数,不新增任何猜测。
|
|
4498
|
+
*/
|
|
4499
|
+
const deliberatelySkipped = segments.reduce((sum, s) => sum
|
|
4500
|
+
+ (s.conflictCount ?? 0) + (s.externalCount ?? 0), 0)
|
|
4275
4501
|
const allNoop = segments.length > 0
|
|
4276
4502
|
&& segments.every((s) => s.status === 'skipped'
|
|
4277
4503
|
|| (s.status === 'restored' && s.restored === 0 && (s.removed ?? 0) === 0))
|
|
4504
|
+
&& deliberatelySkipped === 0
|
|
4278
4505
|
let outcome: RollbackOutcome
|
|
4279
4506
|
let code: RollbackCode
|
|
4280
4507
|
if (workspaceFailed) {
|
|
@@ -4283,7 +4510,7 @@ export class RollbackPointEngine {
|
|
|
4283
4510
|
} else if (allNoop) {
|
|
4284
4511
|
outcome = 'restored'
|
|
4285
4512
|
code = 'nothing-to-restore'
|
|
4286
|
-
} else if (anyFailed || anySkipped) {
|
|
4513
|
+
} else if (anyFailed || anySkipped || deliberatelySkipped > 0) {
|
|
4287
4514
|
outcome = 'partial'
|
|
4288
4515
|
code = anyFailed ? 'restore-failed' : 'ok'
|
|
4289
4516
|
} else {
|
|
@@ -4339,13 +4566,13 @@ export class RollbackPointEngine {
|
|
|
4339
4566
|
*/
|
|
4340
4567
|
private async mergeForeignRecord(
|
|
4341
4568
|
file: RollbackWorkspaceFile,
|
|
4342
|
-
|
|
4343
|
-
|
|
4569
|
+
base: { bytes: Uint8Array; fromGuard: boolean } | null,
|
|
4570
|
+
evidence: ForeignEvidence,
|
|
4344
4571
|
): Promise<
|
|
4345
4572
|
| { kind: 'write'; bytes: Uint8Array; sha256: string; expectedCurrent: string }
|
|
4346
4573
|
| { kind: 'unchanged' }
|
|
4347
|
-
| { kind: 'blocked'; reason:
|
|
4348
|
-
/**
|
|
4574
|
+
| { kind: 'blocked'; reason: MergeBlockReason }
|
|
4575
|
+
/** 没有可用祖先、也没有任何旁证 ⇒ 退回旧口径(整文件覆盖回目标版本) */
|
|
4349
4576
|
| { kind: 'fallback' }
|
|
4350
4577
|
> {
|
|
4351
4578
|
let oursBytes: Uint8Array
|
|
@@ -4359,25 +4586,68 @@ export class RollbackPointEngine {
|
|
|
4359
4586
|
try {
|
|
4360
4587
|
theirsBytes = await fs.readFile(file.path)
|
|
4361
4588
|
} catch (error) {
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
//
|
|
4371
|
-
|
|
4372
|
-
if (
|
|
4589
|
+
// 读不出来(EACCES / EMFILE…)⇒ **存在性未知**,绝不按「不存在」处理
|
|
4590
|
+
if ((error as { code?: unknown }).code !== 'ENOENT') return { kind: 'blocked', reason: 'theirs-unreadable' }
|
|
4591
|
+
// 记录在案的路径**当前不存在**:旧口径是「按记录重建」—— 但那会连带把「别人删掉它」
|
|
4592
|
+
// 这件事一起撤销。判据只有两条,且都能逐字解释:
|
|
4593
|
+
// ① 存在共同祖先(别的会话的回合点里还有它)⇒ 它是在那个点**之后**才消失的 ⇒ 别人删的,
|
|
4594
|
+
// 绝不复活(真机:B 删掉文件后回退 A);
|
|
4595
|
+
// ② 归属 / 时间窗表明那一刻是别人在写 ⇒ 同样不复活。
|
|
4596
|
+
// 注意**不能**用 absentAtForeignPoint 当判据:本会话自己把它改名 / 删除时,它在别人的回合点里
|
|
4597
|
+
// 本来就不存在(T6-3 真机形状)—— 拿它当「别人删的」会让正常的改名回退失效。
|
|
4598
|
+
if (base !== null) return { kind: 'blocked', reason: 'theirs-missing' }
|
|
4599
|
+
if (evidence.provenance === 'unclaimed') return { kind: 'blocked', reason: 'unclaimed' }
|
|
4600
|
+
if (evidence.provenance === 'foreign') return { kind: 'blocked', reason: 'theirs-missing' }
|
|
4373
4601
|
return { kind: 'fallback' }
|
|
4374
4602
|
}
|
|
4375
|
-
//
|
|
4376
|
-
|
|
4377
|
-
|
|
4603
|
+
// 救援点当 base 只在「它之后确实又变过」时才成立:救援点是在**执行回退那一刻**拍的,若它与
|
|
4604
|
+
// 当前磁盘逐字相同,说明它提供不了任何「回退之后发生了什么」的知识 —— 拿它当共同祖先会把
|
|
4605
|
+
// 对方已经改进去的内容当成基线,合并结论变成 ours ⇒ 整文件覆盖、抹掉对方的改动。
|
|
4606
|
+
const usableBase = base !== null && !(base.fromGuard && sameBytes(base.bytes, theirsBytes)) ? base.bytes : null
|
|
4607
|
+
// 二进制(任一侧 8 KiB 内出现 NUL)绝不按文本合并:三方合并算法对它没有定义
|
|
4608
|
+
if (hasNulByte(oursBytes) || hasNulByte(theirsBytes) || (usableBase !== null && hasNulByte(usableBase))) {
|
|
4609
|
+
return { kind: 'blocked', reason: 'binary' }
|
|
4610
|
+
}
|
|
4611
|
+
const oursDecoded = decodeTextBytes(oursBytes)
|
|
4612
|
+
const theirsDecoded = decodeTextBytes(theirsBytes)
|
|
4613
|
+
const baseDecoded = usableBase === null ? null : decodeTextBytes(usableBase)
|
|
4614
|
+
/**
|
|
4615
|
+
* **base 必须真的携带「本会话改动之后」的信息**:若它与 ours(回退目标)逐字相同,说明它
|
|
4616
|
+
* 提供不了任何「本会话改了什么」的知识 —— 拿它当共同祖先会让合并结论变成「保持当前」,
|
|
4617
|
+
* 于是「回退到该点」成了「什么都不做」。
|
|
4618
|
+
*
|
|
4619
|
+
* 但**没有可用祖先**不等于「可以整文件覆盖」:撤销本会话的唯一依据是「这条路径确实是本会话
|
|
4620
|
+
* 写的」,而这条依据只能来自旁证(归属台账 / 活跃时间窗)或「别人的回合点里有没有它」。
|
|
4621
|
+
* 拿不到依据就**一个字节都不写** —— 宁可少退一次,也绝不覆盖别人的(或用户的)内容。
|
|
4622
|
+
* 只有在**完全没有证据**(老数据 / 未接时间窗)时才退回旧口径,与加这套判据之前逐字一致:
|
|
4623
|
+
* 判据只允许「少做」,不允许凭空多拦。
|
|
4624
|
+
*/
|
|
4625
|
+
if (usableBase === null || (baseDecoded !== null && baseDecoded.ok && oursDecoded.ok && baseDecoded.text === oursDecoded.text)) {
|
|
4626
|
+
if (evidence.provenance === 'foreign' || evidence.absentAtForeignPoint) return { kind: 'blocked', reason: 'no-base' }
|
|
4627
|
+
if (evidence.provenance === 'unclaimed') return { kind: 'blocked', reason: 'unclaimed' }
|
|
4628
|
+
if (usableBase === null) return { kind: 'fallback' }
|
|
4629
|
+
return evidence.provenance === 'target' ? { kind: 'fallback' } : { kind: 'blocked', reason: 'unclaimed' }
|
|
4630
|
+
}
|
|
4631
|
+
/**
|
|
4632
|
+
* 合并的文本视图:三方都能**无损 UTF-8 往返** ⇒ 按 UTF-8 文本合并,落盘按 result.shape
|
|
4633
|
+
*(行尾 / BOM 由 text-fidelity 描述,encodeTextBytes 负责还原);
|
|
4634
|
+
* 否则退到 **latin1 视图** —— latin1 是字节的双射,「字符串相等」= 「字节相等」,
|
|
4635
|
+
* 行级 diff / 合并因此等价于**按字节**做:非法 UTF-8(GBK / Latin-1 / 半截序列)逐字保留,
|
|
4636
|
+
* 绝不会被替换成 U+FFFD(2026-10-18 修:真机把 0xE9 写成 EF BF BD)。
|
|
4637
|
+
*/
|
|
4638
|
+
const baseBytes = usableBase as Uint8Array
|
|
4639
|
+
const useLatin1 = !oursDecoded.ok || !theirsDecoded.ok || baseDecoded === null || !baseDecoded.ok
|
|
4640
|
+
const oursText = useLatin1 ? latin1Of(oursBytes) : (oursDecoded as { ok: true; text: string }).text
|
|
4641
|
+
const theirsText = useLatin1 ? latin1Of(theirsBytes) : (theirsDecoded as { ok: true; text: string }).text
|
|
4642
|
+
const baseText = useLatin1 ? latin1Of(baseBytes) : (baseDecoded as { ok: true; text: string }).text
|
|
4378
4643
|
const result = mergeThreeWay(oursText, baseText, theirsText)
|
|
4379
4644
|
if (!result.ok) {
|
|
4380
|
-
return {
|
|
4645
|
+
return {
|
|
4646
|
+
kind: 'blocked',
|
|
4647
|
+
reason: result.reason === 'conflict' ? 'conflict'
|
|
4648
|
+
: result.reason === 'degraded' ? 'degraded'
|
|
4649
|
+
: result.reason === 'non-utf8' ? 'non-utf8' : 'binary',
|
|
4650
|
+
}
|
|
4381
4651
|
}
|
|
4382
4652
|
// 调用方只在「当前内容 ≠ 记录内容」时才会走到这里 ⇒ `ours === theirs` 只可能是内容恰好相同
|
|
4383
4653
|
if (oursText === theirsText) return { kind: 'unchanged' }
|
|
@@ -4390,7 +4660,11 @@ export class RollbackPointEngine {
|
|
|
4390
4660
|
// 一个字节都不写,让用户看到并自己决定。
|
|
4391
4661
|
return { kind: 'blocked', reason: 'ambiguous' }
|
|
4392
4662
|
}
|
|
4393
|
-
|
|
4663
|
+
// 落盘字节:UTF-8 路径按 shape 还原 BOM(result.text 里不含 BOM);latin1 路径逐字节回写。
|
|
4664
|
+
// 两条路径的行尾都已由 mergeThreeWay 按 shape.eol 收尾 ⇒ CRLF 文件不再被折成 LF。
|
|
4665
|
+
const bytes = useLatin1
|
|
4666
|
+
? Buffer.from(result.text, 'latin1')
|
|
4667
|
+
: Buffer.from(encodeTextBytes(result.text, result.shape))
|
|
4394
4668
|
return { kind: 'write', bytes, sha256: sha256Hex(bytes), expectedCurrent: sha256Hex(theirsBytes) }
|
|
4395
4669
|
}
|
|
4396
4670
|
|
|
@@ -4406,15 +4680,90 @@ export class RollbackPointEngine {
|
|
|
4406
4680
|
* **本会话的点**(见上)、以及内容读不出来的点(继续往后找 —— 找不到 ⇒ 调用方按「无法安全撤销」
|
|
4407
4681
|
* 处理,绝不用近似值代替)。
|
|
4408
4682
|
*/
|
|
4409
|
-
|
|
4683
|
+
/**
|
|
4684
|
+
* 这条路径**是谁写的** —— 只读旁证(归属台账 + 活跃时间窗),**绝不从内容猜**。
|
|
4685
|
+
*
|
|
4686
|
+
* 优先归属台账(tool/call 参数,精确到调用);没有归属时看文件 mtime 落在谁的活跃窗口内 ——
|
|
4687
|
+
* 这一条与工具无关,因此 bash / 脚本 / 外部编辑器写的路径同样能被判出来。
|
|
4688
|
+
* `hasDataFor` 是「有没有覆盖」的自证:**没有覆盖就返回 unknown**(不拦),有覆盖却没有任何会话
|
|
4689
|
+
* 活跃在那一刻才返回 unclaimed(手改 / 外部工具 / 另一个 DSH 进程)。
|
|
4690
|
+
*/
|
|
4691
|
+
private async provenanceOf(target: string, capturedAt: number, sessionId: string | null): Promise<WriteProvenance> {
|
|
4692
|
+
if (sessionId === null) return 'unknown'
|
|
4693
|
+
const writer = this.attribution?.writerOf(target)
|
|
4694
|
+
if (writer !== undefined && writer.at > capturedAt) {
|
|
4695
|
+
return writer.sessionId === sessionId ? 'target' : 'foreign'
|
|
4696
|
+
}
|
|
4697
|
+
const activity = this.activity
|
|
4698
|
+
if (activity === undefined) return 'unknown'
|
|
4699
|
+
let mtimeMs: number
|
|
4700
|
+
try {
|
|
4701
|
+
mtimeMs = (await fs.stat(target)).mtimeMs
|
|
4702
|
+
} catch {
|
|
4703
|
+
return 'unknown'
|
|
4704
|
+
}
|
|
4705
|
+
if (!(mtimeMs > capturedAt)) return 'unknown'
|
|
4706
|
+
const active = activity.sessionsActiveAt(mtimeMs)
|
|
4707
|
+
if (active.length > 0) {
|
|
4708
|
+
// 只有「那一刻唯一活跃的就是本会话」才算自有证据;多会话同时活跃 ⇒ 保守当成别人的
|
|
4709
|
+
if (active.includes(sessionId) && active.every((id) => id === sessionId)) return 'target'
|
|
4710
|
+
return 'foreign'
|
|
4711
|
+
}
|
|
4712
|
+
return activity.hasDataFor(sessionId) ? 'unclaimed' : 'unknown'
|
|
4713
|
+
}
|
|
4714
|
+
|
|
4715
|
+
/**
|
|
4716
|
+
* 目标点之后**别的会话的回合点**里,有没有哪个**不含**这条路径(= 那一刻它不存在)。
|
|
4717
|
+
*
|
|
4718
|
+
* 它证明的是:「当前磁盘上这份内容是该点之后才出现的」⇒ 拿目标点的内容整文件覆盖它,等于
|
|
4719
|
+
* 抹掉对方新建/重建的工作(2026-10-18:A 删文件、B 重建,回退 A 把 B 的内容覆盖回旧版本)。
|
|
4720
|
+
* 截断的点(`payload.truncated`)不能用来证明「不存在」,一律跳过;只扫最早的若干个点(有界)。
|
|
4721
|
+
*/
|
|
4722
|
+
private async absentAtForeignPointOf(target: string, afterAt: number, sessionId: string | null): Promise<boolean> {
|
|
4410
4723
|
const ledger = await this.readManifest()
|
|
4411
4724
|
const key = pathKey(target)
|
|
4412
4725
|
const candidates = ledger.entries
|
|
4413
4726
|
.filter((entry) => entry.createdAt > afterAt
|
|
4414
4727
|
&& entry.kind !== 'guard'
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4728
|
+
&& (sessionId === null || entry.sessionId !== sessionId))
|
|
4729
|
+
.sort((left, right) => (left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0))
|
|
4730
|
+
.slice(0, 8)
|
|
4731
|
+
for (const entry of candidates) {
|
|
4732
|
+
const resolved = await this.readFullRecord(entry.id)
|
|
4733
|
+
const payload = resolved.record?.workspace.payload
|
|
4734
|
+
if (payload === null || payload === undefined) continue
|
|
4735
|
+
if (payload.truncated === true) continue
|
|
4736
|
+
if (!payload.files.some((item) => pathKey(item.path) === key)) return true
|
|
4737
|
+
}
|
|
4738
|
+
return false
|
|
4739
|
+
}
|
|
4740
|
+
|
|
4741
|
+
/** 两档 base:**别的会话的回合点**优先,其次**救援点**(见 baseVersionFor 的判据注释) */
|
|
4742
|
+
private async baseFor(target: string, afterAt: number, excludeSessionId: string | null): Promise<{ bytes: Uint8Array; fromGuard: boolean } | null> {
|
|
4743
|
+
const turn = await this.baseVersionFor(target, afterAt, excludeSessionId, false)
|
|
4744
|
+
if (turn !== null) return turn
|
|
4745
|
+
return await this.baseVersionFor(target, afterAt, excludeSessionId, true)
|
|
4746
|
+
}
|
|
4747
|
+
|
|
4748
|
+
private async baseVersionFor(
|
|
4749
|
+
target: string,
|
|
4750
|
+
afterAt: number,
|
|
4751
|
+
excludeSessionId: string | null,
|
|
4752
|
+
fromGuard: boolean,
|
|
4753
|
+
): Promise<{ bytes: Uint8Array; fromGuard: boolean } | null> {
|
|
4754
|
+
const ledger = await this.readManifest()
|
|
4755
|
+
const key = pathKey(target)
|
|
4756
|
+
const candidates = ledger.entries
|
|
4757
|
+
.filter((entry) => entry.createdAt > afterAt
|
|
4758
|
+
// 第一档(fromGuard=false)= **别的会话的回合点**:共同祖先的正统来源。本会话后续回合的点
|
|
4759
|
+
// 不能当 base —— 它会把「本会话后续改动」也一并保留,与「回退到该点之前」的直觉冲突。
|
|
4760
|
+
// 第二档(fromGuard=true)= **救援点**:连续回退(先退 A 再退 B)时,B 的目标点之后往往只有
|
|
4761
|
+
// 第一次回退留下的救援点,而它的内容正是「A 已改、B 未改」那一刻的状态 —— 只有它能算出
|
|
4762
|
+
// 「把 A 的改动也一并撤销、同时保住 B 之后的内容」。旧口径在这时直接整文件覆盖 ⇒ A 的改动
|
|
4763
|
+
// 复活、B 的改动被抹掉(2026-10-18 真机可复现)。救援点与当前磁盘相同的情形由调用方挡掉。
|
|
4764
|
+
&& (fromGuard
|
|
4765
|
+
? entry.kind === 'guard'
|
|
4766
|
+
: entry.kind !== 'guard' && (excludeSessionId === null || entry.sessionId !== excludeSessionId)))
|
|
4418
4767
|
.sort((left, right) => (left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0))
|
|
4419
4768
|
for (const entry of candidates) {
|
|
4420
4769
|
const resolved = await this.readFullRecord(entry.id)
|
|
@@ -4425,7 +4774,7 @@ export class RollbackPointEngine {
|
|
|
4425
4774
|
try {
|
|
4426
4775
|
const bytes = await this.readObject(hit.object)
|
|
4427
4776
|
if (sha256Hex(bytes) !== hit.sha256) continue
|
|
4428
|
-
return
|
|
4777
|
+
return { bytes, fromGuard }
|
|
4429
4778
|
} catch {
|
|
4430
4779
|
continue
|
|
4431
4780
|
}
|
|
@@ -4515,7 +4864,7 @@ export class RollbackPointEngine {
|
|
|
4515
4864
|
*/
|
|
4516
4865
|
const mergeGuardEnabled = selection === null && isolationSessionId !== null
|
|
4517
4866
|
/** 同一路径的 base 只查一次(合并判定在「确认需要写」之后,命中数很少) */
|
|
4518
|
-
const baseCache = new Map<string,
|
|
4867
|
+
const baseCache = new Map<string, { bytes: Uint8Array; fromGuard: boolean } | null>()
|
|
4519
4868
|
const sink: ChunkWalkSink = { skipped: [], excludedDirs: [], ignoredDirs: [], ignoredDirCount: 0, unreadableDirs: [], skippedCount: 0, skippedTruncated: false }
|
|
4520
4869
|
// 删除集与**预览**共用同一份规划(planCreatedRemovals)⇒「预览说会删的」= 「实际删的」。
|
|
4521
4870
|
// 为什么提到文件循环**之前**:选择模式要拿它当「合法路径」的第二来源(新建候选也允许被点名),
|
|
@@ -4553,11 +4902,14 @@ export class RollbackPointEngine {
|
|
|
4553
4902
|
let conflictCount = 0
|
|
4554
4903
|
/** 无法安全撤销:没有中间版本 / 二进制 / 差异被降级 / 对方把它改成了不存在 */
|
|
4555
4904
|
let unavailableCount = 0
|
|
4905
|
+
/** 落点无人认领(时间窗表明那一刻没有会话活跃)⇒ 拒绝覆盖,交由用户决定 */
|
|
4906
|
+
let externalCount = 0
|
|
4556
4907
|
/** 写入前发现文件又被改过(并发)⇒ 放弃写入 */
|
|
4557
4908
|
let concurrentCount = 0
|
|
4558
4909
|
const mergedPaths: string[] = []
|
|
4559
4910
|
const conflictPaths: string[] = []
|
|
4560
4911
|
const unavailablePaths: string[] = []
|
|
4912
|
+
const externalPaths: string[] = []
|
|
4561
4913
|
const concurrentPaths: string[] = []
|
|
4562
4914
|
for (const file of payload.files) {
|
|
4563
4915
|
// 路径级选择:没被点名的记录条目**不写**(用户自己的选择,不是安全判据)
|
|
@@ -4593,44 +4945,42 @@ export class RollbackPointEngine {
|
|
|
4593
4945
|
// **合并保护**(2026-10-17 修):到这里才确认「这个文件确实需要写」,也才值得去查共同祖先。
|
|
4594
4946
|
// 触发不吃归属:只要目标点之后存在**别的会话**的回合点(那它就是共同祖先),就走三方合并。
|
|
4595
4947
|
if (mergeGuardEnabled) {
|
|
4596
|
-
let base
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
base = await this.baseVersionFor(file.path, track.capturedAt, isolationSessionId)
|
|
4948
|
+
let base = baseCache.get(pathKey(file.path))
|
|
4949
|
+
if (base === undefined) {
|
|
4950
|
+
base = await this.baseFor(file.path, track.capturedAt, isolationSessionId)
|
|
4600
4951
|
baseCache.set(pathKey(file.path), base)
|
|
4601
|
-
} else {
|
|
4602
|
-
base = cached
|
|
4603
4952
|
}
|
|
4604
|
-
const
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
}
|
|
4953
|
+
const evidence: ForeignEvidence = {
|
|
4954
|
+
provenance: await this.provenanceOf(file.path, track.capturedAt, isolationSessionId),
|
|
4955
|
+
// 「别人的回合点里没有它」只在**拿不到祖先**时才问(有祖先就不必再读一遍台账)
|
|
4956
|
+
absentAtForeignPoint: base === null
|
|
4957
|
+
&& await this.absentAtForeignPointOf(file.path, track.capturedAt, isolationSessionId),
|
|
4958
|
+
}
|
|
4959
|
+
const outcome = await this.mergeForeignRecord(file, base, evidence)
|
|
4960
|
+
if (outcome.kind === 'fallback') {
|
|
4961
|
+
// 没有可用祖先、也没有任何旁证 ⇒ 落到下面的旧口径(整文件覆盖回目标版本)
|
|
4962
|
+
} else if (outcome.kind === 'write') {
|
|
4963
|
+
writable.push({ file, bytes: outcome.bytes, sha256: outcome.sha256, expectedCurrent: outcome.expectedCurrent })
|
|
4964
|
+
mergedCount += 1
|
|
4965
|
+
if (mergedPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) mergedPaths.push(file.path)
|
|
4966
|
+
continue
|
|
4967
|
+
} else if (outcome.kind === 'unchanged') {
|
|
4968
|
+
unchanged += 1
|
|
4969
|
+
continue
|
|
4970
|
+
} else {
|
|
4623
4971
|
foreignSkipped += 1
|
|
4624
4972
|
if (outcome.reason === 'conflict') {
|
|
4625
4973
|
conflictCount += 1
|
|
4626
4974
|
if (conflictPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) conflictPaths.push(file.path)
|
|
4975
|
+
} else if (outcome.reason === 'unclaimed') {
|
|
4976
|
+
externalCount += 1
|
|
4977
|
+
if (externalPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) externalPaths.push(file.path)
|
|
4627
4978
|
} else {
|
|
4628
4979
|
unavailableCount += 1
|
|
4629
4980
|
if (unavailablePaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT) unavailablePaths.push(file.path)
|
|
4630
4981
|
}
|
|
4631
4982
|
pushDetail(detail, 'foreign-' + outcome.reason + ' ' + file.path + ' zeroWrites=true')
|
|
4632
4983
|
continue
|
|
4633
|
-
}
|
|
4634
4984
|
}
|
|
4635
4985
|
}
|
|
4636
4986
|
writable.push({ file, bytes })
|
|
@@ -4697,10 +5047,17 @@ export class RollbackPointEngine {
|
|
|
4697
5047
|
}
|
|
4698
5048
|
}
|
|
4699
5049
|
if (failed > 0) {
|
|
4700
|
-
//
|
|
5050
|
+
// 零写入:一条不合格就不写任何一条。
|
|
5051
|
+
// **reasonCode 必须分得清成因**(2026-10-18 X-12a 真机):这里此前一律写 'invalid-path',
|
|
5052
|
+
// 把「对象仓丢对象 / 哈希不符」也报成「路径非法」—— 界面与排障都会指向错误的方向。
|
|
5053
|
+
// 判据只读本段已累积的 detail(与回传的逐条说明同源,不另算一套口径)。
|
|
5054
|
+
const hasDetail = (prefix: string): boolean => detail.some((line) => line.startsWith(prefix))
|
|
5055
|
+
const reasonCode: RollbackCode = hasDetail('object-missing') ? 'object-missing'
|
|
5056
|
+
: hasDetail('object-hash-mismatch') ? 'object-hash-mismatch'
|
|
5057
|
+
: 'invalid-path'
|
|
4701
5058
|
detail.push('restored=0 failed=' + String(failed) + ' leftovers=' + String(leftovers.count) + ' zeroWrites=true')
|
|
4702
5059
|
return {
|
|
4703
|
-
state: 'workspace', status: 'failed', reasonCode
|
|
5060
|
+
state: 'workspace', status: 'failed', reasonCode, detail,
|
|
4704
5061
|
restored: 0, failed,
|
|
4705
5062
|
leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
|
|
4706
5063
|
}
|
|
@@ -4755,6 +5112,13 @@ export class RollbackPointEngine {
|
|
|
4755
5112
|
if (mergedCount > 0) { noop.mergedCount = mergedCount; noop.mergedPaths = mergedPaths }
|
|
4756
5113
|
if (conflictCount > 0) { noop.conflictCount = conflictCount; noop.conflictPaths = conflictPaths }
|
|
4757
5114
|
if (unavailableCount > 0) { noop.unavailableCount = unavailableCount; noop.unavailablePaths = unavailablePaths }
|
|
5115
|
+
if (externalCount > 0) { noop.externalCount = externalCount; noop.externalPaths = externalPaths }
|
|
5116
|
+
// **noop 也要保住原因**:冲突 / 无法安全撤销 / 无人认领的逐条说明都累积在 detail 里。
|
|
5117
|
+
// 只留一行 nothingToDo=true 会让用户看到「没有可退的」却看不到「有 3 个文件因冲突没退」
|
|
5118
|
+
//(2026-10-18 X-3 真机形状:conflictCount=1 但 detail 里一个字都没有)。
|
|
5119
|
+
for (const line of detail) {
|
|
5120
|
+
if (line.startsWith('foreign-') || line.startsWith('concurrent-change')) noop.detail.push(line)
|
|
5121
|
+
}
|
|
4758
5122
|
if (concurrentCount > 0) { noop.concurrentCount = concurrentCount; noop.concurrentPaths = concurrentPaths }
|
|
4759
5123
|
return noop
|
|
4760
5124
|
}
|
|
@@ -4817,7 +5181,7 @@ export class RollbackPointEngine {
|
|
|
4817
5181
|
}
|
|
4818
5182
|
}
|
|
4819
5183
|
try {
|
|
4820
|
-
await stager.stage(item.file.path, bytes)
|
|
5184
|
+
await stager.stage(item.file.path, bytes, { sha256: item.sha256 ?? item.file.sha256 })
|
|
4821
5185
|
} catch (error) {
|
|
4822
5186
|
// 暂存写不下去(ENOSPC / EACCES):**零写入失败** —— 绝不带着不完整的前像开工,
|
|
4823
5187
|
// 也绝不让异常穿出引擎(那会被路由兜成没有机器码的裸 500,且暂存目录无人清理)。
|
|
@@ -4890,7 +5254,7 @@ export class RollbackPointEngine {
|
|
|
4890
5254
|
removal.skipped.push(target)
|
|
4891
5255
|
continue
|
|
4892
5256
|
}
|
|
4893
|
-
await stager.stage(target, bytes)
|
|
5257
|
+
await stager.stage(target, bytes, { deleted: true })
|
|
4894
5258
|
destroyedCount += 1
|
|
4895
5259
|
await fs.rm(target, { force: true })
|
|
4896
5260
|
removed += 1
|
|
@@ -4937,9 +5301,12 @@ export class RollbackPointEngine {
|
|
|
4937
5301
|
if (mergedCount > 0) { segment.mergedCount = mergedCount; segment.mergedPaths = mergedPaths }
|
|
4938
5302
|
if (conflictCount > 0) { segment.conflictCount = conflictCount; segment.conflictPaths = conflictPaths }
|
|
4939
5303
|
if (unavailableCount > 0) { segment.unavailableCount = unavailableCount; segment.unavailablePaths = unavailablePaths }
|
|
5304
|
+
if (externalCount > 0) { segment.externalCount = externalCount; segment.externalPaths = externalPaths }
|
|
4940
5305
|
if (concurrentCount > 0) { segment.concurrentCount = concurrentCount; segment.concurrentPaths = concurrentPaths }
|
|
4941
5306
|
// 捕获不完整 ⇒ 一条都没删:如实回报原因(界面据此说「因为捕获不完整,一条都没动」)
|
|
4942
5307
|
if (removal.refused !== null) segment.createdRefusedReason = removal.refused
|
|
5308
|
+
// 全部写入 / 删除都成功 ⇒ 先落 done 标记(这条前像不再是「中断的回退」),再清前像字节。
|
|
5309
|
+
await stager.finish()
|
|
4943
5310
|
// 前像字节用完即清(成功路径):留在磁盘上只会变成无人认领的残留
|
|
4944
5311
|
await stager.dispose()
|
|
4945
5312
|
return segment
|
|
@@ -5178,6 +5545,11 @@ export interface RollbackTrackerOptions {
|
|
|
5178
5545
|
* 回退行为与旧版**逐字一致**。
|
|
5179
5546
|
*/
|
|
5180
5547
|
attribution?: RollbackWriteRecorder
|
|
5548
|
+
/**
|
|
5549
|
+
* 会话活跃时间窗采集器(2026-10-18)。缺省 = 不采集 ⇒ 归属判定只剩 tool/call 参数那一条。
|
|
5550
|
+
* **每个事件都记一次**(与工具参数无关),因此 bash / 脚本 / 外部编辑器写的路径同样有归属证据。
|
|
5551
|
+
*/
|
|
5552
|
+
activity?: RollbackActivityRecorder
|
|
5181
5553
|
}
|
|
5182
5554
|
|
|
5183
5555
|
export interface RollbackTrackerResult {
|
|
@@ -5358,6 +5730,7 @@ export class RollbackAutoTracker {
|
|
|
5358
5730
|
private readonly log: ((line: string) => void) | undefined
|
|
5359
5731
|
private readonly statSessionLog: ((logPath: string) => Promise<{ size: number; mtimeMs: number } | undefined>) | undefined
|
|
5360
5732
|
private readonly attribution: RollbackWriteRecorder | undefined
|
|
5733
|
+
private readonly activity: RollbackActivityRecorder | undefined
|
|
5361
5734
|
private readonly bindings = new Map<string, RollbackSessionBinding>()
|
|
5362
5735
|
private readonly open = new Map<string, { turn: number | null; startSeq: number; boundarySeq: number | null }>()
|
|
5363
5736
|
/**
|
|
@@ -5383,6 +5756,7 @@ export class RollbackAutoTracker {
|
|
|
5383
5756
|
this.log = options.log
|
|
5384
5757
|
this.statSessionLog = options.statSessionLog
|
|
5385
5758
|
this.attribution = options.attribution
|
|
5759
|
+
this.activity = options.activity
|
|
5386
5760
|
}
|
|
5387
5761
|
|
|
5388
5762
|
/** 实际采用的留点机制(output / 诊断用) */
|
|
@@ -5446,6 +5820,9 @@ export class RollbackAutoTracker {
|
|
|
5446
5820
|
async noteEvent(event: RollbackTrackedEvent): Promise<RollbackTrackerResult> {
|
|
5447
5821
|
try {
|
|
5448
5822
|
this.mechanism = 'event'
|
|
5823
|
+
// 时间窗归属(2026-10-18):**每个**事件都记一次活跃 —— 与工具参数无关,因此 bash / 脚本 /
|
|
5824
|
+
// 外部编辑器写的文件也能按「mtime 落在谁的活跃窗口内」判定归属。零 I/O(内存 + debounce 落盘)。
|
|
5825
|
+
this.activity?.noteActivity(event.sessionId, this.nowFn())
|
|
5449
5826
|
const binding = this.bindings.get(event.sessionId)
|
|
5450
5827
|
if (binding === undefined) return { captured: false, code: 'invalid-input', detail: 'unknown-session ' + event.sessionId }
|
|
5451
5828
|
// 未开回合时出现的「待消费输入」属于**下一个**回合:边界必须落在它之前,
|