dsh-config-manager 0.1.80 → 0.1.82
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/lib/cli/actions.d.ts +5 -0
- package/lib/cli/actions.js +5 -0
- package/lib/cli/index.js +33 -17
- package/lib/cli/restore-resume.d.ts +25 -0
- package/lib/cli/restore-resume.js +49 -0
- package/lib/client.d.ts +6 -0
- package/lib/client.js +521 -519
- package/lib/core/analyzer.d.ts +3 -41
- package/lib/core/analyzer.js +9 -292
- package/lib/core/import-planning.d.ts +110 -0
- package/lib/core/import-planning.js +305 -0
- package/lib/core/journal.d.ts +12 -0
- package/lib/core/journal.js +12 -0
- package/lib/core/messages.d.ts +17 -0
- package/lib/core/messages.js +37 -0
- package/lib/core/model-tools.js +17 -5
- package/lib/core/phase3-host.d.ts +21 -0
- package/lib/core/phase3-host.js +63 -14
- package/lib/core/plugin-cli.d.ts +24 -2
- package/lib/core/plugin-cli.js +36 -3
- package/lib/core/recovery-orchestrator.d.ts +17 -1
- package/lib/core/recovery-orchestrator.js +39 -29
- package/lib/core/restore-crash-child.d.ts +1 -0
- package/lib/core/restore-crash-child.js +77 -0
- package/lib/core/restore-resume.d.ts +161 -0
- package/lib/core/restore-resume.js +274 -0
- package/lib/core/restore-wal.d.ts +88 -0
- package/lib/core/restore-wal.js +164 -0
- package/lib/core/restore.d.ts +39 -1
- package/lib/core/restore.js +229 -13
- package/lib/core/rollback-ignore-rules.d.ts +14 -0
- package/lib/core/rollback-ignore-rules.js +54 -1
- package/lib/core/rollback-point.d.ts +3 -1
- package/lib/core/rollback-point.js +10 -1
- package/lib/index.d.ts +6 -252
- package/lib/index.js +17 -561
- package/lib/market/git-file-writer.d.ts +12 -3
- package/lib/market/git-file-writer.js +42 -6
- package/lib/market/messages-market.d.ts +1 -0
- package/lib/market/messages-market.js +2 -0
- package/lib/market/reader.d.ts +14 -2
- package/lib/market/reader.js +57 -18
- package/lib/routes/consult.js +1 -1
- package/lib/routes/import.js +1 -1
- package/lib/routes/market.js +1 -1
- package/lib/routes/me.js +1 -1
- package/lib/routes/prefs.js +1 -2
- package/lib/routes/profiles.js +1 -1
- package/lib/routes/recovery.js +1 -1
- package/lib/routes/route-support.d.ts +275 -0
- package/lib/routes/route-support.js +637 -0
- package/lib/routes/snapshots.js +40 -8
- package/lib/routes/sync.js +17 -1
- package/lib/sync/autosync-scheduler.d.ts +8 -3
- package/lib/sync/autosync-scheduler.js +22 -9
- package/lib/sync/gist/gist-transport.d.ts +44 -2
- package/lib/sync/gist/gist-transport.js +133 -39
- package/lib/sync/gist/messages.d.ts +1 -0
- package/lib/sync/gist/messages.js +3 -0
- package/lib/sync/git/git-transport.d.ts +33 -0
- package/lib/sync/git/git-transport.js +111 -13
- package/lib/sync/s3/oss4-verify.d.ts +47 -0
- package/lib/sync/s3/oss4-verify.js +156 -0
- package/lib/sync/s3/s3-providers.d.ts +10 -0
- package/lib/sync/s3/s3-providers.js +29 -6
- package/lib/sync/s3/s3-transport.js +6 -1
- package/lib/sync/s3/sigv4.d.ts +23 -2
- package/lib/sync/s3/sigv4.js +19 -8
- package/lib/sync/sync-engine.d.ts +13 -0
- package/lib/sync/sync-engine.js +9 -1
- package/lib/sync/sync-session.d.ts +5 -0
- package/lib/utils/git-fast-forward.d.ts +18 -0
- package/lib/utils/git-fast-forward.js +28 -0
- package/lib/utils/workdir-lock.d.ts +58 -0
- package/lib/utils/workdir-lock.js +147 -0
- package/package.json +2 -1
- package/src/cli/actions.ts +6 -0
- package/src/cli/index-guards.test.ts +62 -0
- package/src/cli/index.ts +30 -20
- package/src/cli/restore-resume.test.ts +120 -0
- package/src/cli/restore-resume.ts +76 -0
- package/src/client/CLIENT_DEPENDENCIES.md +8 -29
- package/src/client/sync/merge-view.ts +2 -0
- package/src/core/analyzer.ts +16 -340
- package/src/core/import-planning.ts +356 -0
- package/src/core/journal.ts +13 -0
- package/src/core/messages.ts +37 -0
- package/src/core/model-tools.ts +19 -5
- package/src/core/phase3-host.ts +73 -14
- package/src/core/plugin-cli.fs.test.ts +72 -2
- package/src/core/plugin-cli.ts +35 -3
- package/src/core/recovery-orchestrator.ts +41 -19
- package/src/core/restore-crash-child.ts +85 -0
- package/src/core/restore-crash-injection.test.ts +329 -0
- package/src/core/restore-resume.test.ts +376 -0
- package/src/core/restore-resume.ts +414 -0
- package/src/core/restore-target-symlink.test.ts +232 -0
- package/src/core/restore-wal.test.ts +170 -0
- package/src/core/restore-wal.ts +222 -0
- package/src/core/restore.ts +251 -13
- package/src/core/rollback-ignore-rules.ts +53 -1
- package/src/core/rollback-point.ts +19 -2
- package/src/core/rollback-sensitive-exclusion.test.ts +226 -0
- package/src/index.facade.test.ts +49 -0
- package/src/index.ts +23 -647
- package/src/market/git-file-writer.test.ts +39 -3
- package/src/market/git-file-writer.ts +43 -7
- package/src/market/market.test.ts +31 -3
- package/src/market/messages-market.ts +2 -0
- package/src/market/reader.ts +55 -18
- package/src/routes/consult.ts +1 -1
- package/src/routes/import.ts +1 -1
- package/src/routes/market.ts +1 -1
- package/src/routes/me.ts +1 -1
- package/src/routes/prefs.ts +3 -2
- package/src/routes/profiles.ts +1 -1
- package/src/routes/recovery.ts +1 -1
- package/src/routes/route-support.ts +735 -0
- package/src/routes/snapshots.ts +47 -7
- package/src/routes/sync.ts +17 -1
- package/src/security/secret-scanner.test.ts +1 -1
- package/src/sync/autosync-scheduler.test.ts +62 -0
- package/src/sync/autosync-scheduler.ts +21 -8
- package/src/sync/gist/gist-conditional-write.test.ts +34 -15
- package/src/sync/gist/gist-delete-concurrency.test.ts +244 -0
- package/src/sync/gist/gist-overwrite-semantics.test.ts +258 -0
- package/src/sync/gist/gist-transport.ts +154 -44
- package/src/sync/gist/messages.ts +3 -0
- package/src/sync/git/git-transport.test.ts +68 -6
- package/src/sync/git/git-transport.ts +117 -14
- package/src/sync/s3/oss4-verify.ts +201 -0
- package/src/sync/s3/s3-providers.ts +32 -6
- package/src/sync/s3/s3-transport.oss4-server.test.ts +117 -0
- package/src/sync/s3/s3-transport.test.ts +44 -1
- package/src/sync/s3/s3-transport.ts +8 -1
- package/src/sync/s3/sigv4.test.ts +97 -0
- package/src/sync/s3/sigv4.ts +38 -9
- package/src/sync/sync-engine.test.ts +64 -0
- package/src/sync/sync-engine.ts +22 -1
- package/src/sync/sync-session.ts +5 -0
- package/src/utils/git-fast-forward.test.ts +44 -0
- package/src/utils/git-fast-forward.ts +31 -0
- package/src/utils/workdir-lock.test.ts +111 -0
- package/src/utils/workdir-lock.ts +162 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导入「计划期纯函数」集合(P4 拆分):从 `core/analyzer.ts` 尾部原样外提,**零行为改动**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成文件:analyzer 曾 1824 行、装着三段式引擎 + 13 个互不相关的纯函数,
|
|
5
|
+
* 任何一处(如墓碑过滤)的阅读都得先跳过引擎实现。这里只收「不碰 fs、不改入参」的纯计算,
|
|
6
|
+
* 引擎类(Analyzer)留在 `analyzer.ts`;两者单向依赖:analyzer → 本模块,本模块不 import analyzer。
|
|
7
|
+
*
|
|
8
|
+
* 对外导入面不变:analyzer.ts 仍 re-export 原本导出的 3 个名字(rebaseMapping /
|
|
9
|
+
* applyMappingsToSections / runSchemaMigration),core/index.ts 与既有测试无需改 import。
|
|
10
|
+
*/
|
|
11
|
+
import { sha256Hex } from '../utils/hashing.js';
|
|
12
|
+
import { isAbsolutePath, applyPrefixMappings } from '../utils/paths.js';
|
|
13
|
+
import { isFileSection } from '../schema/config.js';
|
|
14
|
+
import { validateManifest } from '../schema/manifest.js';
|
|
15
|
+
import { migrateToCurrent } from '../migrations/index.js';
|
|
16
|
+
import { isTombstoned } from '../schema/tombstones.js';
|
|
17
|
+
export async function reportRollbackToJournal(binding, report) {
|
|
18
|
+
const reporter = binding;
|
|
19
|
+
await reporter?.recordRollback?.(report);
|
|
20
|
+
}
|
|
21
|
+
/* ---------------- 纯函数辅助 ---------------- */
|
|
22
|
+
/**
|
|
23
|
+
* G-06:执行 schema 迁移链并把迁移结果重新校验为合法 manifest。
|
|
24
|
+
*
|
|
25
|
+
* `migrateToCurrent` 此前在 `src/` 内零引用(迁移链有定义、有单测、从不执行);
|
|
26
|
+
* `loadBundle` 只用 isSupported 做判定就直接把旧文档当新格式使用。本函数是迁移链的
|
|
27
|
+
* **真实接线点**:沿链迁移 → 重新校验结果是合法 manifest(不合法则抛明确错误,
|
|
28
|
+
* 绝不把半迁移文档当合法 manifest 继续)→ 每个已应用步骤翻译成用户可见告警。
|
|
29
|
+
*
|
|
30
|
+
* 显式传入 from/target 使「链式迁移真的被执行」可被直接单测:当前
|
|
31
|
+
* `MIN_SUPPORTED_SCHEMA_VERSION = CURRENT_SCHEMA_VERSION = 1`,`needsMigration` 恒假,
|
|
32
|
+
* 因此该分支在真实导入中结构上不可达(loadBundle 仍按 needsMigration 调用本函数)。
|
|
33
|
+
*/
|
|
34
|
+
export function runSchemaMigration(doc, fromVersion, targetVersion, msg) {
|
|
35
|
+
const result = migrateToCurrent(doc, fromVersion, targetVersion);
|
|
36
|
+
const issues = validateManifest(result.doc).filter((i) => i.severity === 'error');
|
|
37
|
+
if (issues.length > 0) {
|
|
38
|
+
throw new Error(msg('import.migrateInvalidManifest', { issues: issues.map((i) => i.message).join('; ') }));
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
manifest: result.doc,
|
|
42
|
+
warnings: result.applied.map((step) => msg('import.migrated', { from: String(step.from), to: String(step.to) })),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export async function verifyAgainstTable(entries, table) {
|
|
46
|
+
const mismatches = [];
|
|
47
|
+
const missing = [];
|
|
48
|
+
for (const [relPath, expected] of Object.entries(table)) {
|
|
49
|
+
const data = entries.get(relPath);
|
|
50
|
+
if (data === undefined) {
|
|
51
|
+
missing.push(relPath);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (sha256Hex(data) !== expected)
|
|
55
|
+
mismatches.push(relPath);
|
|
56
|
+
}
|
|
57
|
+
return { ok: mismatches.length === 0 && missing.length === 0, mismatches, missing };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 冲突决策(applyItemResolution)已抽到 `./conflict-decisions.ts`:
|
|
61
|
+
* 与恢复/回放路径共用同一份语义(回放侧随灾备快照线下线,语义仍是同一条)。
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* 基础路径重定基规则:导出机 DSH home → 本机 DSH home(issue #45 用户补充)。
|
|
65
|
+
*
|
|
66
|
+
* 只在「两边的规范化路径都是绝对路径、且不相同」时生成;两侧都去掉尾部分隔符后比较,
|
|
67
|
+
* 避免 /opt/.dsh/ 与 /opt/.dsh 被判成不同而白跑一次全量替换。前缀匹配仍由 applyPrefixMappings
|
|
68
|
+
* 负责(必须落在段边界),所以 /opt/.dsh 不会误伤 /opt/.dsh-extra。
|
|
69
|
+
*
|
|
70
|
+
* @returns 映射规则;不需要重定基(缺 sourceHome / 相同 / 相对路径)→ undefined
|
|
71
|
+
*/
|
|
72
|
+
export function rebaseMapping(sourceHome, localHome) {
|
|
73
|
+
if (sourceHome === undefined || sourceHome === '')
|
|
74
|
+
return undefined;
|
|
75
|
+
const strip = (value) => value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
76
|
+
const from = strip(sourceHome);
|
|
77
|
+
const to = strip(localHome ?? '');
|
|
78
|
+
if (from === '' || to === '' || from === to)
|
|
79
|
+
return undefined;
|
|
80
|
+
const isAbsolute = (value) => value.startsWith('/') || /^[a-zA-Z]:[\/]/.test(value);
|
|
81
|
+
if (!isAbsolute(from) || !isAbsolute(to))
|
|
82
|
+
return undefined;
|
|
83
|
+
return { oldPrefix: from, newPrefix: to, appliesTo: [] };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 会话单元版本查询:探针的单元键(`projectKey/会话目录`)与计划项单元 id
|
|
87
|
+
* (`sessions:projectKey/会话目录`)是两个键空间,必须两种形态都试。
|
|
88
|
+
*
|
|
89
|
+
* 为什么不在探针里直接带上适配器前缀:探针只认「分区内的相对路径」,它不知道也不该知道
|
|
90
|
+
* 哪个适配器在消费它(core 也不得 import 会话字节工具)。键空间对齐因此落在计划生成期。
|
|
91
|
+
*/
|
|
92
|
+
export function sessionUnitVersion(byUnit, unitId) {
|
|
93
|
+
const direct = byUnit.get(unitId);
|
|
94
|
+
if (direct !== undefined)
|
|
95
|
+
return direct;
|
|
96
|
+
const prefix = 'sessions:';
|
|
97
|
+
const bare = unitId.startsWith(prefix) ? unitId.slice(prefix.length) : prefix + unitId;
|
|
98
|
+
return byUnit.get(bare);
|
|
99
|
+
}
|
|
100
|
+
/** 路径映射合并:只保留「已解析」(newPrefix 非空)的映射用于执行期数据改写;
|
|
101
|
+
* 未解决项(newPrefix 空)保留在 plan.items 里供 UI 提示,但不参与数据映射。 */
|
|
102
|
+
export function mergePathMappings(items, userMappings) {
|
|
103
|
+
const merged = new Map();
|
|
104
|
+
for (const m of userMappings) {
|
|
105
|
+
if (m.newPrefix !== '')
|
|
106
|
+
merged.set(m.oldPrefix, m);
|
|
107
|
+
}
|
|
108
|
+
return [...merged.values()];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 把映射应用到分区数据(PathMapper 先行:只替换匹配前缀的字符串叶值)。
|
|
112
|
+
*
|
|
113
|
+
* `fileCollectionIds` 里的分区一律跳过:`FileCollectionAdapter` 把 `relativePath` 当落盘路径用,
|
|
114
|
+
* 它必须逐字保留(issue #45 ④:改写会话文件的相对路径会让目标机下次启动直接报
|
|
115
|
+
* `corrupt session log`;pluginFiles/skills 等同理——丢位置等于丢归属)。
|
|
116
|
+
* 映射在这些分区上**没有任何合法作用**:文件内容存的是字节(`Uint8Array` 叶子),不会被改。
|
|
117
|
+
*/
|
|
118
|
+
export function applyMappingsToSections(sections, mappings, fileCollectionIds = new Set()) {
|
|
119
|
+
if (mappings.length === 0)
|
|
120
|
+
return;
|
|
121
|
+
for (const [sectionId, data] of sections) {
|
|
122
|
+
if (fileCollectionIds.has(sectionId))
|
|
123
|
+
continue;
|
|
124
|
+
const appliesTo = sectionId === 'workspaces' ? ['workspaces']
|
|
125
|
+
: sectionId === 'mcp' ? ['mcp']
|
|
126
|
+
: sectionId === 'skills' ? ['skills']
|
|
127
|
+
: sectionId === 'pluginFiles' ? ['pluginConfig']
|
|
128
|
+
: [];
|
|
129
|
+
const relevant = mappings.filter((m) => m.appliesTo.some((a) => appliesTo.includes(a)) || m.appliesTo.length === 0);
|
|
130
|
+
if (relevant.length === 0)
|
|
131
|
+
continue;
|
|
132
|
+
sections.set(sectionId, applyPrefixMappings(data, relevant));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** 路径形态判定:跨平台盘符/UNIX 冲突 → platformMismatch;其余绝对路径 → missing(需映射) */
|
|
136
|
+
export function detectPathIssues(sourcePlatform, targetPlatform, sections) {
|
|
137
|
+
const issues = [];
|
|
138
|
+
const workspaces = sections.get('workspaces');
|
|
139
|
+
for (const w of workspaces?.workspaces ?? []) {
|
|
140
|
+
const p = w.path;
|
|
141
|
+
// section:如实标注来源分区,供导入向导按「本次会写盘的分区」过滤(见 PathIssue.section)
|
|
142
|
+
if (p && isAbsolutePath(p))
|
|
143
|
+
issues.push({ ...judgePath(p, sourcePlatform, targetPlatform), section: 'workspaces' });
|
|
144
|
+
}
|
|
145
|
+
const mcp = sections.get('mcp');
|
|
146
|
+
for (const s of mcp?.servers ?? []) {
|
|
147
|
+
for (const p of [s.cwd, s.command]) {
|
|
148
|
+
if (p && isAbsolutePath(p))
|
|
149
|
+
issues.push({ ...judgePath(p, sourcePlatform, targetPlatform), section: 'mcp' });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// 去重
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
return issues.filter((i) => {
|
|
155
|
+
const key = `${i.kind}:${i.value}`;
|
|
156
|
+
if (seen.has(key))
|
|
157
|
+
return false;
|
|
158
|
+
seen.add(key);
|
|
159
|
+
return true;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
export function judgePath(p, sourcePlatform, targetPlatform) {
|
|
163
|
+
const isWinStyle = /^[a-zA-Z]:[\\/]/.test(p);
|
|
164
|
+
const isUnixStyle = p.startsWith('/');
|
|
165
|
+
if (sourcePlatform !== targetPlatform && ((isWinStyle && targetPlatform !== 'win32') || (isUnixStyle && targetPlatform === 'win32'))) {
|
|
166
|
+
return { kind: 'platformMismatch', value: p };
|
|
167
|
+
}
|
|
168
|
+
return { kind: 'missing', value: p };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* 凭据计划项(MissingSecret / Skip)在**导入路径**上的唯一生成点。
|
|
172
|
+
*
|
|
173
|
+
* ref 来源取**并集**:① credentialsStatus 里 configured=true 的 ref(源机声明「有」,
|
|
174
|
+
* 但普通备份不带值);② 宿主解密出的归档 ref(`opts.decryptedCredentials`)——
|
|
175
|
+
* 加密备份携带的是 `.credentials.yaml` **原文**,可能含未被任何 settings namespace 引用的 ref
|
|
176
|
+
* (如其它插件自用的 key),只认 ① 会让这些值静默丢掉(真机反馈:导入密钥没生效)。
|
|
177
|
+
*
|
|
178
|
+
* 规则(与同步侧 sync-engine.appendCredentialPlanItems 同口径:**值的有无**优先于本机状态):
|
|
179
|
+
* - **有值** → MissingSecret「随加密备份恢复」:用户显式勾了「导出密钥」,导入即写回;
|
|
180
|
+
* **不因本机已有而跳过**(跳过 = 用户以为密钥导入了、其实没写)。
|
|
181
|
+
* - **无值**(只有 ref 名)→ 本机已配置 → Skip(保留本机值、不再索要补录,用户报告
|
|
182
|
+
* 「已有的重复密钥也会提示」);本机没有 → MissingSecret「需要补录」。
|
|
183
|
+
*
|
|
184
|
+
* 凭据值不可回读,因此无值分支一律不覆盖本机已有值。
|
|
185
|
+
*
|
|
186
|
+
* @param decrypted 归档里解出的 ref→值(仅内存;undefined = 未提供密码/无凭据载荷)
|
|
187
|
+
* @param isConfiguredLocally 目标机是否已配置该 ref(读不到 → false,保守按需补录处理)
|
|
188
|
+
*/
|
|
189
|
+
/**
|
|
190
|
+
* 凭据值「可用」的**唯一判据**(t14 / core-F2 修):必须是非空字符串。
|
|
191
|
+
*
|
|
192
|
+
* 此前三处各判各的 —— 计划期用 `Map.has()`(空串也算「有值」)、执行期用 `value !== ''`、
|
|
193
|
+
* 结果期又用 `Map.has()`。于是「归档里该 ref 的值为空串」时:计划项说「随加密备份恢复」,
|
|
194
|
+
* 执行静默跳过,结果既不报缺失还把该 ref 算成「已从归档写回」—— 用户看到「导入完成」,
|
|
195
|
+
* 密钥其实没写进去(静默 no-op)。三处必须同源,否则该缺陷会再次以另一种形态复发。
|
|
196
|
+
*/
|
|
197
|
+
export function usableSecretValue(decrypted, inputs, ref) {
|
|
198
|
+
const fromArchive = decrypted?.get(ref);
|
|
199
|
+
if (typeof fromArchive === 'string' && fromArchive !== '')
|
|
200
|
+
return true;
|
|
201
|
+
const fromInput = inputs?.[ref];
|
|
202
|
+
return typeof fromInput === 'string' && fromInput !== '';
|
|
203
|
+
}
|
|
204
|
+
export async function buildCredentialPlanItems(items, sections, decrypted, msg, isConfiguredLocally) {
|
|
205
|
+
const creds = sections.get('credentialsStatus');
|
|
206
|
+
const declared = [];
|
|
207
|
+
for (const c of creds?.credentials ?? []) {
|
|
208
|
+
if (typeof c.ref === 'string' && c.ref !== '' && c.configured === true)
|
|
209
|
+
declared.push(c.ref);
|
|
210
|
+
}
|
|
211
|
+
const refs = [...new Set([...declared, ...(decrypted?.keys() ?? [])])];
|
|
212
|
+
const existing = new Set(items.map((i) => i.id));
|
|
213
|
+
for (const ref of refs) {
|
|
214
|
+
const id = `secret:${ref}`;
|
|
215
|
+
if (existing.has(id))
|
|
216
|
+
continue;
|
|
217
|
+
existing.add(id);
|
|
218
|
+
const target = { adapter: 'credentialsStatus', ref };
|
|
219
|
+
if (usableSecretValue(decrypted, undefined, ref)) {
|
|
220
|
+
items.push({
|
|
221
|
+
id,
|
|
222
|
+
kind: 'MissingSecret',
|
|
223
|
+
adapter: 'credentialsStatus',
|
|
224
|
+
description: msg('import.secretFromArchive', { ref }),
|
|
225
|
+
severity: 'warning',
|
|
226
|
+
target,
|
|
227
|
+
});
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (await isConfiguredLocally(ref)) {
|
|
231
|
+
items.push({
|
|
232
|
+
id,
|
|
233
|
+
kind: 'Skip',
|
|
234
|
+
adapter: 'credentialsStatus',
|
|
235
|
+
description: msg('import.secretAlreadyConfigured', { ref }),
|
|
236
|
+
severity: 'info',
|
|
237
|
+
target,
|
|
238
|
+
});
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
items.push({
|
|
242
|
+
id,
|
|
243
|
+
kind: 'MissingSecret',
|
|
244
|
+
adapter: 'credentialsStatus',
|
|
245
|
+
description: msg('import.secretMissingDesc', { ref }),
|
|
246
|
+
severity: 'warning',
|
|
247
|
+
target,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/* ---------------- F4 删除墓碑过滤(纯函数,可独立测试) ---------------- */
|
|
252
|
+
/**
|
|
253
|
+
* 按删除墓碑过滤计划项(F4):
|
|
254
|
+
* - 条目级:插件(Install/Update/Conflict)、技能(skills 文件)、文件类条目(agentPresets 等)命中墓碑 → 剔除;
|
|
255
|
+
* - 分区级:墓碑 kind='section' 且 id=分区 id → 整分区跳过(仅记一条 skipped)。
|
|
256
|
+
* 返回过滤后的 items 与被跳过清单(纯函数:不读文件、不改入参)。
|
|
257
|
+
*/
|
|
258
|
+
export function applyTombstoneFilter(items, tombstones) {
|
|
259
|
+
if (tombstones.length === 0)
|
|
260
|
+
return { items, skipped: [] };
|
|
261
|
+
const tombstonedSections = new Set(tombstones.filter((t) => t.kind === 'section').map((t) => t.id));
|
|
262
|
+
const skipped = [];
|
|
263
|
+
const out = [];
|
|
264
|
+
const reportedSections = new Set();
|
|
265
|
+
for (const item of items) {
|
|
266
|
+
// 分区级墓碑优先:整个分区不出现(含 Skip 等非写入项,UI 提示「该分区已删除」)
|
|
267
|
+
if (tombstonedSections.has(item.adapter)) {
|
|
268
|
+
if (!reportedSections.has(item.adapter)) {
|
|
269
|
+
skipped.push({ kind: 'section', id: item.adapter, adapter: item.adapter });
|
|
270
|
+
reportedSections.add(item.adapter);
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const key = tombstoneKeyForItem(item);
|
|
275
|
+
if (key !== null && isTombstoned(key.kind, key.id, tombstones)) {
|
|
276
|
+
skipped.push({ kind: key.kind, id: key.id, adapter: item.adapter });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
out.push(item);
|
|
280
|
+
}
|
|
281
|
+
return { items: out, skipped };
|
|
282
|
+
}
|
|
283
|
+
/** PlanItem → 墓碑键({kind,id})映射;非「将写入」的条目(Skip 等)与配置类项返回 null 不过滤 */
|
|
284
|
+
export function tombstoneKeyForItem(item) {
|
|
285
|
+
// 插件本体(plugin:<pkg>):Install / Update / Conflict 均视为「将写入该插件」→ 命中墓碑剔除;
|
|
286
|
+
// issue #95 的 Create = 「把 host 自带的层声明进 dsh.profile.bundles」,同样是写入该插件,
|
|
287
|
+
// 必须一起被墓碑剔除(用户删过的插件不得被一份旧备份重新声明回来)。
|
|
288
|
+
// patch: 行与 plugins:pnpm-workspace 是配置类项,不按插件墓碑过滤(分区级墓碑可覆盖)。
|
|
289
|
+
if (item.adapter === 'plugins' && item.id.startsWith('plugin:')) {
|
|
290
|
+
if (item.kind !== 'Install' && item.kind !== 'Update' && item.kind !== 'Conflict' && item.kind !== 'Create')
|
|
291
|
+
return null;
|
|
292
|
+
return { kind: 'plugin', id: item.id.slice('plugin:'.length) };
|
|
293
|
+
}
|
|
294
|
+
// 文件类分区条目(id = <分区>:<相对路径>):skills → skill 墓碑;其余文件类(agentPresets/
|
|
295
|
+
// agentInstructions/pluginFiles/sessions/self)→ 通用 file 墓碑(id 为相对路径)。
|
|
296
|
+
if (item.kind === 'Create' || item.kind === 'Update' || item.kind === 'Conflict') {
|
|
297
|
+
const prefix = `${item.adapter}:`;
|
|
298
|
+
if (item.adapter === 'skills')
|
|
299
|
+
return { kind: 'skill', id: item.id.slice(prefix.length) };
|
|
300
|
+
if (isFileSection(item.adapter))
|
|
301
|
+
return { kind: 'file', id: item.id.slice(prefix.length) };
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
//# sourceMappingURL=import-planning.js.map
|
package/lib/core/journal.d.ts
CHANGED
|
@@ -21,6 +21,18 @@ export declare const ALLOWED_TRANSITIONS: Record<JournalState, JournalState[]>;
|
|
|
21
21
|
export declare function isValidTransition(from: JournalState, to: JournalState): boolean;
|
|
22
22
|
export declare function isTerminalState(s: JournalState): boolean;
|
|
23
23
|
export type StepStatus = 'planned' | 'done' | 'failed' | 'skipped' | 'attention';
|
|
24
|
+
/**
|
|
25
|
+
* 指纹哨兵:「该路径**本就不该存在**」(删除类动作的期望态)。
|
|
26
|
+
*
|
|
27
|
+
* 为什么需要:文件类 step 的 beforeFp/afterFp 一直是「文件内容 sha256」,而**删除**动作的正确
|
|
28
|
+
* 终态是「文件不存在」—— 没有任何 sha256 能表达它,于是删除项在 crash 后永远无法自证已应用
|
|
29
|
+
* (reconcile 只能判 none → NEEDS_ATTENTION)。本哨兵把「应不存在」变成可比较的值:
|
|
30
|
+
* - beforeFp = ABSENT_FINGERPRINT ⇒ 「留点时不在」
|
|
31
|
+
* - afterFp = ABSENT_FINGERPRINT ⇒ 「做完后不在」
|
|
32
|
+
* 判定只有一处(phase3-host 的 verifyStepFingerprint);importer 的指纹永远是十六进制 sha256
|
|
33
|
+
* 或 null,因此本哨兵对导入链路零影响。
|
|
34
|
+
*/
|
|
35
|
+
export declare const ABSENT_FINGERPRINT = "absent";
|
|
24
36
|
export interface JournalStep {
|
|
25
37
|
adapter: string;
|
|
26
38
|
ref: string;
|
package/lib/core/journal.js
CHANGED
|
@@ -71,6 +71,18 @@ export function isValidTransition(from, to) {
|
|
|
71
71
|
export function isTerminalState(s) {
|
|
72
72
|
return TERMINAL_STATES.has(s);
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* 指纹哨兵:「该路径**本就不该存在**」(删除类动作的期望态)。
|
|
76
|
+
*
|
|
77
|
+
* 为什么需要:文件类 step 的 beforeFp/afterFp 一直是「文件内容 sha256」,而**删除**动作的正确
|
|
78
|
+
* 终态是「文件不存在」—— 没有任何 sha256 能表达它,于是删除项在 crash 后永远无法自证已应用
|
|
79
|
+
* (reconcile 只能判 none → NEEDS_ATTENTION)。本哨兵把「应不存在」变成可比较的值:
|
|
80
|
+
* - beforeFp = ABSENT_FINGERPRINT ⇒ 「留点时不在」
|
|
81
|
+
* - afterFp = ABSENT_FINGERPRINT ⇒ 「做完后不在」
|
|
82
|
+
* 判定只有一处(phase3-host 的 verifyStepFingerprint);importer 的指纹永远是十六进制 sha256
|
|
83
|
+
* 或 null,因此本哨兵对导入链路零影响。
|
|
84
|
+
*/
|
|
85
|
+
export const ABSENT_FINGERPRINT = 'absent';
|
|
74
86
|
export function createJournalEntry(operationType, lockCtx, now) {
|
|
75
87
|
return {
|
|
76
88
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
package/lib/core/messages.d.ts
CHANGED
|
@@ -390,6 +390,7 @@ export declare const zh: {
|
|
|
390
390
|
readonly 'sync.git.repoUnreachable': '仓库不可达或认证失败: {url}';
|
|
391
391
|
readonly 'sync.git.workDirNotRepo': 'workDir 不是 git 仓库(缺少 .git)且目录非空,无法 clone: {dir}';
|
|
392
392
|
readonly 'sync.git.pullFailed': 'git pull 失败: {err}';
|
|
393
|
+
readonly 'sync.git.workdirBusy': '同步工作副本 {dir} 正被另一个同步任务占用(另一个 DSH 实例,或上一轮同步还没结束):已等待超时。请稍后重试。';
|
|
393
394
|
readonly 'sync.git.hostKeyUnverified': 'SSH 主机密钥校验失败:本机 known_hosts 里没有 {host} 的密钥(或密钥已变更),git 因此拒绝连接。请先核对主机指纹,再在本机执行 ssh-keyscan -p <端口> {host} >> ~/.ssh/known_hosts(或直接 ssh {host} 按提示确认)后重试 —— 插件不会自动信任任何主机。';
|
|
394
395
|
readonly 'sync.git.hostKeyUnverifiedNoHost': 'SSH 主机密钥校验失败:本机 known_hosts 里没有该主机的密钥(或密钥已变更),git 因此拒绝连接。请先核对主机指纹,再在本机用 ssh-keyscan 把该主机写入 ~/.ssh/known_hosts(或直接 ssh 该主机按提示确认)后重试 —— 插件不会自动信任任何主机。';
|
|
395
396
|
readonly 'sync.git.historyDiverged': '本地工作副本 {dir} 与远端不是同一条历史(远端换了仓库,或远端历史被重写):已拒绝合并。请把仓库地址改回原仓库,或在「同步 → 通道配置」里重建工作副本。';
|
|
@@ -431,6 +432,7 @@ export declare const zh: {
|
|
|
431
432
|
readonly 'market.repoUnreachable': '市场仓库不可达: {url}';
|
|
432
433
|
readonly 'market.cloneFailed': '市场仓库拉取(clone)失败: {err}';
|
|
433
434
|
readonly 'market.pullFailed': '市场仓库同步(pull)失败: {err}';
|
|
435
|
+
readonly 'market.workdirBusy': '市场工作副本 {dir} 正被另一个任务占用(读取或上传还没结束):已等待超时。请稍后重试。';
|
|
434
436
|
readonly 'market.workDirNotRepo': '市场工作目录非空且不是 git 仓库,无法 clone: {dir}';
|
|
435
437
|
readonly 'market.missingIndex': '市场缺少 index.json: {url}';
|
|
436
438
|
readonly 'market.missingManifest': '条目 {id} 缺少 manifest.json';
|
|
@@ -467,6 +469,21 @@ export declare const zh: {
|
|
|
467
469
|
readonly 'command.rollback.segmentFacts': '段:{facts}';
|
|
468
470
|
readonly 'command.rollback.rewindFailed': '回退失败({code}):{id}。{detail}';
|
|
469
471
|
readonly 'command.rollback.mutationLocked': '当前不能写({reason}),回退未执行:{id}。请先解除 SAFE MODE 或等其它任务结束。';
|
|
472
|
+
readonly 'restore.resume.noActive': '没有可续跑的恢复记录';
|
|
473
|
+
readonly 'restore.resume.terminal': '恢复记录 {state} 不能自动续跑(只有「写了几个动作后崩掉」的 APPLYING 才可续)';
|
|
474
|
+
readonly 'restore.resume.wrongOperation': '这条记录属于另一类操作({operationType}),不是恢复';
|
|
475
|
+
readonly 'restore.resume.rollbackInFlight': '上一次恢复已开始回滚({count} 项),不再向前续跑';
|
|
476
|
+
readonly 'restore.resume.noSnapshot': '记录绑定的快照({actual})不是本次要恢复的快照({expected})';
|
|
477
|
+
readonly 'restore.resume.planDrift': '当前计划与崩溃那次登记的步骤对不上:{detail}';
|
|
478
|
+
readonly 'restore.resume.untrustedStep': '崩溃记录里有无法信任的步骤:{detail}';
|
|
479
|
+
readonly 'restore.resume.externalUnproven': '含无法自证的插件卸载({detail}),请到「事故恢复」里处理';
|
|
480
|
+
readonly 'restore.resume.stepAttention': '{count} 个动作上次未成功({detail}),不自动续跑';
|
|
481
|
+
readonly 'restore.resume.fingerprintDrift': '{path} 的内容既不是执行前也不是执行后({detail}),不自动续跑';
|
|
482
|
+
readonly 'restore.resume.detected': '检测到未完成的恢复 {operationId}:已完成 {done} 项、待做 {todo} 项,本次自动续跑';
|
|
483
|
+
readonly 'restore.resumedSkip': '续跑跳过(上次已完成):{description}';
|
|
484
|
+
readonly 'core.phase3.resumeMissing': '续跑失败:找不到 active journal {opId}(可能已被恢复面板收敛)';
|
|
485
|
+
readonly 'core.phase3.resumeTerminal': '续跑失败:journal {opId} 已处于终态 {state}';
|
|
486
|
+
readonly 'core.phase3.resumeWrongType': '续跑失败:journal {opId} 的操作类型是 {actual},本次要求 {expected}';
|
|
470
487
|
};
|
|
471
488
|
export declare const en: Record<keyof typeof zh, string>;
|
|
472
489
|
/**
|
package/lib/core/messages.js
CHANGED
|
@@ -384,6 +384,8 @@ export const zh = {
|
|
|
384
384
|
'sync.git.repoUnreachable': '仓库不可达或认证失败: {url}',
|
|
385
385
|
'sync.git.workDirNotRepo': 'workDir 不是 git 仓库(缺少 .git)且目录非空,无法 clone: {dir}',
|
|
386
386
|
'sync.git.pullFailed': 'git pull 失败: {err}',
|
|
387
|
+
// 工作副本锁等锁超时(并发同步 / 另一个 DSH 实例正在用同一个工作副本)
|
|
388
|
+
'sync.git.workdirBusy': '同步工作副本 {dir} 正被另一个同步任务占用(另一个 DSH 实例,或上一轮同步还没结束):已等待超时。请稍后重试。',
|
|
387
389
|
// issue #79:主机密钥与「换仓库地址」两类失败此前只透出 git 原始 stderr,用户无从下手
|
|
388
390
|
'sync.git.hostKeyUnverified': 'SSH 主机密钥校验失败:本机 known_hosts 里没有 {host} 的密钥(或密钥已变更),git 因此拒绝连接。请先核对主机指纹,再在本机执行 ssh-keyscan -p <端口> {host} >> ~/.ssh/known_hosts(或直接 ssh {host} 按提示确认)后重试 —— 插件不会自动信任任何主机。',
|
|
389
391
|
'sync.git.hostKeyUnverifiedNoHost': 'SSH 主机密钥校验失败:本机 known_hosts 里没有该主机的密钥(或密钥已变更),git 因此拒绝连接。请先核对主机指纹,再在本机用 ssh-keyscan 把该主机写入 ~/.ssh/known_hosts(或直接 ssh 该主机按提示确认)后重试 —— 插件不会自动信任任何主机。',
|
|
@@ -430,6 +432,7 @@ export const zh = {
|
|
|
430
432
|
'market.repoUnreachable': '市场仓库不可达: {url}',
|
|
431
433
|
'market.cloneFailed': '市场仓库拉取(clone)失败: {err}',
|
|
432
434
|
'market.pullFailed': '市场仓库同步(pull)失败: {err}',
|
|
435
|
+
'market.workdirBusy': '市场工作副本 {dir} 正被另一个任务占用(读取或上传还没结束):已等待超时。请稍后重试。',
|
|
433
436
|
'market.workDirNotRepo': '市场工作目录非空且不是 git 仓库,无法 clone: {dir}',
|
|
434
437
|
'market.missingIndex': '市场缺少 index.json: {url}',
|
|
435
438
|
'market.missingManifest': '条目 {id} 缺少 manifest.json',
|
|
@@ -468,6 +471,22 @@ export const zh = {
|
|
|
468
471
|
'command.rollback.segmentFacts': '段:{facts}',
|
|
469
472
|
'command.rollback.rewindFailed': '回退失败({code}):{id}。{detail}',
|
|
470
473
|
'command.rollback.mutationLocked': '当前不能写({reason}),回退未执行:{id}。请先解除 SAFE MODE 或等其它任务结束。',
|
|
474
|
+
// ---------- 恢复续跑(崩溃后再点一次恢复 = 接着做;判定见 core/restore-resume.ts) ----------
|
|
475
|
+
'restore.resume.noActive': '没有可续跑的恢复记录',
|
|
476
|
+
'restore.resume.terminal': '恢复记录 {state} 不能自动续跑(只有「写了几个动作后崩掉」的 APPLYING 才可续)',
|
|
477
|
+
'restore.resume.wrongOperation': '这条记录属于另一类操作({operationType}),不是恢复',
|
|
478
|
+
'restore.resume.rollbackInFlight': '上一次恢复已开始回滚({count} 项),不再向前续跑',
|
|
479
|
+
'restore.resume.noSnapshot': '记录绑定的快照({actual})不是本次要恢复的快照({expected})',
|
|
480
|
+
'restore.resume.planDrift': '当前计划与崩溃那次登记的步骤对不上:{detail}',
|
|
481
|
+
'restore.resume.untrustedStep': '崩溃记录里有无法信任的步骤:{detail}',
|
|
482
|
+
'restore.resume.externalUnproven': '含无法自证的插件卸载({detail}),请到「事故恢复」里处理',
|
|
483
|
+
'restore.resume.stepAttention': '{count} 个动作上次未成功({detail}),不自动续跑',
|
|
484
|
+
'restore.resume.fingerprintDrift': '{path} 的内容既不是执行前也不是执行后({detail}),不自动续跑',
|
|
485
|
+
'restore.resume.detected': '检测到未完成的恢复 {operationId}:已完成 {done} 项、待做 {todo} 项,本次自动续跑',
|
|
486
|
+
'restore.resumedSkip': '续跑跳过(上次已完成):{description}',
|
|
487
|
+
'core.phase3.resumeMissing': '续跑失败:找不到 active journal {opId}(可能已被恢复面板收敛)',
|
|
488
|
+
'core.phase3.resumeTerminal': '续跑失败:journal {opId} 已处于终态 {state}',
|
|
489
|
+
'core.phase3.resumeWrongType': '续跑失败:journal {opId} 的操作类型是 {actual},本次要求 {expected}',
|
|
471
490
|
};
|
|
472
491
|
export const en = {
|
|
473
492
|
// ---------- export ----------
|
|
@@ -854,6 +873,7 @@ export const en = {
|
|
|
854
873
|
'sync.git.repoUnreachable': 'Repository unreachable or authentication failed: {url}',
|
|
855
874
|
'sync.git.workDirNotRepo': 'workDir is not a git repository (no .git) and the directory is not empty; cannot clone: {dir}',
|
|
856
875
|
'sync.git.pullFailed': 'git pull failed: {err}',
|
|
876
|
+
'sync.git.workdirBusy': 'The sync working copy {dir} is in use by another sync task (another DSH instance, or a previous sync that has not finished yet): the wait timed out. Please retry later.',
|
|
857
877
|
// issue #79: host-key and "repository address changed" failures previously leaked raw git stderr
|
|
858
878
|
'sync.git.hostKeyUnverified': 'SSH host key verification failed: this machine has no key for {host} in known_hosts (or the key changed), so git refused to connect. Verify the host fingerprint first, then run ssh-keyscan -p <port> {host} >> ~/.ssh/known_hosts on this machine (or just ssh {host} and confirm the prompt), then retry — the plugin never trusts a host automatically.',
|
|
859
879
|
'sync.git.hostKeyUnverifiedNoHost': 'SSH host key verification failed: this machine has no key for that host in known_hosts (or the key changed), so git refused to connect. Verify the host fingerprint first, then add the host to ~/.ssh/known_hosts with ssh-keyscan (or just ssh the host and confirm the prompt), then retry — the plugin never trusts a host automatically.',
|
|
@@ -899,6 +919,7 @@ export const en = {
|
|
|
899
919
|
'market.repoUnreachable': 'Marketplace repository unreachable: {url}',
|
|
900
920
|
'market.cloneFailed': 'Marketplace repository clone failed: {err}',
|
|
901
921
|
'market.pullFailed': 'Marketplace repository sync (pull) failed: {err}',
|
|
922
|
+
'market.workdirBusy': 'The marketplace working copy {dir} is in use by another task (a read or upload is still running); timed out waiting. Please retry shortly.',
|
|
902
923
|
'market.workDirNotRepo': 'Marketplace working directory is not a git repo and is not empty; cannot clone: {dir}',
|
|
903
924
|
'market.missingIndex': 'Marketplace is missing index.json: {url}',
|
|
904
925
|
'market.missingManifest': 'Item {id} is missing manifest.json',
|
|
@@ -937,6 +958,22 @@ export const en = {
|
|
|
937
958
|
'command.rollback.segmentFacts': 'segments: {facts}',
|
|
938
959
|
'command.rollback.rewindFailed': 'Rewind failed ({code}): {id}. {detail}',
|
|
939
960
|
'command.rollback.mutationLocked': 'Writing is currently blocked ({reason}); the rewind did not run: {id}. Clear SAFE MODE or wait for the running task to finish.',
|
|
961
|
+
// ---------- restore resume (crash then click restore again = continue; see core/restore-resume.ts) ----------
|
|
962
|
+
'restore.resume.noActive': 'No restorable recovery to resume',
|
|
963
|
+
'restore.resume.terminal': 'Recovery record {state} cannot auto-resume (only an APPLYING run that crashed mid-way can)',
|
|
964
|
+
'restore.resume.wrongOperation': 'That record belongs to another operation type ({operationType}), not a restore',
|
|
965
|
+
'restore.resume.rollbackInFlight': 'The previous restore already started rolling back ({count} item(s)); not resuming forward',
|
|
966
|
+
'restore.resume.noSnapshot': 'The record is bound to snapshot {actual}, not the snapshot being restored ({expected})',
|
|
967
|
+
'restore.resume.planDrift': 'The current plan does not line up with the steps registered by the crashed run: {detail}',
|
|
968
|
+
'restore.resume.untrustedStep': 'The crash record contains a step that cannot be trusted: {detail}',
|
|
969
|
+
'restore.resume.externalUnproven': 'Contains a plugin uninstall that cannot be self-proven ({detail}); handle it in Incident recovery',
|
|
970
|
+
'restore.resume.stepAttention': '{count} action(s) did not succeed last time ({detail}); not auto-resuming',
|
|
971
|
+
'restore.resume.fingerprintDrift': '{path} matches neither the pre-run nor the post-run content ({detail}); not auto-resuming',
|
|
972
|
+
'restore.resume.detected': 'An unfinished restore was found ({operationId}): {done} item(s) done, {todo} to go — resuming automatically',
|
|
973
|
+
'restore.resumedSkip': 'Resume skipped (already applied last time): {description}',
|
|
974
|
+
'core.phase3.resumeMissing': 'Resume failed: active journal {opId} not found (it may have been settled from the recovery panel)',
|
|
975
|
+
'core.phase3.resumeTerminal': 'Resume failed: journal {opId} is already in terminal state {state}',
|
|
976
|
+
'core.phase3.resumeWrongType': 'Resume failed: journal {opId} has operation type {actual}, expected {expected}',
|
|
940
977
|
};
|
|
941
978
|
/**
|
|
942
979
|
* 构造 MsgFunc:en → en 目录(缺键回退 zh);其余 → zh。
|
package/lib/core/model-tools.js
CHANGED
|
@@ -26,6 +26,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
26
26
|
import { join } from 'node:path';
|
|
27
27
|
import { Exporter } from './exporter.js';
|
|
28
28
|
import { listSnapshots, planRestore, restore as executeRestore } from './restore.js';
|
|
29
|
+
import { probeRestoreResume, runRestoreWithResume } from './restore-resume.js';
|
|
29
30
|
import { createEncryptionProvider } from '../security/encryption.js';
|
|
30
31
|
import { SECTION_IDS } from '../schema/config.js';
|
|
31
32
|
import { parseSyncChannel, readFullSyncConfig, readSyncConfigFor, SYNC_CHANNELS } from '../sync/sync-config.js';
|
|
@@ -162,11 +163,22 @@ export function createModelTools(deps) {
|
|
|
162
163
|
};
|
|
163
164
|
}
|
|
164
165
|
// Step 3 P0-A:真实 model-restore 在已持锁下创建 journal(不 double-acquire)
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
// ctx = journal 上下文(可选):交给 restore() 逐动作 WAL,崩溃后 reconcile 可判进度
|
|
167
|
+
const doRestore = async (ctx) => executeRestore(ctx === undefined ? restoreOpts : { ...restoreOpts, journal: ctx });
|
|
168
|
+
const phase3 = deps.host.phase3Recovery;
|
|
169
|
+
// 续跑探测(锁前,只读):崩溃后再跑一次同一快照的恢复时,「同一个恢复崩在中途」正是 SAFE MODE
|
|
170
|
+
// 在等的结论 —— 判得出来就放过这道门;真正写盘的决定在锁内由 runRestoreWithResume 再判一次。
|
|
171
|
+
const resumeProbe = phase3 === undefined
|
|
172
|
+
? null
|
|
173
|
+
: await probeRestoreResume({ store: phase3.store, restoreOpts, allowedOperationTypes: ['model-restore'] });
|
|
174
|
+
const resumeAllowed = resumeProbe !== null && resumeProbe.ok;
|
|
175
|
+
const report = await runWithMutationLock(deps.host.mutationLock, { op: 'model-restore', target: snapshotId, isBlocked: () => (deps.host.safeModeIsBlocked?.() ?? false) && !resumeAllowed }, async (lockCtx) => {
|
|
176
|
+
if (phase3 !== undefined && lockCtx !== null) {
|
|
177
|
+
const out = await runRestoreWithResume({
|
|
178
|
+
store: phase3.store, restoreOpts, operationType: 'model-restore',
|
|
179
|
+
allowedOperationTypes: ['model-restore'], lockCtx, phase3,
|
|
180
|
+
});
|
|
181
|
+
return out.report;
|
|
170
182
|
}
|
|
171
183
|
return doRestore();
|
|
172
184
|
});
|
|
@@ -165,6 +165,27 @@ export declare class Phase3Recovery {
|
|
|
165
165
|
operationId: string;
|
|
166
166
|
result: T;
|
|
167
167
|
}>;
|
|
168
|
+
/**
|
|
169
|
+
* 续跑一条已存在的 active journal(崩溃后重做被中断的恢复,见 core/restore-resume.ts 的判定)。
|
|
170
|
+
*
|
|
171
|
+
* 与 runJournaled 的差别:**不 create、不做 active ≤ 1 门禁** —— 要续的正是那条 active 记录;
|
|
172
|
+
* 只有在「上次崩溃前已经写过至少一个动作」这一点被逐项证明后,调用方才允许走到这里。
|
|
173
|
+
* 所有 reject 分支都抛错(调用方负责转成用户可见的「不续跑」原因),绝不静默重跑。
|
|
174
|
+
*/
|
|
175
|
+
resumeJournaled<T>(opts: {
|
|
176
|
+
operationId: string;
|
|
177
|
+
operationType: string;
|
|
178
|
+
lockCtx: MutationLockContext;
|
|
179
|
+
fn: (ctx?: JournalRunContext) => Promise<T>;
|
|
180
|
+
}): Promise<{
|
|
181
|
+
operationId: string;
|
|
182
|
+
result: T;
|
|
183
|
+
}>;
|
|
184
|
+
/**
|
|
185
|
+
* runJournaled / resumeJournaled 共用的收尾:成功推进终态、失败置 SAFE MODE 并分类 durable 状态。
|
|
186
|
+
* body 抛错即表示「副作用阶段失败」,由本方法决定 NEEDS_ATTENTION / 保持非终态 / ROLLED_BACK。
|
|
187
|
+
*/
|
|
188
|
+
private runJournalTail;
|
|
168
189
|
/**
|
|
169
190
|
* 把「引擎已就地回滚」写成 durable 终态 ROLLED_BACK(审计 P0-3)。
|
|
170
191
|
*
|