@inventec/dsh-copilot-auth 1.0.2 → 1.1.1

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/src/host.mjs CHANGED
@@ -1,28 +1,32 @@
1
- // host 半区:cordis 插件。注册 4 条 exact 路由,把内置 github-copilot 的
2
- // OAuth 设备码流(ctx.authorization)暴露给 Web client。零 GitHub 协议代码。
1
+ // host 半区:cordis 插件。注册 6 条 exact 路由,把内置 github-copilot 的
2
+ // OAuth 设备码流(ctx.authorization)暴露给 Web client,并提供「手动刷新可用
3
+ // 模型目录」(数据级目录补丁,pi-ai 代码版本不动;只读 GET /models 适配显式
4
+ // 耦合 pi-ai 0.84.4,详见 CONTEXT.md 与 ADR 0001)。
3
5
  import { existsSync, readFileSync, realpathSync } from "node:fs";
4
6
  import { dirname, join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { fileURLToPath } from "node:url";
5
9
  import { CREDENTIAL_KEY, emptyState, routes } from "./shared.mjs";
10
+ import { readJson, writeJsonAtomic, createMutex } from "./atomic-json.mjs";
11
+ import { mergeCatalog, diffModels, digest, findPiAiInstallation } from "./catalog.mjs";
12
+ import { fetchLatestCatalog } from "./catalog-fetch.mjs";
13
+ import { fetchLiveAvailableModelIds } from "./copilot-models.mjs";
14
+ import { loadState, saveState, freshState, createJournal, advanceJournal, restartMarker, setLastError, clearLastError } from "./state.mjs";
6
15
 
7
16
  export const name = "copilot-auth";
8
17
  export const inject = ["webServer", "authorization", "credentials", "settings", "llm"];
9
18
 
10
- // 读取 pi-ai 内置目录的 github-copilot 模型 id → 协议映射。数据文件位于 dsh
11
- // 安装树内的 @earendil-works/pi-ai;从进程入口(dsh 可执行文件)逐级向上定位,
12
- // 只读不改。定位/解析失败返回 null(调用方回退 listModels 交集,见下)。
13
- function readCatalogModelIds() {
19
+ // 内置覆盖层的收割来源版本(Task 7,integrity 记录于 README「模型目录刷新」章节)
20
+ const OVERLAY_SOURCE_VERSION = "0.85.1";
21
+
22
+ // pi-ai 目录数据文件定位层(从 readCatalogModelIds 拆出,行为不变):
23
+ // 从进程入口(dsh 可执行文件)逐级向上找 node_modules/@earendil-works/pi-ai。
24
+ function findCatalogFile() {
14
25
  try {
15
26
  let dir = dirname(realpathSync(process.argv?.[1] ?? ""));
16
27
  for (let depth = 0; depth < 8; depth++) {
17
28
  const dataFile = join(dir, "node_modules", "@earendil-works", "pi-ai", "dist", "providers", "data", "github-copilot.json");
18
- if (existsSync(dataFile)) {
19
- const data = JSON.parse(readFileSync(dataFile, "utf8"));
20
- const byId = {};
21
- for (const [api, section] of Object.entries(data)) {
22
- for (const id of Object.keys(section ?? {})) byId[id] ??= api;
23
- }
24
- return byId;
25
- }
29
+ if (existsSync(dataFile)) return dataFile;
26
30
  const parent = dirname(dir);
27
31
  if (parent === dir) return null;
28
32
  dir = parent;
@@ -31,6 +35,22 @@ function readCatalogModelIds() {
31
35
  return null;
32
36
  }
33
37
 
38
+ // 读取 pi-ai 内置目录的 github-copilot 模型 id → 协议映射。只读不改。
39
+ // 定位/解析失败返回 null(调用方回退 listModels 交集,见下)。
40
+ function readCatalogModelIds() {
41
+ try {
42
+ const dataFile = findCatalogFile();
43
+ if (!dataFile) return null;
44
+ const data = JSON.parse(readFileSync(dataFile, "utf8"));
45
+ const byId = {};
46
+ for (const [api, section] of Object.entries(data)) {
47
+ for (const id of Object.keys(section ?? {})) byId[id] ??= api;
48
+ }
49
+ return byId;
50
+ } catch { /* 安装树结构变化时回退 */ }
51
+ return null;
52
+ }
53
+
34
54
  // 登录成功后,把账号可用模型写入用户 settings 的模型目录
35
55
  // (turnkey,用户新增需求 2026-09-04;目录保护,用户反馈 2026-09-05)。
36
56
  // 可写集合取交集:凭据 payload.availableModelIds(账号可用)∩ 内置目录已描述。
@@ -107,10 +127,334 @@ function guard(req, res, method) {
107
127
  return true;
108
128
  }
109
129
 
110
- export function apply(ctx) {
130
+ // ==================== 模型目录手动刷新(数据级目录补丁,ADR 0001) ====================
131
+
132
+ class InvalidModeError extends Error {}
133
+
134
+ const defaultStateFile = () => join(homedir(), ".dsh", "copilot-auth-state.json");
135
+ const defaultOverlayFile = () => fileURLToPath(new URL("./catalog-overlay.json", import.meta.url));
136
+
137
+ // 字面量 body 直接用(测试);否则按流读并 JSON.parse
138
+ function readBody(req) {
139
+ if (req.body !== undefined) return Promise.resolve(req.body);
140
+ return new Promise((resolve, reject) => {
141
+ const chunks = [];
142
+ req.on("data", (c) => chunks.push(c));
143
+ req.on("end", () => {
144
+ if (chunks.length === 0) return resolve(undefined);
145
+ try {
146
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
147
+ } catch (err) {
148
+ reject(err);
149
+ }
150
+ });
151
+ req.on("error", reject);
152
+ });
153
+ }
154
+
155
+ // 版本与 catalog 同根解析(R3-8):注入 catalogFile 时 version 取其所在根的
156
+ // package.json(opts.piAiVersion 为测试钩子);生产走 findPiAiInstallation()。
157
+ function resolveInstall(opts) {
158
+ if (opts.catalogFile) {
159
+ if (opts.piAiVersion) return { catalogFile: opts.catalogFile, version: opts.piAiVersion };
160
+ const root = dirname(dirname(dirname(dirname(opts.catalogFile))));
161
+ const packageJsonFile = join(root, "package.json");
162
+ const pkg = JSON.parse(readFileSync(packageJsonFile, "utf8"));
163
+ return { catalogFile: opts.catalogFile, packageJsonFile, version: pkg.version };
164
+ }
165
+ const found = findPiAiInstallation();
166
+ if (!found) throw new Error("pi-ai installation not found");
167
+ return found;
168
+ }
169
+
170
+ function readLocalCatalog(opts) {
171
+ const file = resolveInstall(opts).catalogFile;
172
+ const bytes = readFileSync(file);
173
+ return { file, bytes, catalog: JSON.parse(bytes.toString("utf8")) };
174
+ }
175
+
176
+ // 账号可用模型:现场拉取(显式耦合 0.84.4)→ 任何失败回退凭证缓存 availableModelIds
177
+ async function resolveAvailable(ctx, fetchImpl) {
178
+ const record = await ctx.credentials.readRecord(CREDENTIAL_KEY);
179
+ const payload = record?.payload ?? {};
180
+ if (payload.access) {
181
+ try {
182
+ return { ids: await fetchLiveAvailableModelIds({ credential: payload, fetchImpl }), source: "live" };
183
+ } catch { /* 回退 cache */ }
184
+ }
185
+ return { ids: Array.isArray(payload.availableModelIds) ? payload.availableModelIds : [], source: "cache" };
186
+ }
187
+
188
+ // R3-3 严格分支:仅 mode==="overlay" 读内置覆盖层;normal npm 失败只回
189
+ // catalogSource:"local"(绝不隐式读 overlay);未知 mode → 400。
190
+ async function resolveCatalogSource(opts, mode, fetchImpl) {
191
+ if (mode === "overlay") {
192
+ const catalog = readJson(opts.overlayFile ?? defaultOverlayFile());
193
+ if (catalog === undefined) throw new Error("bundled overlay file missing");
194
+ return {
195
+ catalog,
196
+ catalogSource: "overlay",
197
+ catalogError: null,
198
+ provenance: { kind: "overlay", piAiVersion: OVERLAY_SOURCE_VERSION, integrity: null },
199
+ };
200
+ }
201
+ if (mode !== undefined && mode !== "latest") throw new InvalidModeError(String(mode));
202
+ try {
203
+ const r = await fetchLatestCatalog({ fetchImpl });
204
+ return {
205
+ catalog: r.catalog,
206
+ catalogSource: "latest",
207
+ catalogError: null,
208
+ provenance: { kind: "latest", piAiVersion: r.piAiVersion, integrity: r.integrity },
209
+ };
210
+ } catch (error) {
211
+ return {
212
+ catalog: readLocalCatalog(opts).catalog,
213
+ catalogSource: "local",
214
+ catalogError: String(error?.message ?? error),
215
+ provenance: { kind: "local" },
216
+ };
217
+ }
218
+ }
219
+
220
+ // settings raw user 层完整配置视图(R3-2):digest/baseline/CAS 的唯一取数面。
221
+ // 缺席一律 null 占位(非 undefined),保证 journal JSON 持久化无损、boot 重载后
222
+ // 与现算视图可逐字节比较;「未配置 vs 显式空」由 *Present 标志区分。
223
+ function viewFromUserLayer(user) {
224
+ let route = {};
225
+ if (user && typeof user === "object" && !Array.isArray(user)) {
226
+ const r = user.providers?.["github-copilot"];
227
+ if (r && typeof r === "object" && !Array.isArray(r)) route = r;
228
+ }
229
+ const modelsPresent = route.models !== undefined && route.models !== null;
230
+ const modelOverridesPresent = route.modelOverrides !== undefined && route.modelOverrides !== null;
231
+ return {
232
+ modelsPresent,
233
+ models: modelsPresent ? route.models : null,
234
+ modelOverridesPresent,
235
+ modelOverrides: modelOverridesPresent ? route.modelOverrides : null,
236
+ };
237
+ }
238
+
239
+ function rawSettingsView(ctx) {
240
+ try {
241
+ const d = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
242
+ return viewFromUserLayer(d?.user);
243
+ } catch { /* settings 未注入/未注册/读取失败一律按空视图 */ }
244
+ return viewFromUserLayer(undefined);
245
+ }
246
+
247
+ // 幂等判定:当前视图是否已等于 target 形状——models 恰好为 targetIds 的纯 {id}
248
+ // 条目(无任何字段定制),且无有效 modelOverrides。
249
+ function viewEqualsTarget(view, targetIds) {
250
+ if (!view.modelsPresent || !Array.isArray(view.models)) return false;
251
+ if (view.models.some((m) => !m || typeof m !== "object" || Array.isArray(m) || Object.keys(m).some((k) => k !== "id"))) {
252
+ return false;
253
+ }
254
+ const ids = view.models.map((m) => m.id);
255
+ if ([...ids].sort().join("\0") !== [...targetIds].sort().join("\0")) return false;
256
+ if (view.modelOverridesPresent && view.modelOverrides && Object.keys(view.modelOverrides).length > 0) return false;
257
+ return true;
258
+ }
259
+
260
+ function overlayEntryIds(overlay) {
261
+ return Object.values(overlay ?? {}).flatMap((s) => Object.keys(s ?? {}));
262
+ }
263
+
264
+ // 启动序列(严格按序 boot 0→1→2;整体 try/catch,失败只记 lastError +
265
+ // logger.warn,绝不阻断挂载;所有写入幂等,崩溃现场下个 boot 安全重试)。
266
+ async function bootRefresh(ctx, opts) {
267
+ const stateFile = opts.stateFile ?? defaultStateFile();
268
+ let state = loadState(stateFile);
269
+ try {
270
+ if (state.lastError === "state-corrupt") {
271
+ // 损坏已隔离:持久化安全态(/status 稳定可见、激活丢失→自愈停用,🟡-4)
272
+ saveState(stateFile, state);
273
+ }
274
+ if (!state.journal && !state.activated && !state.restartState) return; // R9:未激活不触碰
275
+ let install = null;
276
+ try {
277
+ install = resolveInstall(opts);
278
+ } catch {
279
+ install = null;
280
+ }
281
+ let bootPatched = false;
282
+ const readDiskCatalog = () => JSON.parse(readFileSync(install.catalogFile, "utf8"));
283
+ const diskIds = (disk) => new Set(Object.values(disk).flatMap((s) => Object.keys(s ?? {})));
284
+
285
+ // boot 0:journal=prepared 崩溃恢复(先于激活门,但先过兼容 gate,R3-1)
286
+ if (state.journal?.phase === "prepared") {
287
+ if (!install || install.version !== state.journal.appliedAgainstPiAiVersion) {
288
+ state = setLastError(state, `prepared-incompatible: journal against ${state.journal.appliedAgainstPiAiVersion}, current ${install?.version ?? "none"} — re-run refresh preview`);
289
+ saveState(stateFile, state); // 目录零写入、journal 保留
290
+ return;
291
+ }
292
+ const replay = mergeCatalog(readDiskCatalog(), state.journal.pendingOverlay);
293
+ if (replay.added.length > 0) {
294
+ writeJsonAtomic(install.catalogFile, replay.merged);
295
+ bootPatched = true;
296
+ }
297
+ state.appliedOverlay = mergeCatalog(state.appliedOverlay, state.journal.pendingOverlay).merged;
298
+ state.appliedProvenance = {
299
+ sourcePiAiVersion: state.journal.source?.piAiVersion ?? null,
300
+ integrity: state.journal.source?.integrity ?? null,
301
+ appliedAgainstPiAiVersion: state.journal.appliedAgainstPiAiVersion,
302
+ catalogSchemaVersion: 1,
303
+ };
304
+ state.activated = true;
305
+ state.restartState = restartMarker("refresh", digest(state.appliedOverlay));
306
+ state.journal = advanceJournal(state.journal);
307
+ state = clearLastError(state);
308
+ saveState(stateFile, state);
309
+ }
310
+
311
+ // boot 1:自愈(仅 activated,且当前 pi-ai 版本==基线才重放;跨版本见 gate)
312
+ if (state.activated) {
313
+ const baseline = state.appliedProvenance?.appliedAgainstPiAiVersion;
314
+ const overlayIds = overlayEntryIds(state.appliedOverlay);
315
+ if (overlayIds.length > 0 && baseline) {
316
+ if (!install) {
317
+ state = setLastError(state, "self-heal-incompatible: pi-ai installation not found");
318
+ saveState(stateFile, state);
319
+ } else if (install.version === baseline) {
320
+ const replay = mergeCatalog(readDiskCatalog(), state.appliedOverlay);
321
+ if (replay.added.length > 0) {
322
+ writeJsonAtomic(install.catalogFile, replay.merged); // 原子重放
323
+ bootPatched = true;
324
+ state.restartState = restartMarker("self-heal", digest(state.appliedOverlay));
325
+ state = clearLastError(state);
326
+ saveState(stateFile, state);
327
+ }
328
+ } else {
329
+ const missing = overlayIds.filter((id) => !diskIds(readDiskCatalog()).has(id));
330
+ if (missing.length > 0) {
331
+ // 跨版本有缺失:上报 self-heal-incompatible,不改安装树
332
+ state = setLastError(state, `self-heal-incompatible: pi-ai ${install.version} != baseline ${baseline}, missing ${missing.join(",")}`);
333
+ saveState(stateFile, state);
334
+ }
335
+ // 条目已原生存在 → 空操作
336
+ }
337
+ }
338
+ }
339
+
340
+ // boot 2:本 boot 未写目录时,两个独立出口(R3-5 职责分离)
341
+ if (!bootPatched) {
342
+ // 2a. journal=committed → settings 真 CAS(settings 同步的唯一触发源)
343
+ if (state.journal?.phase === "catalog-committed-needs-restart") {
344
+ const j = state.journal;
345
+ let consumed = false;
346
+ let failed = null;
347
+ try {
348
+ if (!install) throw new Error("pi-ai installation not found");
349
+ // 语义前置(Y2-3):targetIds 全部可由当前目录解析;whole-file digest 仅诊断
350
+ const ids = diskIds(readDiskCatalog());
351
+ const unresolvable = j.targetIds.filter((id) => !ids.has(id));
352
+ if (unresolvable.length > 0) throw new Error(`target-unresolvable: ${unresolvable.join(",")}`);
353
+ const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
354
+ if (!desc) throw new Error("settings namespace llm-pi-ai unavailable");
355
+ const view = viewFromUserLayer(desc.user);
356
+ if (digest(view) === digest(j.settingsBaseline)) {
357
+ await ctx.settings.mutate("llm-pi-ai", [
358
+ { op: "set", path: ["providers", "github-copilot", "models"], value: j.targetIds.map((id) => ({ id })) },
359
+ { op: "unset", path: ["providers", "github-copilot", "modelOverrides"] },
360
+ ], desc.revision); // 真 CAS:revision 不符由 settings 抛 SETTINGS_CONFLICT
361
+ consumed = true;
362
+ } else if (viewEqualsTarget(view, j.targetIds)) {
363
+ consumed = true; // 已等于 target → 幂等消费,不再 mutate
364
+ } else {
365
+ failed = "settings-conflict: configuration changed since preview; re-run refresh";
366
+ }
367
+ } catch (err) {
368
+ failed = err?.code === "SETTINGS_CONFLICT" || /conflict/i.test(String(err?.message ?? ""))
369
+ ? `settings-conflict: ${err.message}`
370
+ : String(err?.message ?? err);
371
+ }
372
+ if (consumed) {
373
+ state.journal = null;
374
+ state = clearLastError(state);
375
+ saveState(stateFile, state);
376
+ } else if (failed) {
377
+ state = setLastError(state, failed); // 不覆盖、记 conflict、/status 上报
378
+ saveState(stateFile, state);
379
+ }
380
+ }
381
+ // 2b. restartState 非空且 expected entries 已在目录 → 仅清 restartState
382
+ // (不携带 settings 意图:单独存在时不得触碰 describe/mutate)
383
+ if (state.restartState && install) {
384
+ const ids = diskIds(readDiskCatalog());
385
+ if (overlayEntryIds(state.appliedOverlay).every((id) => ids.has(id))) {
386
+ state.restartState = null;
387
+ state = clearLastError(state);
388
+ saveState(stateFile, state);
389
+ }
390
+ }
391
+ }
392
+ } catch (err) {
393
+ try {
394
+ saveState(stateFile, setLastError(state, err));
395
+ } catch { /* 记录失败不掩盖 */ }
396
+ ctx.logger?.warn?.("copilot-auth: refresh boot failed: %s", String(err?.message ?? err));
397
+ }
398
+ }
399
+
400
+ // /status 专用只读读取:不隔离、不写盘(隔离只在 boot 发生一次)
401
+ function peekState(path) {
402
+ const corrupt = () => ({ ...freshState(), lastError: "state-corrupt" });
403
+ let raw;
404
+ try {
405
+ raw = readJson(path);
406
+ } catch {
407
+ return corrupt();
408
+ }
409
+ if (raw === undefined) return freshState();
410
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return corrupt();
411
+ return { ...freshState(), ...raw };
412
+ }
413
+
414
+ // preview 与 apply 共享的输入计算——digest 绑定的正确性依赖两侧走同一代码路径。
415
+ async function computeRefreshInputs(ctx, opts, mode) {
416
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch?.bind(globalThis);
417
+ const { ids, source } = await resolveAvailable(ctx, fetchImpl);
418
+ const local = readLocalCatalog(opts);
419
+ const resolved = await resolveCatalogSource(opts, mode, fetchImpl);
420
+ const merged = mergeCatalog(local.catalog, resolved.catalog);
421
+ const catalogIds = new Set(Object.values(merged.merged).flatMap((s) => Object.keys(s ?? {})));
422
+ const view = rawSettingsView(ctx);
423
+ const currentIds = Array.isArray(view.models)
424
+ ? view.models.map((m) => m?.id).filter((x) => typeof x === "string")
425
+ : [];
426
+ const diff = diffModels(currentIds, ids, catalogIds);
427
+ const customizationReset = {
428
+ modelEntryIds: (Array.isArray(view.models) ? view.models : [])
429
+ .filter((m) => m && typeof m === "object" && Object.keys(m).some((k) => k !== "id"))
430
+ .map((m) => m.id),
431
+ modelOverrideIds:
432
+ view.modelOverrides && typeof view.modelOverrides === "object" && !Array.isArray(view.modelOverrides)
433
+ ? Object.keys(view.modelOverrides)
434
+ : [],
435
+ };
436
+ const digests = {
437
+ settings: digest(view), // raw 完整视图:字段级变化必漂移(R3-2)
438
+ available: digest([...ids].sort()),
439
+ catalog: digest(local.bytes), // 本地目录文件字节
440
+ remote: digest(resolved.catalog), // 实际使用的目录来源对象(与 mode 严格对应)
441
+ };
442
+ return { view, ids, source, local, resolved, merged, diff, customizationReset, digests };
443
+ }
444
+
445
+ export function apply(ctx, opts = {}) {
111
446
  const r = routes();
112
447
  let attempt = emptyState();
113
448
  let lastSyncError;
449
+ const refreshMutex = createMutex(); // 宿主单进程内互斥(不支持多实例,见 README)
450
+
451
+ // 启动序列:prepared 恢复(先过兼容 gate)→ 激活门 → 自愈 → settings 真 CAS。
452
+ // 绝不阻断挂载;失败仅 lastError + warn。opts.onBootDone 为测试钩子(boot settle 回调)。
453
+ void bootRefresh(ctx, opts)
454
+ .catch((err) => {
455
+ ctx.logger?.warn?.("copilot-auth: refresh boot failed: %s", String(err?.message ?? err));
456
+ })
457
+ .finally(() => opts.onBootDone?.());
114
458
 
115
459
  // 模型目录同步只在登录成功后执行(2026-09-05 对齐结论:挂载不再同步)。
116
460
  // 插件启动/重启绝不触碰用户 settings,用户精简过的目录不会被动重置。
@@ -187,7 +531,35 @@ export function apply(ctx) {
187
531
  } catch {
188
532
  configured = false;
189
533
  }
190
- json(res, 200, { configured, syncError: lastSyncError });
534
+ // refresh 状态块:lastError 直读状态文件顶层(R3-6);pendingRestart 派生自
535
+ // 状态文件(journal 相位或 restartState),不用模块布尔;piAiVersion /
536
+ // catalogDigest / catalogFile 来自同根解析,digest 为当前文件字节 sha256
537
+ // (E2E 据此把被检查文件绑定到实际加载副本,R3-8/R4-5)。
538
+ let install = null;
539
+ try {
540
+ install = resolveInstall(opts);
541
+ } catch {
542
+ install = null;
543
+ }
544
+ let catalogDigest = null;
545
+ if (install) {
546
+ try {
547
+ catalogDigest = digest(readFileSync(install.catalogFile));
548
+ } catch {
549
+ catalogDigest = null;
550
+ }
551
+ }
552
+ const state = peekState(opts.stateFile ?? defaultStateFile());
553
+ const refresh = {
554
+ activated: state.activated === true,
555
+ pendingRestart: state.restartState != null || state.journal?.phase === "catalog-committed-needs-restart",
556
+ phase: state.journal?.phase ?? null,
557
+ lastError: state.lastError ?? null,
558
+ piAiVersion: install?.version ?? null,
559
+ catalogDigest,
560
+ catalogFile: install?.catalogFile ?? null,
561
+ };
562
+ json(res, 200, { configured, syncError: lastSyncError, refresh });
191
563
  },
192
564
  });
193
565
 
@@ -202,6 +574,123 @@ export function apply(ctx) {
202
574
  json(res, 200, { ok: true });
203
575
  },
204
576
  });
577
+
578
+ // 手动刷新可用模型目录 · preview:只读,返回 diff + customizationReset + digest 组。
579
+ // body 可带 { "mode": "overlay" } 用内置覆盖层出 diff(离线 bootstrap)。
580
+ ctx.webServer.register({
581
+ kind: "exact",
582
+ path: r.refreshPreview,
583
+ handler: async (req, res) => {
584
+ if (!guard(req, res, "POST")) return;
585
+ let body;
586
+ try {
587
+ body = await readBody(req);
588
+ } catch {
589
+ json(res, 400, { ok: false, error: "bad-body" });
590
+ return;
591
+ }
592
+ const mode = body?.mode;
593
+ if (mode !== undefined && mode !== "latest" && mode !== "overlay") {
594
+ json(res, 400, { ok: false, error: "invalid-mode" });
595
+ return;
596
+ }
597
+ try {
598
+ const inputs = await computeRefreshInputs(ctx, opts, mode);
599
+ json(res, 200, {
600
+ ok: true,
601
+ source: inputs.source,
602
+ catalogSource: inputs.resolved.catalogSource,
603
+ catalogError: inputs.resolved.catalogError,
604
+ skipped: inputs.merged.skipped,
605
+ added: inputs.diff.added,
606
+ removed: inputs.diff.removed,
607
+ kept: inputs.diff.kept,
608
+ target: inputs.diff.target,
609
+ customizationReset: inputs.customizationReset,
610
+ digests: inputs.digests,
611
+ });
612
+ } catch (err) {
613
+ if (err instanceof InvalidModeError) {
614
+ json(res, 400, { ok: false, error: "invalid-mode" });
615
+ return;
616
+ }
617
+ json(res, 500, { ok: false, error: String(err?.message ?? err) });
618
+ }
619
+ },
620
+ });
621
+
622
+ // 手动刷新 · apply:mutex 内重算全部输入 digest 与回传比对(漂移 → 409
623
+ // preview-stale,client 重新 preview);一致则 write-ahead:journal(prepared)
624
+ // → 原子提交目录 → 合入 appliedOverlay + activated → journal 推进 committed。
625
+ // 重启 dsh web 后由启动序列把 settings 目录镜像重建为 target(见 boot 序列)。
626
+ ctx.webServer.register({
627
+ kind: "exact",
628
+ path: r.refreshApply,
629
+ handler: async (req, res) => {
630
+ if (!guard(req, res, "POST")) return;
631
+ let body;
632
+ try {
633
+ body = await readBody(req);
634
+ } catch {
635
+ json(res, 400, { ok: false, error: "bad-body" });
636
+ return;
637
+ }
638
+ const mode = body?.mode;
639
+ if (mode !== undefined && mode !== "latest" && mode !== "overlay") {
640
+ json(res, 400, { ok: false, error: "invalid-mode" });
641
+ return;
642
+ }
643
+ try {
644
+ const out = await refreshMutex(async () => {
645
+ const inputs = await computeRefreshInputs(ctx, opts, mode);
646
+ const got = body?.digests ?? {};
647
+ for (const k of ["settings", "available", "catalog", "remote"]) {
648
+ if (got[k] !== inputs.digests[k]) {
649
+ return { code: 409, payload: { ok: false, error: "preview-stale" } };
650
+ }
651
+ }
652
+ // digest 全部一致后才解析版本(写入失败点上移,500 不留 journal)
653
+ const install = resolveInstall(opts);
654
+ const stateFile = opts.stateFile ?? defaultStateFile();
655
+ const patchedBytes = Buffer.from(JSON.stringify(inputs.merged.merged, null, 2) + "\n", "utf8");
656
+ let state = loadState(stateFile);
657
+ state.journal = createJournal({
658
+ settingsBaseline: inputs.view,
659
+ targetIds: inputs.diff.target,
660
+ pendingOverlay: inputs.merged.addedOverlay, // catalog-shaped 增量(R3-4)
661
+ appliedAgainstPiAiVersion: install.version,
662
+ catalogBaselineDigest: inputs.digests.catalog,
663
+ patchedCatalogDigest: digest(patchedBytes),
664
+ source: inputs.resolved.provenance,
665
+ });
666
+ saveState(stateFile, state); // ① write-ahead:prepared
667
+ writeJsonAtomic(install.catalogFile, inputs.merged.merged); // ② 目录原子提交
668
+ state = loadState(stateFile);
669
+ state.appliedOverlay = mergeCatalog(state.appliedOverlay, inputs.merged.addedOverlay).merged;
670
+ state.appliedProvenance = {
671
+ sourcePiAiVersion: inputs.resolved.provenance.piAiVersion ?? null,
672
+ integrity: inputs.resolved.provenance.integrity ?? null,
673
+ appliedAgainstPiAiVersion: install.version,
674
+ catalogSchemaVersion: 1,
675
+ };
676
+ state.activated = true;
677
+ state.restartState = restartMarker("refresh", digest(state.appliedOverlay));
678
+ saveState(stateFile, state); // ③ appliedOverlay/activated 落盘
679
+ state = loadState(stateFile);
680
+ state.journal = advanceJournal(state.journal);
681
+ saveState(stateFile, state); // ④ 相位推进 catalog-committed-needs-restart
682
+ return { code: 200, payload: { ok: true, restartRequired: true } };
683
+ });
684
+ json(res, out.code, out.payload);
685
+ } catch (err) {
686
+ if (err instanceof InvalidModeError) {
687
+ json(res, 400, { ok: false, error: "invalid-mode" });
688
+ return;
689
+ }
690
+ json(res, 500, { ok: false, error: String(err?.message ?? err) });
691
+ }
692
+ },
693
+ });
205
694
  }
206
695
 
207
696
  export default { name, inject, apply };