dsh-config-manager 0.1.78 → 0.1.80
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 +401 -112
- package/lib/client.js +2899 -1525
- 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/ancestor.d.ts +22 -0
- package/lib/sync/ancestor.js +66 -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 +18 -1
- package/lib/sync/gist/gist-transport.js +42 -3
- package/lib/sync/gist/messages.d.ts +2 -0
- package/lib/sync/gist/messages.js +5 -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/s3/sigv4.js +11 -2
- package/lib/sync/sync-engine.d.ts +212 -8
- package/lib/sync/sync-engine.js +514 -58
- 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 +9 -0
- package/lib/ui/i18n.js +22 -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/api-base.test.ts +36 -0
- package/src/client/common/http.test.ts +12 -1
- package/src/client/common/http.ts +8 -2
- package/src/client/common/routes.ts +46 -3
- 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/market/market-api.test.ts +3 -2
- 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 +220 -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 +248 -0
- package/src/client/sync/merge-view.ts +398 -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.test.ts +5 -3
- 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/ancestor.ts +76 -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 +203 -0
- package/src/sync/gist/gist-transport.ts +53 -5
- package/src/sync/gist/messages.ts +5 -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.test.ts +2 -1
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/s3/sigv4.test.ts +4 -1
- package/src/sync/s3/sigv4.ts +11 -2
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +410 -19
- package/src/sync/sync-engine.ts +602 -57
- 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 +22 -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
|
@@ -73,9 +73,38 @@ import { diffLines } from '../utils/line-diff.js';
|
|
|
73
73
|
import { projectKeyOf } from './session-select.js';
|
|
74
74
|
import { ignoreRelativePath, ignoreRuleMatches, loadIgnoreRules } from './rollback-ignore-rules.js';
|
|
75
75
|
import { mergeThreeWay } from './rollback-merge.js';
|
|
76
|
+
import { decodeTextBytes, encodeTextBytes } from './text-fidelity.js';
|
|
77
|
+
import { UndoJournalWriter, findInterruptedUndos, readUndoJournal, replayUndoJournal } from './rollback-undo-journal.js';
|
|
76
78
|
import { isSessionLogName } from '../utils/session-log.js';
|
|
77
79
|
import { decodeZstdFrame, scanZstdFrames, zstdAvailable, ZSTD_MAGIC } from '../utils/zstd-frame.js';
|
|
78
80
|
import { cleanupOrphanTempFiles, entrySummaryOf, ROLLBACK_DEFAULT_LEDGER_BYTES, ROLLBACK_UNDO_DIR_NAME, RollbackLedger, } from './rollback-ledger.js';
|
|
81
|
+
/** 逐字节相等(救援点当 base 的自证用) */
|
|
82
|
+
function sameBytes(left, right) {
|
|
83
|
+
if (left.length !== right.length)
|
|
84
|
+
return false;
|
|
85
|
+
for (let i = 0; i < left.length; i += 1)
|
|
86
|
+
if (left[i] !== right[i])
|
|
87
|
+
return false;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/** 前 8 KiB 是否含 NUL(与 text-fidelity / line-diff 同口径) */
|
|
91
|
+
function hasNulByte(bytes) {
|
|
92
|
+
const head = bytes.subarray(0, 8192);
|
|
93
|
+
for (let i = 0; i < head.length; i += 1)
|
|
94
|
+
if (head[i] === 0)
|
|
95
|
+
return true;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* latin1 视图:**字节的双射**(0..255 ↔ U+0000..U+00FF,逐字节一一对应)。
|
|
100
|
+
*
|
|
101
|
+
* 用途:三方合并的输入必须是「文本」,而非 UTF-8 文件(GBK / Latin-1 / 半截序列)用 UTF-8 解码
|
|
102
|
+
* 会把非法字节变成 U+FFFD ⇒ 写回即内容损毁(2026-10-18 真机形状:caf\u00E9 的 0xE9 被写成 EF BF BD)。
|
|
103
|
+
* latin1 视图下「字符串相等」= 「字节相等」,行级 diff / 合并因此**等价于按字节做**:非法字节逐字保留。
|
|
104
|
+
*/
|
|
105
|
+
function latin1Of(bytes) {
|
|
106
|
+
return Buffer.from(bytes).toString('latin1');
|
|
107
|
+
}
|
|
79
108
|
/* ------------------------------------------------------------------ 常量 */
|
|
80
109
|
/** 增量链的最大长度:到顶就落一个**全量锚点**(重建最多读 1 份全量 + 几份小增量) */
|
|
81
110
|
const ROLLBACK_DELTA_CHAIN_LIMIT = 8;
|
|
@@ -711,35 +740,51 @@ function withLedgerWriteLock(ledgerPath, task) {
|
|
|
711
740
|
* 目录名带 pid,崩溃残留由 rollback-ledger 的 `cleanupOrphanTempFiles` 在下次启动回收。
|
|
712
741
|
*/
|
|
713
742
|
class UndoStager {
|
|
714
|
-
entries = [];
|
|
715
743
|
dir;
|
|
716
|
-
|
|
717
|
-
constructor(dir) {
|
|
744
|
+
journal;
|
|
745
|
+
constructor(dir, journal) {
|
|
718
746
|
this.dir = dir;
|
|
747
|
+
this.journal = journal;
|
|
719
748
|
}
|
|
720
|
-
/**
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
this.
|
|
728
|
-
|
|
729
|
-
|
|
749
|
+
/**
|
|
750
|
+
* 记一条前像:`bytes === null` = 该路径当时**不存在**(回滚时删掉它)。
|
|
751
|
+
*
|
|
752
|
+
* `after` = 本条**回退动作会写成什么**(写入 = 内容 sha256;删除 = deleted)。它同时是崩溃恢复
|
|
753
|
+
* 的安全判据:只有「当前内容仍等于 after 镜像」时重放才被允许(绝不覆盖后来又被改过的文件)。
|
|
754
|
+
*/
|
|
755
|
+
async stage(target, bytes, after) {
|
|
756
|
+
await this.journal.record(target, bytes, after);
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* 全部写入 / 删除都成功之后才调用:落 `done` 标记 ⇒ 这条前像不再算「中断的回退」。
|
|
760
|
+
* 崩在它之前 = 启动期能认出「半回退现场」并按 after 镜像安全重放(见 interruptedRollbacks)。
|
|
761
|
+
*/
|
|
762
|
+
async finish() {
|
|
763
|
+
await this.journal.finish();
|
|
730
764
|
}
|
|
731
765
|
/**
|
|
732
766
|
* 逐条写回(**逆序**:同一路径被 stage 两次时,最后一次 stage 记的是最早状态,必须最后写)。
|
|
733
767
|
* 返回失败清单 —— 回滚失败绝不静默。
|
|
734
768
|
*/
|
|
735
769
|
async rollback(writer) {
|
|
770
|
+
// 判据来源改成 **journal 本身**(不再是进程内的内存列表):内存路径与崩溃后的磁盘路径共用
|
|
771
|
+
// 同一份条目,不会各存一套、也不会各有一套口径。
|
|
772
|
+
let entries;
|
|
773
|
+
try {
|
|
774
|
+
const snapshot = await readUndoJournal(this.dir);
|
|
775
|
+
entries = snapshot === null ? [] : snapshot.entries;
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
// 前像读不出来 ⇒ **必须报失败**,绝不静默当成「没有要回滚的」
|
|
779
|
+
return ['undo-journal-unreadable ' + errorDetail(error)];
|
|
780
|
+
}
|
|
736
781
|
const failed = [];
|
|
737
|
-
for (const entry of [...
|
|
782
|
+
for (const entry of [...entries].reverse()) {
|
|
738
783
|
try {
|
|
739
|
-
if (entry.
|
|
784
|
+
if (entry.bin === null)
|
|
740
785
|
await fs.rm(entry.path, { force: true });
|
|
741
786
|
else
|
|
742
|
-
await writer(entry.path, await fs.readFile(entry.
|
|
787
|
+
await writer(entry.path, await fs.readFile(path.join(this.dir, entry.bin)));
|
|
743
788
|
}
|
|
744
789
|
catch (error) {
|
|
745
790
|
failed.push(entry.path + ' ' + errorDetail(error));
|
|
@@ -856,6 +901,7 @@ export class RollbackPointEngine {
|
|
|
856
901
|
ledger;
|
|
857
902
|
/** 写入归属(2026-10-17 定案 B)。undefined = 没有归属信息 ⇒ 预览不标 foreign */
|
|
858
903
|
attribution;
|
|
904
|
+
activity;
|
|
859
905
|
ledgerBytesBudget;
|
|
860
906
|
/**
|
|
861
907
|
* 工作区捕获的挂钟时间预算(毫秒;见 ROLLBACK_DEFAULT_CAPTURE_BUDGET_MS)。**可变**:
|
|
@@ -912,6 +958,7 @@ export class RollbackPointEngine {
|
|
|
912
958
|
this.guardPolicy = options.guardPolicy ?? 'warn';
|
|
913
959
|
this.probe = options.storage;
|
|
914
960
|
this.attribution = options.attribution;
|
|
961
|
+
this.activity = options.activity;
|
|
915
962
|
this.forkSeam = options.fork;
|
|
916
963
|
this.writeSeam = options.writeFile;
|
|
917
964
|
this.readSeam = options.readFile;
|
|
@@ -997,7 +1044,17 @@ export class RollbackPointEngine {
|
|
|
997
1044
|
this.warn('rollback undo staging unavailable: ' + errorDetail(error));
|
|
998
1045
|
return null;
|
|
999
1046
|
}
|
|
1000
|
-
|
|
1047
|
+
// 前像 journal(2026-10-18):**先落 bin → 再 append 一行索引 + fsync**;全部写完才落 done。
|
|
1048
|
+
// 崩在写入中途时这个目录会被启动期认出是「中断的回退」并保持可重放,
|
|
1049
|
+
// 而不会被 cleanupOrphanTempFiles 当垃圾删掉(见 rollback-ledger 的同名判据)。
|
|
1050
|
+
try {
|
|
1051
|
+
return new UndoStager(dir, await UndoJournalWriter.create(dir, { now: () => this.stamp() }));
|
|
1052
|
+
}
|
|
1053
|
+
catch (error) {
|
|
1054
|
+
// journal 建不出来 = 没有安全网 ⇒ **零写入失败**(与「暂存目录建不出来」同一条判据)
|
|
1055
|
+
this.warn('rollback undo journal unavailable: ' + errorDetail(error));
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1001
1058
|
}
|
|
1002
1059
|
async writeFile(target, bytes) {
|
|
1003
1060
|
if (this.writeSeam !== undefined) {
|
|
@@ -2818,6 +2875,71 @@ export class RollbackPointEngine {
|
|
|
2818
2875
|
return previewCountsUnavailable();
|
|
2819
2876
|
return { added: counts.added, removed: counts.removed, truncated: false };
|
|
2820
2877
|
}
|
|
2878
|
+
/**
|
|
2879
|
+
* **中断的回退**(2026-10-18):进程在「回退写到一半」时没了 ⇒ 工作区停在半回退状态。
|
|
2880
|
+
*
|
|
2881
|
+
* 判据 = 目录名形态(`<pid>-<ts36>-<8hex>`)+ 有 journal 索引 + **没有 done** + 持锁进程已死
|
|
2882
|
+
*(全部在 rollback-undo-journal.ts 里,可注入 isAlive 以便测试)。
|
|
2883
|
+
*
|
|
2884
|
+
* 只读:不改工作区、不删现场;是否重放由用户决定(见 recoverInterruptedRollback)。
|
|
2885
|
+
* 这也是 cleanupOrphanTempFiles 不再整目录删它的原因 —— 删了就等于把半回退现场变成
|
|
2886
|
+
* 「无人知晓的部分修改」,正是任务要求里禁止留下的东西。
|
|
2887
|
+
*/
|
|
2888
|
+
async interruptedRollbacks() {
|
|
2889
|
+
try {
|
|
2890
|
+
return await findInterruptedUndos(path.join(this.root, ROLLBACK_UNDO_DIR_NAME));
|
|
2891
|
+
}
|
|
2892
|
+
catch (error) {
|
|
2893
|
+
this.warn('rollback interrupted scan failed: ' + errorDetail(error));
|
|
2894
|
+
return [];
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
/**
|
|
2898
|
+
* 把一条中断的回退**退回回退前**(重放前像),返回逐条结果。
|
|
2899
|
+
*
|
|
2900
|
+
* 安全边界全在 replayUndoJournal 里:只有「当前内容仍等于 after 镜像」的条目才写回 ——
|
|
2901
|
+
* 之后发生的任何人工 / 外部 / 别的会话的改动一律**跳过**并如实回报(绝不覆盖)。
|
|
2902
|
+
* 只有全部条目都成功(没有 skipped / failed)才清理现场;否则现场留着,可以再看、再试。
|
|
2903
|
+
*/
|
|
2904
|
+
async recoverInterruptedRollback(dir) {
|
|
2905
|
+
const list = await this.interruptedRollbacks();
|
|
2906
|
+
const hit = list.find((item) => pathKey(item.dir) === pathKey(dir));
|
|
2907
|
+
if (hit === undefined)
|
|
2908
|
+
return { ok: false, restored: 0, skipped: [], failed: [], detail: 'not-an-interrupted-rollback' };
|
|
2909
|
+
let result;
|
|
2910
|
+
try {
|
|
2911
|
+
result = await replayUndoJournal(hit, {
|
|
2912
|
+
readCurrent: async (target) => {
|
|
2913
|
+
try {
|
|
2914
|
+
return await this.readFileBytes(target);
|
|
2915
|
+
}
|
|
2916
|
+
catch (error) {
|
|
2917
|
+
// 读不出来(EACCES 等)**不等于不存在**:抛出去让 replay 记 failed,绝不写
|
|
2918
|
+
if (error.code === 'ENOENT')
|
|
2919
|
+
return null;
|
|
2920
|
+
throw error;
|
|
2921
|
+
}
|
|
2922
|
+
},
|
|
2923
|
+
write: (target, bytes) => this.writeFile(target, bytes),
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2926
|
+
catch (error) {
|
|
2927
|
+
return { ok: false, restored: 0, skipped: [], failed: [], detail: errorDetail(error) };
|
|
2928
|
+
}
|
|
2929
|
+
const ok = result.failed.length === 0 && result.skipped.length === 0;
|
|
2930
|
+
if (ok) {
|
|
2931
|
+
try {
|
|
2932
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
2933
|
+
}
|
|
2934
|
+
catch {
|
|
2935
|
+
/* 现场清理失败不影响「已经恢复好了」这个事实 */
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
return {
|
|
2939
|
+
ok, restored: result.restored, skipped: result.skipped, failed: result.failed,
|
|
2940
|
+
detail: 'restored=' + String(result.restored) + ' skipped=' + String(result.skipped.length) + ' failed=' + String(result.failed.length),
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2821
2943
|
/** 只读预览:零写入(不过确认门、不拍 guard、不写任何字节)。 */
|
|
2822
2944
|
async preview(id) {
|
|
2823
2945
|
const storage = await this.storageStatus();
|
|
@@ -2831,6 +2953,7 @@ export class RollbackPointEngine {
|
|
|
2831
2953
|
removals: [], removalsCount: 0, removalsTruncated: false,
|
|
2832
2954
|
createdSkipped: [], createdSkippedCount: 0, createdSkippedTruncated: false,
|
|
2833
2955
|
createdRefusedReason: null,
|
|
2956
|
+
conflictCount: 0, conflictPaths: [], unavailableCount: 0, externalCount: 0,
|
|
2834
2957
|
chunks: [], available: false, reasonCode: null,
|
|
2835
2958
|
skippedCount: 0, truncated: false,
|
|
2836
2959
|
};
|
|
@@ -2889,7 +3012,12 @@ export class RollbackPointEngine {
|
|
|
2889
3012
|
// 单张预览共享的比对预算(字节 + 步数);只有真的会被改动的条目才消耗它
|
|
2890
3013
|
const diffBudget = { bytes: 0, work: 0 };
|
|
2891
3014
|
/** 合并判定的会话身份与 base 缓存(与执行端同一判据:拿目标点的会话当「本会话」) */
|
|
2892
|
-
|
|
3015
|
+
// 救援点(guard)是「回到回退前那一刻」的**精确恢复**语义 ⇒ 关掉隔离与三方合并
|
|
3016
|
+
// (sessionId='' ⇒ isolationSessionId=null)。R-25 冻结了这条口径:回退救援点必须把工作区
|
|
3017
|
+
// **逐字**写回那一刻;让它走合并会变成「什么都不做」(内容与当前磁盘一致)。
|
|
3018
|
+
const previewIsolationSessionId = record.kind === 'guard'
|
|
3019
|
+
? null
|
|
3020
|
+
: (typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null);
|
|
2893
3021
|
const previewBaseCache = new Map();
|
|
2894
3022
|
// T10:stat 的三种结局分开表达 —— 「不存在(ENOENT)」与「存在性未知(其它 errno)」绝不混为一谈
|
|
2895
3023
|
const previewFiles = wsPayload.files;
|
|
@@ -2959,16 +3087,18 @@ export class RollbackPointEngine {
|
|
|
2959
3087
|
const decision = await (async () => {
|
|
2960
3088
|
if (current === null || previewIsolationSessionId === null)
|
|
2961
3089
|
return { mode: 'overwrite' };
|
|
2962
|
-
let
|
|
2963
|
-
if (
|
|
2964
|
-
|
|
2965
|
-
previewBaseCache.set(pathKey(file.path),
|
|
3090
|
+
let base = previewBaseCache.get(pathKey(file.path));
|
|
3091
|
+
if (base === undefined) {
|
|
3092
|
+
base = await this.baseFor(file.path, wsTrack.capturedAt, previewIsolationSessionId);
|
|
3093
|
+
previewBaseCache.set(pathKey(file.path), base);
|
|
2966
3094
|
}
|
|
2967
|
-
const
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
3095
|
+
const evidence = {
|
|
3096
|
+
provenance: await this.provenanceOf(file.path, wsTrack.capturedAt, previewIsolationSessionId),
|
|
3097
|
+
// 「别人的回合点里没有它」只在**拿不到祖先**时才问(有祖先就不必再读一遍台账)
|
|
3098
|
+
absentAtForeignPoint: base === null
|
|
3099
|
+
&& await this.absentAtForeignPointOf(file.path, wsTrack.capturedAt, previewIsolationSessionId),
|
|
3100
|
+
};
|
|
3101
|
+
const outcome = await this.mergeForeignRecord(file, base, evidence);
|
|
2972
3102
|
if (outcome.kind === 'write')
|
|
2973
3103
|
return { mode: 'merged', bytes: outcome.bytes };
|
|
2974
3104
|
if (outcome.kind === 'unchanged')
|
|
@@ -3020,6 +3150,17 @@ export class RollbackPointEngine {
|
|
|
3020
3150
|
// 捕获期的跳过计数与截断标记如实回传(列表被记录上限截断时 skippedCount 仍是真实总数)
|
|
3021
3151
|
workspace.skippedCount = wsPayload.skippedCount;
|
|
3022
3152
|
workspace.truncated = wsPayload.truncated;
|
|
3153
|
+
// 冲突 / 无法安全撤销 / 落点无人认领 —— **预览逐条列出**,不是静默略过(任务要求:
|
|
3154
|
+
// 「回退前显示预计撤销的修改、保留的后续修改及潜在冲突」)。三者与执行阶段**同源**:
|
|
3155
|
+
// 都来自同一份 mergeForeignRecord 的 blocked 原因,写进逐文件的 mergeReason。
|
|
3156
|
+
const blockedFiles = changedFiles.filter((item) => item.skipped === true);
|
|
3157
|
+
workspace.conflictCount = blockedFiles.filter((item) => item.mergeReason === 'conflict').length;
|
|
3158
|
+
workspace.conflictPaths = blockedFiles
|
|
3159
|
+
.filter((item) => item.mergeReason === 'conflict')
|
|
3160
|
+
.map((item) => item.path)
|
|
3161
|
+
.slice(0, ROLLBACK_LEFTOVER_LIST_LIMIT);
|
|
3162
|
+
workspace.externalCount = blockedFiles.filter((item) => item.mergeReason === 'external-edits').length;
|
|
3163
|
+
workspace.unavailableCount = blockedFiles.length - workspace.conflictCount - workspace.externalCount;
|
|
3023
3164
|
workspaceRestorable = wsPayload.files.length > 0;
|
|
3024
3165
|
}
|
|
3025
3166
|
// 会话态
|
|
@@ -3156,11 +3297,14 @@ export class RollbackPointEngine {
|
|
|
3156
3297
|
let mergeSkippedReason = null;
|
|
3157
3298
|
const isolationSessionId = typeof record.sessionId === 'string' && record.sessionId !== '' ? record.sessionId : null;
|
|
3158
3299
|
if (current !== null && snapshot !== null && isolationSessionId !== null) {
|
|
3159
|
-
const
|
|
3160
|
-
const
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3300
|
+
const base = await this.baseFor(file.path, record.workspace.capturedAt, isolationSessionId);
|
|
3301
|
+
const evidence = {
|
|
3302
|
+
provenance: await this.provenanceOf(file.path, record.workspace.capturedAt, isolationSessionId),
|
|
3303
|
+
absentAtForeignPoint: base === null
|
|
3304
|
+
&& await this.absentAtForeignPointOf(file.path, record.workspace.capturedAt, isolationSessionId),
|
|
3305
|
+
};
|
|
3306
|
+
if (base !== null || evidence.provenance !== 'unknown' || evidence.absentAtForeignPoint) {
|
|
3307
|
+
const outcome = await this.mergeForeignRecord(file, base, evidence);
|
|
3164
3308
|
if (outcome.kind === 'write') {
|
|
3165
3309
|
mergedAfter = outcome.bytes;
|
|
3166
3310
|
mergedFlag = true;
|
|
@@ -3328,8 +3472,11 @@ export class RollbackPointEngine {
|
|
|
3328
3472
|
const segment = state === 'workspace'
|
|
3329
3473
|
// 路径级选择(2026-10-17 定案 A)只在显式给出时透传 —— 缺省口径一字不变
|
|
3330
3474
|
? await this.restoreWorkspace(record.workspace, input.onlyPaths === undefined
|
|
3331
|
-
|
|
3332
|
-
|
|
3475
|
+
// 救援点(guard)是「回到回退前那一刻」的**精确恢复**语义 ⇒ 关掉隔离与三方合并
|
|
3476
|
+
// (sessionId='' ⇒ isolationSessionId=null)。R-25 冻结了这条口径:回退救援点必须把工作区
|
|
3477
|
+
// **逐字**写回那一刻;让它走合并会变成「什么都不做」(内容与当前磁盘一致)。
|
|
3478
|
+
? { removeCreated: input.removeCreated === true, sessionId: record.kind === 'guard' ? '' : (record.sessionId ?? '') }
|
|
3479
|
+
: { removeCreated: input.removeCreated === true, onlyPaths: input.onlyPaths, sessionId: record.kind === 'guard' ? '' : (record.sessionId ?? '') })
|
|
3333
3480
|
: await this.restoreSession(record.session);
|
|
3334
3481
|
segments.push(segment);
|
|
3335
3482
|
if (segment.childSessionId !== undefined)
|
|
@@ -3353,9 +3500,20 @@ export class RollbackPointEngine {
|
|
|
3353
3500
|
// 「0 变更」必须真的是 0 变更:**删了文件的段不算无事可做**(restored=0 只表示没写回记录在案的
|
|
3354
3501
|
// 路径;truncated 的点回退时常常「一条都不用写、但删掉了几个新建文件」—— 那种情形说
|
|
3355
3502
|
// 「没有可恢复的内容」就是假尾句。真机端到端实测:removed=2 而 code 曾为 nothing-to-restore)
|
|
3503
|
+
/**
|
|
3504
|
+
* **被安全判据主动跳过**的文件总数(冲突 / 无法安全撤销 / 落点无人认领)。
|
|
3505
|
+
*
|
|
3506
|
+
* 与「无事可做」是两件事:用户要求回退,这些文件**是故意没有回退的**。
|
|
3507
|
+
* 把它们算进「成功、0 变更 / 没有可恢复的内容」就是假尾句 —— 竞品口径可以直接引用:
|
|
3508
|
+
* Claude Code 的原文是 Restored the code, but skipped N files(官方文档 errors 一节)。
|
|
3509
|
+
* 判据只读段上已经如实回传的三个计数,不新增任何猜测。
|
|
3510
|
+
*/
|
|
3511
|
+
const deliberatelySkipped = segments.reduce((sum, s) => sum
|
|
3512
|
+
+ (s.conflictCount ?? 0) + (s.externalCount ?? 0), 0);
|
|
3356
3513
|
const allNoop = segments.length > 0
|
|
3357
3514
|
&& segments.every((s) => s.status === 'skipped'
|
|
3358
|
-
|| (s.status === 'restored' && s.restored === 0 && (s.removed ?? 0) === 0))
|
|
3515
|
+
|| (s.status === 'restored' && s.restored === 0 && (s.removed ?? 0) === 0))
|
|
3516
|
+
&& deliberatelySkipped === 0;
|
|
3359
3517
|
let outcome;
|
|
3360
3518
|
let code;
|
|
3361
3519
|
if (workspaceFailed) {
|
|
@@ -3366,7 +3524,7 @@ export class RollbackPointEngine {
|
|
|
3366
3524
|
outcome = 'restored';
|
|
3367
3525
|
code = 'nothing-to-restore';
|
|
3368
3526
|
}
|
|
3369
|
-
else if (anyFailed || anySkipped) {
|
|
3527
|
+
else if (anyFailed || anySkipped || deliberatelySkipped > 0) {
|
|
3370
3528
|
outcome = 'partial';
|
|
3371
3529
|
code = anyFailed ? 'restore-failed' : 'ok';
|
|
3372
3530
|
}
|
|
@@ -3428,7 +3586,7 @@ export class RollbackPointEngine {
|
|
|
3428
3586
|
* `unchanged`(合并结果就是当前内容 —— 对方没动这一处,本来就不需要写)、
|
|
3429
3587
|
* `blocked`(不能安全处理:冲突 / 没有中间版本 / 二进制 / 差异被降级 / 对方改成了不存在 / 读不出)。
|
|
3430
3588
|
*/
|
|
3431
|
-
async mergeForeignRecord(file,
|
|
3589
|
+
async mergeForeignRecord(file, base, evidence) {
|
|
3432
3590
|
let oursBytes;
|
|
3433
3591
|
try {
|
|
3434
3592
|
oursBytes = await this.readObject(file.object);
|
|
@@ -3443,27 +3601,75 @@ export class RollbackPointEngine {
|
|
|
3443
3601
|
theirsBytes = await fs.readFile(file.path);
|
|
3444
3602
|
}
|
|
3445
3603
|
catch (error) {
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
//
|
|
3455
|
-
//
|
|
3456
|
-
if (
|
|
3457
|
-
return { kind: 'blocked', reason: '
|
|
3604
|
+
// 读不出来(EACCES / EMFILE…)⇒ **存在性未知**,绝不按「不存在」处理
|
|
3605
|
+
if (error.code !== 'ENOENT')
|
|
3606
|
+
return { kind: 'blocked', reason: 'theirs-unreadable' };
|
|
3607
|
+
// 记录在案的路径**当前不存在**:旧口径是「按记录重建」—— 但那会连带把「别人删掉它」
|
|
3608
|
+
// 这件事一起撤销。判据只有两条,且都能逐字解释:
|
|
3609
|
+
// ① 存在共同祖先(别的会话的回合点里还有它)⇒ 它是在那个点**之后**才消失的 ⇒ 别人删的,
|
|
3610
|
+
// 绝不复活(真机:B 删掉文件后回退 A);
|
|
3611
|
+
// ② 归属 / 时间窗表明那一刻是别人在写 ⇒ 同样不复活。
|
|
3612
|
+
// 注意**不能**用 absentAtForeignPoint 当判据:本会话自己把它改名 / 删除时,它在别人的回合点里
|
|
3613
|
+
// 本来就不存在(T6-3 真机形状)—— 拿它当「别人删的」会让正常的改名回退失效。
|
|
3614
|
+
if (base !== null)
|
|
3615
|
+
return { kind: 'blocked', reason: 'theirs-missing' };
|
|
3616
|
+
if (evidence.provenance === 'unclaimed')
|
|
3617
|
+
return { kind: 'blocked', reason: 'unclaimed' };
|
|
3618
|
+
if (evidence.provenance === 'foreign')
|
|
3619
|
+
return { kind: 'blocked', reason: 'theirs-missing' };
|
|
3458
3620
|
return { kind: 'fallback' };
|
|
3459
3621
|
}
|
|
3460
|
-
//
|
|
3461
|
-
|
|
3622
|
+
// 救援点当 base 只在「它之后确实又变过」时才成立:救援点是在**执行回退那一刻**拍的,若它与
|
|
3623
|
+
// 当前磁盘逐字相同,说明它提供不了任何「回退之后发生了什么」的知识 —— 拿它当共同祖先会把
|
|
3624
|
+
// 对方已经改进去的内容当成基线,合并结论变成 ours ⇒ 整文件覆盖、抹掉对方的改动。
|
|
3625
|
+
const usableBase = base !== null && !(base.fromGuard && sameBytes(base.bytes, theirsBytes)) ? base.bytes : null;
|
|
3626
|
+
// 二进制(任一侧 8 KiB 内出现 NUL)绝不按文本合并:三方合并算法对它没有定义
|
|
3627
|
+
if (hasNulByte(oursBytes) || hasNulByte(theirsBytes) || (usableBase !== null && hasNulByte(usableBase))) {
|
|
3462
3628
|
return { kind: 'blocked', reason: 'binary' };
|
|
3463
|
-
|
|
3629
|
+
}
|
|
3630
|
+
const oursDecoded = decodeTextBytes(oursBytes);
|
|
3631
|
+
const theirsDecoded = decodeTextBytes(theirsBytes);
|
|
3632
|
+
const baseDecoded = usableBase === null ? null : decodeTextBytes(usableBase);
|
|
3633
|
+
/**
|
|
3634
|
+
* **base 必须真的携带「本会话改动之后」的信息**:若它与 ours(回退目标)逐字相同,说明它
|
|
3635
|
+
* 提供不了任何「本会话改了什么」的知识 —— 拿它当共同祖先会让合并结论变成「保持当前」,
|
|
3636
|
+
* 于是「回退到该点」成了「什么都不做」。
|
|
3637
|
+
*
|
|
3638
|
+
* 但**没有可用祖先**不等于「可以整文件覆盖」:撤销本会话的唯一依据是「这条路径确实是本会话
|
|
3639
|
+
* 写的」,而这条依据只能来自旁证(归属台账 / 活跃时间窗)或「别人的回合点里有没有它」。
|
|
3640
|
+
* 拿不到依据就**一个字节都不写** —— 宁可少退一次,也绝不覆盖别人的(或用户的)内容。
|
|
3641
|
+
* 只有在**完全没有证据**(老数据 / 未接时间窗)时才退回旧口径,与加这套判据之前逐字一致:
|
|
3642
|
+
* 判据只允许「少做」,不允许凭空多拦。
|
|
3643
|
+
*/
|
|
3644
|
+
if (usableBase === null || (baseDecoded !== null && baseDecoded.ok && oursDecoded.ok && baseDecoded.text === oursDecoded.text)) {
|
|
3645
|
+
if (evidence.provenance === 'foreign' || evidence.absentAtForeignPoint)
|
|
3646
|
+
return { kind: 'blocked', reason: 'no-base' };
|
|
3647
|
+
if (evidence.provenance === 'unclaimed')
|
|
3648
|
+
return { kind: 'blocked', reason: 'unclaimed' };
|
|
3649
|
+
if (usableBase === null)
|
|
3650
|
+
return { kind: 'fallback' };
|
|
3651
|
+
return evidence.provenance === 'target' ? { kind: 'fallback' } : { kind: 'blocked', reason: 'unclaimed' };
|
|
3652
|
+
}
|
|
3653
|
+
/**
|
|
3654
|
+
* 合并的文本视图:三方都能**无损 UTF-8 往返** ⇒ 按 UTF-8 文本合并,落盘按 result.shape
|
|
3655
|
+
*(行尾 / BOM 由 text-fidelity 描述,encodeTextBytes 负责还原);
|
|
3656
|
+
* 否则退到 **latin1 视图** —— latin1 是字节的双射,「字符串相等」= 「字节相等」,
|
|
3657
|
+
* 行级 diff / 合并因此等价于**按字节**做:非法 UTF-8(GBK / Latin-1 / 半截序列)逐字保留,
|
|
3658
|
+
* 绝不会被替换成 U+FFFD(2026-10-18 修:真机把 0xE9 写成 EF BF BD)。
|
|
3659
|
+
*/
|
|
3660
|
+
const baseBytes = usableBase;
|
|
3661
|
+
const useLatin1 = !oursDecoded.ok || !theirsDecoded.ok || baseDecoded === null || !baseDecoded.ok;
|
|
3662
|
+
const oursText = useLatin1 ? latin1Of(oursBytes) : oursDecoded.text;
|
|
3663
|
+
const theirsText = useLatin1 ? latin1Of(theirsBytes) : theirsDecoded.text;
|
|
3664
|
+
const baseText = useLatin1 ? latin1Of(baseBytes) : baseDecoded.text;
|
|
3464
3665
|
const result = mergeThreeWay(oursText, baseText, theirsText);
|
|
3465
3666
|
if (!result.ok) {
|
|
3466
|
-
return {
|
|
3667
|
+
return {
|
|
3668
|
+
kind: 'blocked',
|
|
3669
|
+
reason: result.reason === 'conflict' ? 'conflict'
|
|
3670
|
+
: result.reason === 'degraded' ? 'degraded'
|
|
3671
|
+
: result.reason === 'non-utf8' ? 'non-utf8' : 'binary',
|
|
3672
|
+
};
|
|
3467
3673
|
}
|
|
3468
3674
|
// 调用方只在「当前内容 ≠ 记录内容」时才会走到这里 ⇒ `ours === theirs` 只可能是内容恰好相同
|
|
3469
3675
|
if (oursText === theirsText)
|
|
@@ -3477,7 +3683,11 @@ export class RollbackPointEngine {
|
|
|
3477
3683
|
// 一个字节都不写,让用户看到并自己决定。
|
|
3478
3684
|
return { kind: 'blocked', reason: 'ambiguous' };
|
|
3479
3685
|
}
|
|
3480
|
-
|
|
3686
|
+
// 落盘字节:UTF-8 路径按 shape 还原 BOM(result.text 里不含 BOM);latin1 路径逐字节回写。
|
|
3687
|
+
// 两条路径的行尾都已由 mergeThreeWay 按 shape.eol 收尾 ⇒ CRLF 文件不再被折成 LF。
|
|
3688
|
+
const bytes = useLatin1
|
|
3689
|
+
? Buffer.from(result.text, 'latin1')
|
|
3690
|
+
: Buffer.from(encodeTextBytes(result.text, result.shape));
|
|
3481
3691
|
return { kind: 'write', bytes, sha256: sha256Hex(bytes), expectedCurrent: sha256Hex(theirsBytes) };
|
|
3482
3692
|
}
|
|
3483
3693
|
/**
|
|
@@ -3492,15 +3702,91 @@ export class RollbackPointEngine {
|
|
|
3492
3702
|
* **本会话的点**(见上)、以及内容读不出来的点(继续往后找 —— 找不到 ⇒ 调用方按「无法安全撤销」
|
|
3493
3703
|
* 处理,绝不用近似值代替)。
|
|
3494
3704
|
*/
|
|
3495
|
-
|
|
3705
|
+
/**
|
|
3706
|
+
* 这条路径**是谁写的** —— 只读旁证(归属台账 + 活跃时间窗),**绝不从内容猜**。
|
|
3707
|
+
*
|
|
3708
|
+
* 优先归属台账(tool/call 参数,精确到调用);没有归属时看文件 mtime 落在谁的活跃窗口内 ——
|
|
3709
|
+
* 这一条与工具无关,因此 bash / 脚本 / 外部编辑器写的路径同样能被判出来。
|
|
3710
|
+
* `hasDataFor` 是「有没有覆盖」的自证:**没有覆盖就返回 unknown**(不拦),有覆盖却没有任何会话
|
|
3711
|
+
* 活跃在那一刻才返回 unclaimed(手改 / 外部工具 / 另一个 DSH 进程)。
|
|
3712
|
+
*/
|
|
3713
|
+
async provenanceOf(target, capturedAt, sessionId) {
|
|
3714
|
+
if (sessionId === null)
|
|
3715
|
+
return 'unknown';
|
|
3716
|
+
const writer = this.attribution?.writerOf(target);
|
|
3717
|
+
if (writer !== undefined && writer.at > capturedAt) {
|
|
3718
|
+
return writer.sessionId === sessionId ? 'target' : 'foreign';
|
|
3719
|
+
}
|
|
3720
|
+
const activity = this.activity;
|
|
3721
|
+
if (activity === undefined)
|
|
3722
|
+
return 'unknown';
|
|
3723
|
+
let mtimeMs;
|
|
3724
|
+
try {
|
|
3725
|
+
mtimeMs = (await fs.stat(target)).mtimeMs;
|
|
3726
|
+
}
|
|
3727
|
+
catch {
|
|
3728
|
+
return 'unknown';
|
|
3729
|
+
}
|
|
3730
|
+
if (!(mtimeMs > capturedAt))
|
|
3731
|
+
return 'unknown';
|
|
3732
|
+
const active = activity.sessionsActiveAt(mtimeMs);
|
|
3733
|
+
if (active.length > 0) {
|
|
3734
|
+
// 只有「那一刻唯一活跃的就是本会话」才算自有证据;多会话同时活跃 ⇒ 保守当成别人的
|
|
3735
|
+
if (active.includes(sessionId) && active.every((id) => id === sessionId))
|
|
3736
|
+
return 'target';
|
|
3737
|
+
return 'foreign';
|
|
3738
|
+
}
|
|
3739
|
+
return activity.hasDataFor(sessionId) ? 'unclaimed' : 'unknown';
|
|
3740
|
+
}
|
|
3741
|
+
/**
|
|
3742
|
+
* 目标点之后**别的会话的回合点**里,有没有哪个**不含**这条路径(= 那一刻它不存在)。
|
|
3743
|
+
*
|
|
3744
|
+
* 它证明的是:「当前磁盘上这份内容是该点之后才出现的」⇒ 拿目标点的内容整文件覆盖它,等于
|
|
3745
|
+
* 抹掉对方新建/重建的工作(2026-10-18:A 删文件、B 重建,回退 A 把 B 的内容覆盖回旧版本)。
|
|
3746
|
+
* 截断的点(`payload.truncated`)不能用来证明「不存在」,一律跳过;只扫最早的若干个点(有界)。
|
|
3747
|
+
*/
|
|
3748
|
+
async absentAtForeignPointOf(target, afterAt, sessionId) {
|
|
3496
3749
|
const ledger = await this.readManifest();
|
|
3497
3750
|
const key = pathKey(target);
|
|
3498
3751
|
const candidates = ledger.entries
|
|
3499
3752
|
.filter((entry) => entry.createdAt > afterAt
|
|
3500
3753
|
&& entry.kind !== 'guard'
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3754
|
+
&& (sessionId === null || entry.sessionId !== sessionId))
|
|
3755
|
+
.sort((left, right) => (left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0))
|
|
3756
|
+
.slice(0, 8);
|
|
3757
|
+
for (const entry of candidates) {
|
|
3758
|
+
const resolved = await this.readFullRecord(entry.id);
|
|
3759
|
+
const payload = resolved.record?.workspace.payload;
|
|
3760
|
+
if (payload === null || payload === undefined)
|
|
3761
|
+
continue;
|
|
3762
|
+
if (payload.truncated === true)
|
|
3763
|
+
continue;
|
|
3764
|
+
if (!payload.files.some((item) => pathKey(item.path) === key))
|
|
3765
|
+
return true;
|
|
3766
|
+
}
|
|
3767
|
+
return false;
|
|
3768
|
+
}
|
|
3769
|
+
/** 两档 base:**别的会话的回合点**优先,其次**救援点**(见 baseVersionFor 的判据注释) */
|
|
3770
|
+
async baseFor(target, afterAt, excludeSessionId) {
|
|
3771
|
+
const turn = await this.baseVersionFor(target, afterAt, excludeSessionId, false);
|
|
3772
|
+
if (turn !== null)
|
|
3773
|
+
return turn;
|
|
3774
|
+
return await this.baseVersionFor(target, afterAt, excludeSessionId, true);
|
|
3775
|
+
}
|
|
3776
|
+
async baseVersionFor(target, afterAt, excludeSessionId, fromGuard) {
|
|
3777
|
+
const ledger = await this.readManifest();
|
|
3778
|
+
const key = pathKey(target);
|
|
3779
|
+
const candidates = ledger.entries
|
|
3780
|
+
.filter((entry) => entry.createdAt > afterAt
|
|
3781
|
+
// 第一档(fromGuard=false)= **别的会话的回合点**:共同祖先的正统来源。本会话后续回合的点
|
|
3782
|
+
// 不能当 base —— 它会把「本会话后续改动」也一并保留,与「回退到该点之前」的直觉冲突。
|
|
3783
|
+
// 第二档(fromGuard=true)= **救援点**:连续回退(先退 A 再退 B)时,B 的目标点之后往往只有
|
|
3784
|
+
// 第一次回退留下的救援点,而它的内容正是「A 已改、B 未改」那一刻的状态 —— 只有它能算出
|
|
3785
|
+
// 「把 A 的改动也一并撤销、同时保住 B 之后的内容」。旧口径在这时直接整文件覆盖 ⇒ A 的改动
|
|
3786
|
+
// 复活、B 的改动被抹掉(2026-10-18 真机可复现)。救援点与当前磁盘相同的情形由调用方挡掉。
|
|
3787
|
+
&& (fromGuard
|
|
3788
|
+
? entry.kind === 'guard'
|
|
3789
|
+
: entry.kind !== 'guard' && (excludeSessionId === null || entry.sessionId !== excludeSessionId)))
|
|
3504
3790
|
.sort((left, right) => (left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0));
|
|
3505
3791
|
for (const entry of candidates) {
|
|
3506
3792
|
const resolved = await this.readFullRecord(entry.id);
|
|
@@ -3514,7 +3800,7 @@ export class RollbackPointEngine {
|
|
|
3514
3800
|
const bytes = await this.readObject(hit.object);
|
|
3515
3801
|
if (sha256Hex(bytes) !== hit.sha256)
|
|
3516
3802
|
continue;
|
|
3517
|
-
return
|
|
3803
|
+
return { bytes, fromGuard };
|
|
3518
3804
|
}
|
|
3519
3805
|
catch {
|
|
3520
3806
|
continue;
|
|
@@ -3643,11 +3929,14 @@ export class RollbackPointEngine {
|
|
|
3643
3929
|
let conflictCount = 0;
|
|
3644
3930
|
/** 无法安全撤销:没有中间版本 / 二进制 / 差异被降级 / 对方把它改成了不存在 */
|
|
3645
3931
|
let unavailableCount = 0;
|
|
3932
|
+
/** 落点无人认领(时间窗表明那一刻没有会话活跃)⇒ 拒绝覆盖,交由用户决定 */
|
|
3933
|
+
let externalCount = 0;
|
|
3646
3934
|
/** 写入前发现文件又被改过(并发)⇒ 放弃写入 */
|
|
3647
3935
|
let concurrentCount = 0;
|
|
3648
3936
|
const mergedPaths = [];
|
|
3649
3937
|
const conflictPaths = [];
|
|
3650
3938
|
const unavailablePaths = [];
|
|
3939
|
+
const externalPaths = [];
|
|
3651
3940
|
const concurrentPaths = [];
|
|
3652
3941
|
for (const file of payload.files) {
|
|
3653
3942
|
// 路径级选择:没被点名的记录条目**不写**(用户自己的选择,不是安全判据)
|
|
@@ -3683,50 +3972,51 @@ export class RollbackPointEngine {
|
|
|
3683
3972
|
// **合并保护**(2026-10-17 修):到这里才确认「这个文件确实需要写」,也才值得去查共同祖先。
|
|
3684
3973
|
// 触发不吃归属:只要目标点之后存在**别的会话**的回合点(那它就是共同祖先),就走三方合并。
|
|
3685
3974
|
if (mergeGuardEnabled) {
|
|
3686
|
-
let base;
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
base = await this.baseVersionFor(file.path, track.capturedAt, isolationSessionId);
|
|
3975
|
+
let base = baseCache.get(pathKey(file.path));
|
|
3976
|
+
if (base === undefined) {
|
|
3977
|
+
base = await this.baseFor(file.path, track.capturedAt, isolationSessionId);
|
|
3690
3978
|
baseCache.set(pathKey(file.path), base);
|
|
3691
3979
|
}
|
|
3692
|
-
|
|
3693
|
-
|
|
3980
|
+
const evidence = {
|
|
3981
|
+
provenance: await this.provenanceOf(file.path, track.capturedAt, isolationSessionId),
|
|
3982
|
+
// 「别人的回合点里没有它」只在**拿不到祖先**时才问(有祖先就不必再读一遍台账)
|
|
3983
|
+
absentAtForeignPoint: base === null
|
|
3984
|
+
&& await this.absentAtForeignPointOf(file.path, track.capturedAt, isolationSessionId),
|
|
3985
|
+
};
|
|
3986
|
+
const outcome = await this.mergeForeignRecord(file, base, evidence);
|
|
3987
|
+
if (outcome.kind === 'fallback') {
|
|
3988
|
+
// 没有可用祖先、也没有任何旁证 ⇒ 落到下面的旧口径(整文件覆盖回目标版本)
|
|
3989
|
+
}
|
|
3990
|
+
else if (outcome.kind === 'write') {
|
|
3991
|
+
writable.push({ file, bytes: outcome.bytes, sha256: outcome.sha256, expectedCurrent: outcome.expectedCurrent });
|
|
3992
|
+
mergedCount += 1;
|
|
3993
|
+
if (mergedPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
3994
|
+
mergedPaths.push(file.path);
|
|
3995
|
+
continue;
|
|
3996
|
+
}
|
|
3997
|
+
else if (outcome.kind === 'unchanged') {
|
|
3998
|
+
unchanged += 1;
|
|
3999
|
+
continue;
|
|
3694
4000
|
}
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
4001
|
+
else {
|
|
4002
|
+
foreignSkipped += 1;
|
|
4003
|
+
if (outcome.reason === 'conflict') {
|
|
4004
|
+
conflictCount += 1;
|
|
4005
|
+
if (conflictPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
4006
|
+
conflictPaths.push(file.path);
|
|
4007
|
+
}
|
|
4008
|
+
else if (outcome.reason === 'unclaimed') {
|
|
4009
|
+
externalCount += 1;
|
|
4010
|
+
if (externalPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
4011
|
+
externalPaths.push(file.path);
|
|
3703
4012
|
}
|
|
3704
4013
|
else {
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
if (mergedPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
3709
|
-
mergedPaths.push(file.path);
|
|
3710
|
-
continue;
|
|
3711
|
-
}
|
|
3712
|
-
if (outcome.kind === 'unchanged') {
|
|
3713
|
-
unchanged += 1;
|
|
3714
|
-
continue;
|
|
3715
|
-
}
|
|
3716
|
-
foreignSkipped += 1;
|
|
3717
|
-
if (outcome.reason === 'conflict') {
|
|
3718
|
-
conflictCount += 1;
|
|
3719
|
-
if (conflictPaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
3720
|
-
conflictPaths.push(file.path);
|
|
3721
|
-
}
|
|
3722
|
-
else {
|
|
3723
|
-
unavailableCount += 1;
|
|
3724
|
-
if (unavailablePaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
3725
|
-
unavailablePaths.push(file.path);
|
|
3726
|
-
}
|
|
3727
|
-
pushDetail(detail, 'foreign-' + outcome.reason + ' ' + file.path + ' zeroWrites=true');
|
|
3728
|
-
continue;
|
|
4014
|
+
unavailableCount += 1;
|
|
4015
|
+
if (unavailablePaths.length < ROLLBACK_LEFTOVER_LIST_LIMIT)
|
|
4016
|
+
unavailablePaths.push(file.path);
|
|
3729
4017
|
}
|
|
4018
|
+
pushDetail(detail, 'foreign-' + outcome.reason + ' ' + file.path + ' zeroWrites=true');
|
|
4019
|
+
continue;
|
|
3730
4020
|
}
|
|
3731
4021
|
}
|
|
3732
4022
|
writable.push({ file, bytes });
|
|
@@ -3797,10 +4087,17 @@ export class RollbackPointEngine {
|
|
|
3797
4087
|
};
|
|
3798
4088
|
}
|
|
3799
4089
|
if (failed > 0) {
|
|
3800
|
-
//
|
|
4090
|
+
// 零写入:一条不合格就不写任何一条。
|
|
4091
|
+
// **reasonCode 必须分得清成因**(2026-10-18 X-12a 真机):这里此前一律写 'invalid-path',
|
|
4092
|
+
// 把「对象仓丢对象 / 哈希不符」也报成「路径非法」—— 界面与排障都会指向错误的方向。
|
|
4093
|
+
// 判据只读本段已累积的 detail(与回传的逐条说明同源,不另算一套口径)。
|
|
4094
|
+
const hasDetail = (prefix) => detail.some((line) => line.startsWith(prefix));
|
|
4095
|
+
const reasonCode = hasDetail('object-missing') ? 'object-missing'
|
|
4096
|
+
: hasDetail('object-hash-mismatch') ? 'object-hash-mismatch'
|
|
4097
|
+
: 'invalid-path';
|
|
3801
4098
|
detail.push('restored=0 failed=' + String(failed) + ' leftovers=' + String(leftovers.count) + ' zeroWrites=true');
|
|
3802
4099
|
return {
|
|
3803
|
-
state: 'workspace', status: 'failed', reasonCode
|
|
4100
|
+
state: 'workspace', status: 'failed', reasonCode, detail,
|
|
3804
4101
|
restored: 0, failed,
|
|
3805
4102
|
leftovers: leftovers.leftovers, leftoversTruncated: leftovers.truncated,
|
|
3806
4103
|
};
|
|
@@ -3870,6 +4167,17 @@ export class RollbackPointEngine {
|
|
|
3870
4167
|
noop.unavailableCount = unavailableCount;
|
|
3871
4168
|
noop.unavailablePaths = unavailablePaths;
|
|
3872
4169
|
}
|
|
4170
|
+
if (externalCount > 0) {
|
|
4171
|
+
noop.externalCount = externalCount;
|
|
4172
|
+
noop.externalPaths = externalPaths;
|
|
4173
|
+
}
|
|
4174
|
+
// **noop 也要保住原因**:冲突 / 无法安全撤销 / 无人认领的逐条说明都累积在 detail 里。
|
|
4175
|
+
// 只留一行 nothingToDo=true 会让用户看到「没有可退的」却看不到「有 3 个文件因冲突没退」
|
|
4176
|
+
//(2026-10-18 X-3 真机形状:conflictCount=1 但 detail 里一个字都没有)。
|
|
4177
|
+
for (const line of detail) {
|
|
4178
|
+
if (line.startsWith('foreign-') || line.startsWith('concurrent-change'))
|
|
4179
|
+
noop.detail.push(line);
|
|
4180
|
+
}
|
|
3873
4181
|
if (concurrentCount > 0) {
|
|
3874
4182
|
noop.concurrentCount = concurrentCount;
|
|
3875
4183
|
noop.concurrentPaths = concurrentPaths;
|
|
@@ -3942,7 +4250,7 @@ export class RollbackPointEngine {
|
|
|
3942
4250
|
};
|
|
3943
4251
|
}
|
|
3944
4252
|
try {
|
|
3945
|
-
await stager.stage(item.file.path, bytes);
|
|
4253
|
+
await stager.stage(item.file.path, bytes, { sha256: item.sha256 ?? item.file.sha256 });
|
|
3946
4254
|
}
|
|
3947
4255
|
catch (error) {
|
|
3948
4256
|
// 暂存写不下去(ENOSPC / EACCES):**零写入失败** —— 绝不带着不完整的前像开工,
|
|
@@ -4021,7 +4329,7 @@ export class RollbackPointEngine {
|
|
|
4021
4329
|
removal.skipped.push(target);
|
|
4022
4330
|
continue;
|
|
4023
4331
|
}
|
|
4024
|
-
await stager.stage(target, bytes);
|
|
4332
|
+
await stager.stage(target, bytes, { deleted: true });
|
|
4025
4333
|
destroyedCount += 1;
|
|
4026
4334
|
await fs.rm(target, { force: true });
|
|
4027
4335
|
removed += 1;
|
|
@@ -4078,6 +4386,10 @@ export class RollbackPointEngine {
|
|
|
4078
4386
|
segment.unavailableCount = unavailableCount;
|
|
4079
4387
|
segment.unavailablePaths = unavailablePaths;
|
|
4080
4388
|
}
|
|
4389
|
+
if (externalCount > 0) {
|
|
4390
|
+
segment.externalCount = externalCount;
|
|
4391
|
+
segment.externalPaths = externalPaths;
|
|
4392
|
+
}
|
|
4081
4393
|
if (concurrentCount > 0) {
|
|
4082
4394
|
segment.concurrentCount = concurrentCount;
|
|
4083
4395
|
segment.concurrentPaths = concurrentPaths;
|
|
@@ -4085,6 +4397,8 @@ export class RollbackPointEngine {
|
|
|
4085
4397
|
// 捕获不完整 ⇒ 一条都没删:如实回报原因(界面据此说「因为捕获不完整,一条都没动」)
|
|
4086
4398
|
if (removal.refused !== null)
|
|
4087
4399
|
segment.createdRefusedReason = removal.refused;
|
|
4400
|
+
// 全部写入 / 删除都成功 ⇒ 先落 done 标记(这条前像不再是「中断的回退」),再清前像字节。
|
|
4401
|
+
await stager.finish();
|
|
4088
4402
|
// 前像字节用完即清(成功路径):留在磁盘上只会变成无人认领的残留
|
|
4089
4403
|
await stager.dispose();
|
|
4090
4404
|
return segment;
|
|
@@ -4428,6 +4742,7 @@ export class RollbackAutoTracker {
|
|
|
4428
4742
|
log;
|
|
4429
4743
|
statSessionLog;
|
|
4430
4744
|
attribution;
|
|
4745
|
+
activity;
|
|
4431
4746
|
bindings = new Map();
|
|
4432
4747
|
open = new Map();
|
|
4433
4748
|
/**
|
|
@@ -4449,6 +4764,7 @@ export class RollbackAutoTracker {
|
|
|
4449
4764
|
this.log = options.log;
|
|
4450
4765
|
this.statSessionLog = options.statSessionLog;
|
|
4451
4766
|
this.attribution = options.attribution;
|
|
4767
|
+
this.activity = options.activity;
|
|
4452
4768
|
}
|
|
4453
4769
|
/** 实际采用的留点机制(output / 诊断用) */
|
|
4454
4770
|
get mechanismKind() {
|
|
@@ -4506,6 +4822,9 @@ export class RollbackAutoTracker {
|
|
|
4506
4822
|
async noteEvent(event) {
|
|
4507
4823
|
try {
|
|
4508
4824
|
this.mechanism = 'event';
|
|
4825
|
+
// 时间窗归属(2026-10-18):**每个**事件都记一次活跃 —— 与工具参数无关,因此 bash / 脚本 /
|
|
4826
|
+
// 外部编辑器写的文件也能按「mtime 落在谁的活跃窗口内」判定归属。零 I/O(内存 + debounce 落盘)。
|
|
4827
|
+
this.activity?.noteActivity(event.sessionId, this.nowFn());
|
|
4509
4828
|
const binding = this.bindings.get(event.sessionId);
|
|
4510
4829
|
if (binding === undefined)
|
|
4511
4830
|
return { captured: false, code: 'invalid-input', detail: 'unknown-session ' + event.sessionId };
|