zen-gitsync 2.17.1 → 2.17.2

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.
@@ -0,0 +1,708 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 配置分文件存储(2026-09-18 第二轮)。
16
+ //
17
+ // 为什么拆:config.json 里 projects 占全文件 93%(605KB 中的 563KB),而 projects 里
18
+ // 又有 82% 是画布 flowData。于是任何一次琐碎写入 —— 改主题、拖一次布局比例、记一条
19
+ // 最近目录 —— 都要重写整份 605KB,并全量复制一份同样大小的 .bak(实测 save 耗时
20
+ // 45~66ms,其中大头就是这个 .bak 复制)。而单条画布数据其实只有 10~20KB。
21
+ //
22
+ // 拆成:
23
+ // config.json 全局 theme/locale/models/ui/recentDirectories
24
+ // projects/<fileId>.json 单项目配置 + 画布顺序(id 列表)
25
+ // orchestration/<fileId>/<orchId>.json 每条画布一个文件
26
+ //
27
+ // 对外仍然暴露"旧的单对象形状":config.js 读的时候组装回去、写的时候拆开落盘,
28
+ // 所以所有调用点(loadConfig / saveConfig / readRawConfigFile / writeRawConfigFile)
29
+ // 一行都不用改。这一点很重要 —— 全仓只有 config.js 一个模块碰 raw.projects。
30
+ //
31
+ // 三条铁律(与 dataDirMigration 一致):
32
+ // 1. **幂等**:`.split-migrated` 标记 + 每次校验 config.json 是否又出现内联 projects
33
+ // (用户可能从备份恢复了旧文件)。
34
+ // 2. **永不抛错**:拆分失败就整体退回内联模式,应用照常能读写配置。
35
+ // 3. **只搬不删**:拆分**全部成功并回读校验通过**之前,绝不从 config.json 摘掉
36
+ // projects;旧文件先整份复制进 `_migration-backup-<ts>/` 留档。
37
+ //
38
+ // ⚠️ 多进程一致性靠 config.json 的 mtime+size(见 config.js 的 isRawConfigCacheFresh)。
39
+ // 所以任何一次写入都**必须**重写 config.json,否则别的实例改了某个项目文件、
40
+ // 本实例的缓存签名不变 → 一直读到旧值。这是拆分带来的新约束,别优化掉。
41
+ import fs from 'node:fs/promises';
42
+ import path from 'node:path';
43
+ import crypto from 'node:crypto';
44
+ import logger from './ui/server/utils/logger.js';
45
+ import {
46
+ DATA_DIR,
47
+ CONFIG_FILE,
48
+ PROJECTS_DIR,
49
+ ORCHESTRATION_DIR,
50
+ SPLIT_MIGRATION_MARKER,
51
+ } from './paths.js';
52
+ import { atomicWriteText, ensureDir, unlinkIfExists, fileExists, dirExists } from './fsAtomic.js';
53
+
54
+ const SPLIT_VERSION = 1;
55
+
56
+ // ── 文件名映射 ────────────────────────────────────────────────
57
+
58
+ /**
59
+ * 项目键(归一化过的绝对路径)→ 项目文件名(不含扩展名)。
60
+ *
61
+ * 形状:`<basename 的可读 slug>-<key 的 8 位 sha1>`
62
+ * d:\xz_workspace\github_workspace\zen-gitsync → zen-gitsync-4f3a91bc
63
+ *
64
+ * slug 只是为了"人在目录里一眼认出是哪个项目"(可读性),**唯一性完全由哈希保证** ——
65
+ * slug 会撞车(比如 `...\a\zen-gitsync` 与 `...\b\zen-gitsync` 的 basename 一样),
66
+ * 而且 Windows 路径里 `\ / : * ? " < > |` 都不能进文件名,直接拿路径当文件名不可行。
67
+ * 哈希取自**完整归一化键**,所以不同路径一定不同名。
68
+ */
69
+ export function projectFileId(key) {
70
+ const norm = String(key ?? '');
71
+ const hash = crypto.createHash('sha1').update(norm, 'utf8').digest('hex').slice(0, 8);
72
+ // 手写切分而不是 path.basename:测试里可能用 Windows 风格键跑在 POSIX 上,
73
+ // 那时 path.basename 认不出 `\` 分隔符。
74
+ const parts = norm.split(/[\\/]+/).filter(Boolean);
75
+ const base = parts[parts.length - 1] || 'root';
76
+ const slug = base.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'root';
77
+ return `${slug}-${hash}`;
78
+ }
79
+
80
+ export function projectFilePath(key) {
81
+ return path.join(PROJECTS_DIR, `${projectFileId(key)}.json`);
82
+ }
83
+
84
+ export function orchestrationDir(key) {
85
+ return path.join(ORCHESTRATION_DIR, projectFileId(key));
86
+ }
87
+
88
+ /**
89
+ * 画布文件名。实测 id 形状是 `orch_<毫秒>_<随机>` 全安全字符,但仍然兜一层:
90
+ * 非法字符换成 `-`,并附 8 位哈希防撞(替换本身是可能撞的)。
91
+ */
92
+ export function orchestrationFileId(orchId) {
93
+ const raw = String(orchId ?? '');
94
+ if (/^[A-Za-z0-9._-]+$/.test(raw) && raw.length <= 80) return raw;
95
+ const hash = crypto.createHash('sha1').update(raw, 'utf8').digest('hex').slice(0, 8);
96
+ const slug = raw.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'orch';
97
+ return `${slug}-${hash}`;
98
+ }
99
+
100
+ export function orchestrationFilePath(key, orchId) {
101
+ return path.join(orchestrationDir(key), `${orchestrationFileId(orchId)}.json`);
102
+ }
103
+
104
+ // ── 差分基线 ──────────────────────────────────────────────────
105
+ //
106
+ // "上次读/写到磁盘的原文",用来跳过未变更的文件。键:
107
+ // project : `p:<fileId>` 值:项目文件原文
108
+ // orch : `o:<fileId>/<id>` 值:画布文件原文
109
+ // 读路径在**重新读实盘**时刷新(所以基线永远反映"我们最近一次看到的磁盘状态"),
110
+ // 写路径写成功后更新。缓存失效不清空 —— 清空只会让下一次写退化成全量重写。
111
+ const _baselines = new Map();
112
+
113
+ // 画布**顺序表**的上次已知值:`fileId → JSON.stringify(有序 id 数组)`。
114
+ //
115
+ // 为什么单独存:清理已删除画布要先 readdir 才知道磁盘上有什么,而 readdir 是每个
116
+ // 项目一次 —— Windows 上 30 个项目光这一项 + mkdir 就要 40ms 上下(实测),把拆分
117
+ // 本该公司省下的写放大又吃回去了。而"顺序表没变"就等价于"没有新增/删除画布",
118
+ // 那磁盘上不可能多出孤儿文件,这次 readdir 纯属白跑。于是用它把 readdir 变成
119
+ // "只在顺序真的变了时才做"。
120
+ const _orderBaselines = new Map();
121
+
122
+ /** 仅供测试:清空差分基线 */
123
+ export function resetSplitCaches() {
124
+ _baselines.clear();
125
+ _orderBaselines.clear();
126
+ _splitActive = null;
127
+ }
128
+
129
+ let _splitActive = null;
130
+
131
+ /** 当前是否处于分文件模式。null = 未知(还没判断过) */
132
+ export function isSplitActive() {
133
+ return _splitActive;
134
+ }
135
+
136
+ async function markerExists() {
137
+ try {
138
+ await fs.access(SPLIT_MIGRATION_MARKER);
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * 标记文件是否存在。
147
+ *
148
+ * 给写路径做"还没读过任何配置就要写"时的兜底判定:标记存在说明分文件模式已经
149
+ * 成功建立过,可以放心按分文件落盘;不存在则按内联写 —— 反正下一次读会把它拆开
150
+ * (幂等),不会丢数据。
151
+ */
152
+ export async function splitMarkerExists() {
153
+ return markerExists();
154
+ }
155
+
156
+ /**
157
+ * 判定"分文件模式是否生效"。
158
+ *
159
+ * 两个条件都要满足:标记存在 **且** config.json 里没有内联 projects。
160
+ * 只看标记不够 —— 用户可能从备份恢复了旧的内联版本,那时必须重新拆一遍,
161
+ * 否则新数据写进分文件、读的时候又优先看内联 → 表现为"改了没生效"。
162
+ */
163
+ export async function resolveSplitMode(inlineObj) {
164
+ const hasInline = inlineObj
165
+ && typeof inlineObj.projects === 'object'
166
+ && inlineObj.projects !== null
167
+ && Object.keys(inlineObj.projects).length > 0;
168
+ if (hasInline) return false;
169
+ if (!(await markerExists())) return false;
170
+ return true;
171
+ }
172
+
173
+ // ── 读 ────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * 一次性读出所有项目的画布文件名:`项目 fileId → [文件名]`。
177
+ *
178
+ * 用 `readdir(recursive)` 一次拿全,而不是"每个项目 readdir 一次" —— Windows 上
179
+ * 单次 readdir 约 1ms,30 个项目就是 30ms,比真正解析数据的开销还大(实测)。
180
+ * 这是纯 I/O 批量化,不改变任何语义。
181
+ */
182
+ async function listOrchestrationFiles() {
183
+ const byFileId = new Map();
184
+ let entries = [];
185
+ try {
186
+ entries = await fs.readdir(ORCHESTRATION_DIR, { recursive: true });
187
+ } catch (err) {
188
+ if (err?.code === 'ENOENT') return byFileId;
189
+ throw err;
190
+ }
191
+ for (const entry of entries) {
192
+ const sep = entry.includes('/') ? '/' : (entry.includes('\\') ? '\\' : null);
193
+ if (!sep) continue; // 顶层的散文件,不属于任何项目
194
+ const idx = entry.indexOf(sep);
195
+ const dirId = entry.slice(0, idx);
196
+ const name = entry.slice(idx + 1);
197
+ if (name.includes(sep)) continue; // 更深一层,不是本项目的直接子文件
198
+ if (!name.endsWith('.json')) continue;
199
+ if (!byFileId.has(dirId)) byFileId.set(dirId, []);
200
+ byFileId.get(dirId).push(name);
201
+ }
202
+ return byFileId;
203
+ }
204
+
205
+ /**
206
+ * 组装某个项目的 orchestrations 数组。
207
+ *
208
+ * 顺序取自项目文件里的 `orchestrationOrder`(id 列表),不靠 readdir 的字母序 ——
209
+ * `orch_<毫秒>_<随机>` 虽然按名字排恰好接近创建顺序,但用户拖拽排序后就对不上了。
210
+ * 磁盘上存在但不在 id 列表里的画布(异常残渣)按名字序**追加到末尾**,宁可顺序丑
211
+ * 一点也不能让用户的画布凭空消失。
212
+ *
213
+ * `names` 由 `listOrchestrationFiles()` 批量提供,避免每个项目各 readdir 一次。
214
+ */
215
+ async function readOrchestrations(key, fileId, order, errors, names) {
216
+ const dir = orchestrationDir(key);
217
+ if (!names) return [];
218
+
219
+ const byId = new Map();
220
+ for (const name of names) {
221
+ const filePath = path.join(dir, name);
222
+ let text;
223
+ let parsed;
224
+ try {
225
+ text = await fs.readFile(filePath, 'utf-8');
226
+ parsed = JSON.parse(text);
227
+ } catch (err) {
228
+ // 单条画布坏掉不该拖垮整个配置读取:如实报告并跳过,其余画布照常可用
229
+ errors.push(`画布文件损坏 ${name}: ${err?.code || err?.message}`);
230
+ continue;
231
+ }
232
+ const id = parsed && typeof parsed.id === 'string' ? parsed.id : null;
233
+ if (!id) {
234
+ errors.push(`画布文件缺少 id,已跳过: ${name}`);
235
+ continue;
236
+ }
237
+ byId.set(id, parsed);
238
+ _baselines.set(`o:${fileId}/${id}`, text);
239
+ }
240
+
241
+ const ordered = [];
242
+ const taken = new Set();
243
+ if (Array.isArray(order)) {
244
+ for (const id of order) {
245
+ if (typeof id !== 'string' || taken.has(id)) continue;
246
+ const orch = byId.get(id);
247
+ if (!orch) continue; // 顺序表里有、文件没有 → 跳过,不是错误
248
+ ordered.push(orch);
249
+ taken.add(id);
250
+ }
251
+ }
252
+ const leftovers = [...byId.keys()].filter((id) => !taken.has(id));
253
+ if (leftovers.length) {
254
+ leftovers.sort();
255
+ for (const id of leftovers) ordered.push(byId.get(id));
256
+ }
257
+ return ordered;
258
+ }
259
+
260
+ /**
261
+ * 读取整个分文件存储,组装成与旧单文件格式**完全同形**的对象。
262
+ *
263
+ * 返回 `{ ok, projects, errors }`。`ok:false` 表示分文件存储不可用(目录建不出来等),
264
+ * 调用方应退回内联模式 —— 但注意"某个项目文件坏了"不算不可用,那种情况跳过单项、
265
+ * 其余照常返回(与单文件时代"一个字段坏不该丢全部"的取向一致)。
266
+ */
267
+ export async function readSplitProjects() {
268
+ const projects = {};
269
+ const errors = [];
270
+ let names;
271
+ try {
272
+ names = await fs.readdir(PROJECTS_DIR);
273
+ } catch (err) {
274
+ if (err?.code === 'ENOENT') return { ok: true, projects, errors };
275
+ return { ok: false, projects, errors: [`读取项目目录失败: ${err?.code || err?.message}`] };
276
+ }
277
+
278
+ // 画布文件名一次读全(见 listOrchestrationFiles 的注释:省掉每个项目一次 readdir)
279
+ let orchFiles;
280
+ try {
281
+ orchFiles = await listOrchestrationFiles();
282
+ } catch (err) {
283
+ errors.push(`读取画布目录失败: ${err?.code || err?.message}`);
284
+ orchFiles = new Map();
285
+ }
286
+
287
+ for (const name of names) {
288
+ if (!name.endsWith('.json')) continue; // 跳过 .tmp / .bak
289
+ const filePath = path.join(PROJECTS_DIR, name);
290
+ let text;
291
+ let parsed;
292
+ try {
293
+ text = await fs.readFile(filePath, 'utf-8');
294
+ parsed = JSON.parse(text);
295
+ } catch (err) {
296
+ errors.push(`项目文件损坏 ${name}: ${err?.code || err?.message}`);
297
+ continue;
298
+ }
299
+ const key = parsed && typeof parsed.key === 'string' && parsed.key ? parsed.key : null;
300
+ if (!key) {
301
+ errors.push(`项目文件缺少 key,已跳过: ${name}`);
302
+ continue;
303
+ }
304
+ const fileId = projectFileId(key);
305
+ if (`${fileId}.json` !== name) {
306
+ // 只可能是手工改名/外部工具写进来的。仍按 key 收下(数据比命名重要),
307
+ // 但记一条 —— 下一次写会落到规范名,旧名文件会变成重复项。
308
+ logger.warn(`[configSplit] 项目文件名与键不匹配: ${name} (期望 ${fileId}.json)`);
309
+ }
310
+ const cfg = parsed.config && typeof parsed.config === 'object' && !Array.isArray(parsed.config)
311
+ ? { ...parsed.config }
312
+ : {};
313
+ cfg.orchestrations = await readOrchestrations(
314
+ key, fileId, parsed.orchestrationOrder, errors, orchFiles.get(fileId) || []
315
+ );
316
+ projects[key] = cfg;
317
+ _baselines.set(`p:${fileId}`, text);
318
+ // 顺序基线取"组装出来的有效顺序",而不是文件里存的那份 —— 顺序表里列了但文件
319
+ // 缺失的 id 会被组装丢弃,拿组装结果当基线,下次写才不会被误判成"顺序变了"。
320
+ _orderBaselines.set(fileId, JSON.stringify(cfg.orchestrations.map((o) => o.id)));
321
+ }
322
+
323
+ return { ok: true, projects, errors };
324
+ }
325
+
326
+ // ── 写 ────────────────────────────────────────────────────────
327
+
328
+ /**
329
+ * 把 `raw`(旧的单对象形状)拆开落盘。
330
+ *
331
+ * 契约:
332
+ * - `raw.projects` 里**出现的**项目 →
333
+ * ① 项目文件按需重写(内容与上次一致则跳过);
334
+ * ② 画布数组视为**权威**:磁盘上多出来的画布文件会被删掉(否则被删的画布会复活)。
335
+ * - `raw.projects` 里**没出现的**项目 → 一概不动(写路径不做项目删除,删除走
336
+ * deleteProjectConfig 显式接口)。这条是为了防"某个调用方只加载了一个项目就写回"
337
+ * 把其它项目连带清空 —— 单文件时代这么写会直接丢数据,现在退一步也不会。
338
+ * - 任何一次写入都重写 config.json(见文件头关于多进程一致性的说明)。
339
+ *
340
+ * `stripInlineProjects`(默认 false)是给**迁移**用的开关,含义是"本次写完要顺手把
341
+ * config.json 里的内联 projects 精简掉"。这时内联副本就是唯一数据源,所以只在
342
+ * **每个项目都确实安全落盘**的前提下才允许精简;只要有任何一个项目没写全,就在动
343
+ * config.json 之前刹车并如实报错。不这么做的话,"拆分写了一半失败 → 仍然精简了
344
+ * config.json" = 分文件没写全 + 内联副本被摘掉 = 直接丢数据。
345
+ *
346
+ * 返回统计 `{ projectWrites, projectSkips, orchWrites, orchSkips, orchDeletes, stripped }`。
347
+ */
348
+ export async function writeSplitStore(raw, { stripInlineProjects = false } = {}) {
349
+ const projects = raw && typeof raw.projects === 'object' && raw.projects !== null ? raw.projects : {};
350
+ const stats = {
351
+ projectWrites: 0,
352
+ projectSkips: 0,
353
+ // 因为"本项目本次有落盘失败"而刻意没写的项目数(不是为了省 IO,是为了不留残缺快照)
354
+ projectSkippedUnsafe: 0,
355
+ orchWrites: 0,
356
+ orchSkips: 0,
357
+ orchDeletes: 0,
358
+ stripped: false,
359
+ };
360
+ const errors = [];
361
+ // 没能安全落盘的项目键。只要非空,就绝不能摘掉 config.json 的内联副本。
362
+ const failedProjects = new Set();
363
+
364
+ try {
365
+ await ensureDir(PROJECTS_DIR);
366
+ } catch (err) {
367
+ errors.push(`项目目录不可用 ${PROJECTS_DIR}: ${err?.code || err?.message}`);
368
+ return { stats, errors };
369
+ }
370
+
371
+ for (const [key, cfgRaw] of Object.entries(projects)) {
372
+ const cfg = cfgRaw && typeof cfgRaw === 'object' && !Array.isArray(cfgRaw) ? cfgRaw : {};
373
+ const fileId = projectFileId(key);
374
+ const { orchestrations, ...rest } = cfg;
375
+
376
+ // 1) 画布:先落画布文件,再写项目文件(项目文件里的顺序表要反映"真的写成功了"的集合)
377
+ let order = null;
378
+ if (Array.isArray(orchestrations)) {
379
+ order = [];
380
+ const dir = orchestrationDir(key);
381
+ const desiredIds = orchestrations
382
+ .map((o) => (o && typeof o.id === 'string' && o.id ? o.id : null))
383
+ .filter(Boolean);
384
+ // 顺序表没变 ⇒ 没有新增/删除画布 ⇒ 磁盘上不可能有孤儿文件,这次 readdir 纯属白跑。
385
+ // 这是热路径上最值钱的优化(见 _orderBaselines 的注释)。
386
+ const orderChanged = _orderBaselines.get(fileId) !== JSON.stringify(desiredIds);
387
+
388
+ // 只写内容变了的画布;**目录按需创建** —— mkdir 在 Windows 上约 1ms/次,
389
+ // 30 个项目无脑建目录就吃掉整个写入预算(实测 42ms,比旧实现 605KB 全量重写还亏)。
390
+ let dirReady = false;
391
+ for (const orch of orchestrations) {
392
+ const id = orch && typeof orch.id === 'string' ? orch.id : null;
393
+ if (!id) {
394
+ // 落不了盘的画布:精简内联副本就会把它弄丢 → 该项目整体判为不安全
395
+ errors.push(`画布缺少 id,无法落盘: ${key}`);
396
+ failedProjects.add(key);
397
+ continue;
398
+ }
399
+ order.push(id);
400
+ const bkey = `o:${fileId}/${id}`;
401
+ const text = JSON.stringify(orch, null, 2);
402
+ if (_baselines.get(bkey) === text) {
403
+ stats.orchSkips++;
404
+ continue;
405
+ }
406
+ if (!dirReady) {
407
+ try {
408
+ await ensureDir(dir);
409
+ dirReady = true;
410
+ } catch (err) {
411
+ errors.push(`画布目录不可用 ${fileId}: ${err?.code || err?.message}`);
412
+ failedProjects.add(key);
413
+ break; // 后面每条画布都要写进这个目录,再试也是同样结果
414
+ }
415
+ }
416
+ try {
417
+ await atomicWriteText(orchestrationFilePath(key, id), text);
418
+ _baselines.set(bkey, text);
419
+ stats.orchWrites++;
420
+ } catch (err) {
421
+ // 写失败就把 id 从顺序表里摘掉,免得"顺序表说有、文件没有"造成
422
+ // 项目文件比画布文件更新,重启后顺序对不上
423
+ order.pop();
424
+ errors.push(`写画布失败 ${orchestrationFileId(id)}: ${err?.code || err?.message}`);
425
+ failedProjects.add(key);
426
+ }
427
+ }
428
+
429
+ // 清理已删除的画布。两个条件同时满足才做:
430
+ // ① 顺序表真的变了 —— 否则磁盘上不会有孤儿(省掉每个项目一次 readdir);
431
+ // ② 本项目没失败 —— 失败时不会写项目文件,磁盘上的顺序表还是旧的,
432
+ // 此刻按新顺序删文件会把旧顺序表还引用着的画布删掉 = 丢数据。
433
+ if (orderChanged && !failedProjects.has(key)) {
434
+ let existing = [];
435
+ try {
436
+ existing = await fs.readdir(dir);
437
+ } catch (_) { /* 目录不存在 = 没有可清理的 */ }
438
+ const keep = new Set(desiredIds.map((id) => `${orchestrationFileId(id)}.json`));
439
+ for (const name of existing) {
440
+ if (!name.endsWith('.json') || keep.has(name)) continue;
441
+ try {
442
+ await unlinkIfExists(path.join(dir, name));
443
+ stats.orchDeletes++;
444
+ } catch (err) {
445
+ // 没删掉 = 下次读会把它复活成一条已删画布,也算没落全
446
+ errors.push(`删除画布失败 ${name}: ${err?.code || err?.message}`);
447
+ failedProjects.add(key);
448
+ }
449
+ }
450
+ }
451
+ }
452
+
453
+ // 2) 项目文件。
454
+ //
455
+ // **本项目本次有失败就整个不写**:此时 envelope 里的 orchestrationOrder 是残缺的
456
+ // (落盘失败的画布已被 pop 掉),写出去等于留一份"明知不全"的快照 —— 下次读会
457
+ // 照它把画布显示少了。错误已经如实上报,等下一次写成功时自然修正。
458
+ if (failedProjects.has(key)) {
459
+ stats.projectSkippedUnsafe++;
460
+ } else {
461
+ const envelope = {
462
+ version: SPLIT_VERSION,
463
+ key,
464
+ ...(order ? { orchestrationOrder: order } : {}),
465
+ config: rest,
466
+ };
467
+ const text = JSON.stringify(envelope, null, 2);
468
+ const bkey = `p:${fileId}`;
469
+ if (_baselines.get(bkey) === text) {
470
+ stats.projectSkips++;
471
+ } else {
472
+ try {
473
+ await atomicWriteText(projectFilePath(key), text);
474
+ _baselines.set(bkey, text);
475
+ stats.projectWrites++;
476
+ } catch (err) {
477
+ errors.push(`写项目配置失败 ${fileId}: ${err?.code || err?.message}`);
478
+ failedProjects.add(key);
479
+ }
480
+ }
481
+ // 顺序基线只在这一步之后推进:它必须描述"磁盘上的顺序表现在是什么",
482
+ // 而顺序表正是随项目文件一起落盘的。写失败/跳过了就不动,下次仍会重算。
483
+ if (!failedProjects.has(key) && order) {
484
+ _orderBaselines.set(fileId, JSON.stringify(order));
485
+ }
486
+ }
487
+ }
488
+
489
+ // 3) 全局文件。**必须每次都写**,不只是为了全局字段 —— 它是别的实例判断
490
+ // "磁盘变了没"的唯一签名来源(config.js 只 stat 这一个文件)。
491
+ // 但迁移场景(stripInlineProjects)下,这一步会摘掉唯一的内联副本,必须先确认安全。
492
+ if (stripInlineProjects && failedProjects.size > 0) {
493
+ const detail = [...failedProjects].slice(0, 3).join(', ');
494
+ errors.push(
495
+ `有 ${failedProjects.size} 个项目未安全落盘(${detail}),已放弃精简 config.json 以保住内联副本`
496
+ );
497
+ return { stats, errors };
498
+ }
499
+
500
+ const global = {};
501
+ for (const [k, v] of Object.entries(raw ?? {})) {
502
+ if (k === 'projects') continue;
503
+ global[k] = v;
504
+ }
505
+ await atomicWriteText(CONFIG_FILE, JSON.stringify(global, null, 2));
506
+ stats.stripped = true;
507
+
508
+ _splitActive = true;
509
+ return { stats, errors };
510
+ }
511
+
512
+ // ── 一次性拆分迁移 ─────────────────────────────────────────────
513
+
514
+ async function writeMarker(report) {
515
+ try {
516
+ await fs.writeFile(
517
+ SPLIT_MIGRATION_MARKER,
518
+ JSON.stringify({ version: SPLIT_VERSION, at: new Date().toISOString(), report }, null, 2),
519
+ 'utf-8'
520
+ );
521
+ } catch (err) {
522
+ logger.warn(`[configSplit] 写拆分标记失败: ${err?.code || err?.message}`);
523
+ }
524
+ }
525
+
526
+ /**
527
+ * 把 config.json 里的内联 projects 拆到 projects/ + orchestration/。
528
+ *
529
+ * 流程刻意做成"先写分文件 → 回读校验 → 再精简 config.json":
530
+ * - 分文件写完后**回读计数**,对不上就整体放弃(不精简 config.json),退回内联模式;
531
+ * - 精简前把原始 config.json 整份复制进 `_migration-backup-<ts>/` 留档;
532
+ * - 只有全部成功才写 `.split-migrated` 标记。
533
+ * 永不抛错:任何一步失败都返回 `{ok:false}`,调用方继续用内联的 raw。
534
+ */
535
+ export async function splitInlineConfig(inlineObj, { configPath = CONFIG_FILE } = {}) {
536
+ const inlineProjects = inlineObj?.projects;
537
+ if (!inlineProjects || typeof inlineProjects !== 'object' || Object.keys(inlineProjects).length === 0) {
538
+ // 没有可拆的项目:只补标记,让后续判定能走分文件模式
539
+ await writeMarker({ projects: 0, note: '无内联 projects' });
540
+ return { ok: true, migrated: false, projects: 0 };
541
+ }
542
+
543
+ const keys = Object.keys(inlineProjects);
544
+
545
+ // 留档:整份复制原始 config.json。这是唯一的"拆分前"完整快照,不能省。
546
+ let backupDir = null;
547
+ try {
548
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 12);
549
+ backupDir = path.join(DATA_DIR, `_migration-backup-split-${stamp}`);
550
+ await ensureDir(backupDir);
551
+ if (await fileExists(configPath)) {
552
+ await fs.copyFile(configPath, path.join(backupDir, 'config.json'));
553
+ }
554
+ } catch (err) {
555
+ logger.warn(`[configSplit] 拆分前留档失败,继续(不阻断): ${err?.code || err?.message}`);
556
+ backupDir = null;
557
+ }
558
+
559
+ try {
560
+ const res = await writeSplitStore({ ...inlineObj }, { stripInlineProjects: true });
561
+ if (res.errors.length || !res.stats.stripped) {
562
+ const detail = res.errors.slice(0, 3).join('; ') || 'config.json 未被精简';
563
+ logger.warn(`[configSplit] 拆分写盘未完成(${res.errors.length} 个错误),放弃拆分并退回内联模式: ${detail}`);
564
+ reportSplitFailure(inlineObj, res.errors, backupDir);
565
+ return { ok: false, migrated: false, errors: res.errors };
566
+ }
567
+
568
+ // 回读校验:每个 key 都必须能从分文件里读回来。少一个就整体回退 ——
569
+ // 宁可不拆,也不能让用户的项目配置凭空消失。
570
+ const readBack = await readSplitProjects();
571
+ if (!readBack.ok) {
572
+ reportSplitFailure(inlineObj, readBack.errors, backupDir);
573
+ return { ok: false, migrated: false, errors: readBack.errors };
574
+ }
575
+ const missing = keys.filter((k) => !(k in readBack.projects));
576
+ if (missing.length) {
577
+ const errors = [`回读校验丢失 ${missing.length} 个项目: ${missing.slice(0, 3).join(', ')}`];
578
+ logger.warn(`[configSplit] ${errors[0]} —— 放弃拆分,退回内联模式`);
579
+ reportSplitFailure(inlineObj, errors, backupDir);
580
+ return { ok: false, migrated: false, errors };
581
+ }
582
+
583
+ // 到这里才精简 config.json(由 writeSplitStore 的第 3 步完成,它写的 global
584
+ // 已经不含 projects)。标记最后写,失败也只是下次重跑一遍(幂等)。
585
+ await writeMarker({
586
+ projects: keys.length,
587
+ backup: backupDir ? path.basename(backupDir) : null,
588
+ });
589
+ logger.info(
590
+ `[configSplit] 已把 ${keys.length} 个项目拆到 ${path.basename(PROJECTS_DIR)}/`
591
+ + `(画布 ${res.stats.orchWrites} 个),config.json 只保留全局设置`
592
+ );
593
+ return { ok: true, migrated: true, projects: keys.length };
594
+ } catch (err) {
595
+ const errors = [`拆分异常: ${err?.code || err?.message}`];
596
+ logger.warn(`[configSplit] ${errors[0]} —— 退回内联模式`);
597
+ reportSplitFailure(inlineObj, errors, backupDir);
598
+ return { ok: false, migrated: false, errors };
599
+ }
600
+ }
601
+
602
+ // 回退时只记录,不改任何文件:此时 config.json 还是完整的内联版本,应用照常能用。
603
+ // (splitInlineConfig 内部已经把分文件写了一半,那些文件会留在磁盘上;下次重试
604
+ // 会因为内容一致而全部跳过,不会重复写。)
605
+ let _lastFailure = null;
606
+ function reportSplitFailure(inlineObj, errors, backupDir) {
607
+ _lastFailure = { errors, at: Date.now(), backupDir };
608
+ try {
609
+ logger.warn(
610
+ `[configSplit] 本次退回内联模式(projects=${Object.keys(inlineObj?.projects || {}).length}),`
611
+ + `备份: ${backupDir || '(无)'}`
612
+ );
613
+ } catch (_) { /* ignore */ }
614
+ }
615
+
616
+ /** 供测试/诊断:上一次拆分失败的信息 */
617
+ export function lastSplitFailure() {
618
+ return _lastFailure;
619
+ }
620
+
621
+ /**
622
+ * config.js 的统一入口:给定刚读出来的内联对象,返回"应该用哪个 raw"。
623
+ *
624
+ * { active: true, raw: null } → 分文件模式生效,调用方改用 readSplitProjects 的结果
625
+ * { active: false, raw: obj } → 内联模式,直接用传来的 obj
626
+ *
627
+ * 永不抛错。
628
+ */
629
+ export async function ensureSplitStore(inlineObj, { configPath = CONFIG_FILE } = {}) {
630
+ try {
631
+ if (await resolveSplitMode(inlineObj)) {
632
+ _splitActive = true;
633
+ return { active: true, raw: null };
634
+ }
635
+ // 标记在但 config.json 又变成内联了(用户恢复备份) → 重新拆;首次启动同理。
636
+ const res = await splitInlineConfig(inlineObj, { configPath });
637
+ if (res.ok) {
638
+ _splitActive = true;
639
+ return { active: true, raw: null };
640
+ }
641
+ _splitActive = false;
642
+ return { active: false, raw: inlineObj };
643
+ } catch (err) {
644
+ logger.warn(`[configSplit] 分文件存储初始化失败,退回内联模式: ${err?.code || err?.message}`);
645
+ _splitActive = false;
646
+ return { active: false, raw: inlineObj };
647
+ }
648
+ }
649
+
650
+ /** 显式删除一个项目的全部分文件(写路径刻意不做删除,删除只走这里) */
651
+ export async function deleteProjectConfig(key) {
652
+ const fileId = projectFileId(key);
653
+ const removed = [];
654
+ const errors = [];
655
+ const projFile = projectFilePath(key);
656
+ if (await fileExists(projFile)) {
657
+ try {
658
+ await unlinkIfExists(projFile);
659
+ removed.push(path.basename(projFile));
660
+ } catch (err) {
661
+ errors.push(`删除 ${path.basename(projFile)} 失败: ${err?.code || err?.message}`);
662
+ }
663
+ }
664
+ const dir = orchestrationDir(key);
665
+ if (await dirExists(dir)) {
666
+ try {
667
+ const names = await fs.readdir(dir);
668
+ for (const name of names) {
669
+ try {
670
+ await unlinkIfExists(path.join(dir, name));
671
+ removed.push(`${fileId}/${name}`);
672
+ } catch (err) {
673
+ errors.push(`删除画布 ${name} 失败: ${err?.code || err?.message}`);
674
+ }
675
+ }
676
+ await fs.rmdir(dir).catch(() => {});
677
+ } catch (err) {
678
+ errors.push(`清理画布目录失败: ${err?.code || err?.message}`);
679
+ }
680
+ }
681
+ _baselines.delete(`p:${fileId}`);
682
+ _orderBaselines.delete(fileId);
683
+ for (const k of [..._baselines.keys()]) {
684
+ if (k.startsWith(`o:${fileId}/`)) _baselines.delete(k);
685
+ }
686
+ if (removed.length) {
687
+ logger.info(`[configSplit] 已删除项目 ${fileId} 的 ${removed.length} 个配置文件`);
688
+ }
689
+ return { fileId, removed, errors };
690
+ }
691
+
692
+ export default {
693
+ projectFileId,
694
+ projectFilePath,
695
+ orchestrationFileId,
696
+ orchestrationFilePath,
697
+ orchestrationDir,
698
+ readSplitProjects,
699
+ writeSplitStore,
700
+ splitInlineConfig,
701
+ ensureSplitStore,
702
+ resolveSplitMode,
703
+ splitMarkerExists,
704
+ deleteProjectConfig,
705
+ isSplitActive,
706
+ resetSplitCaches,
707
+ lastSplitFailure,
708
+ };