dsh-plugin-deck-studio 0.2.10 → 0.2.13

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/index.js CHANGED
@@ -14,6 +14,8 @@ import { createHash } from "node:crypto";
14
14
  import { execFile } from "node:child_process";
15
15
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
16
  import { PLUGIN_DIR, mergeConfig, repoRootFrom } from "./lib/config.js";
17
+ import { DATA_DIR_ENV, dirFacts, dirWritable, pointerWritable, readLocationPointer, resolveDataDir, restoreLocationPointer, writeLocationPointer } from "./lib/data-dir.js";
18
+ import { assessDataDir, buildSelfCheckPayload, dataDirBannerFacts, deckDataHealth, listDataRootDeckIds } from "./lib/data-dir-detect.js";
17
19
  import { createRuntimeStore } from "./lib/store.js";
18
20
  import { createDeckWorkspace, scriptSectionRange } from "./lib/deck-workspace.js";
19
21
  import { createLocalSourceAdapter } from "./lib/source-adapter.js";
@@ -93,6 +95,12 @@ export const ROUTES = {
93
95
  sessionBindings: "/api/deck-studio.session-bindings",
94
96
  handoff: "/api/deck-studio.handoff",
95
97
  sourcefile: "/api/deck-studio.sourcefile",
98
+ // P0:数据目录(解析结果 / 切换 / 自检)——顶栏黄条与设置页第一屏共用;
99
+ // 规格:.deck-assistant-scratch/deckline-data-dir-spec.md §2 / §5
100
+ dataDir: "/api/deck-studio.data-dir",
101
+ selfCheck: "/api/deck-studio.self-check",
102
+ // 目录浏览(目录选择器用):浏览器拿不到绝对路径(webkitdirectory 只给相对路径),只能由后端列目录
103
+ dirBrowse: "/api/deck-studio.dir-browse",
96
104
  // F-140:内置 FAQ(工作台「?帮助」面板与 deck_help 工具同源)
97
105
  faq: "/api/deck-studio.faq",
98
106
  // F-141:首页「使用手册」——仓库里的 docs/manual.md + 截图,直接在工作台里读
@@ -396,6 +404,17 @@ export function createService(config, logger, env = process.env) {
396
404
  /** deckId → adapter(workbench / 资产路由共用)。 */
397
405
  const adapterOf = (deckId) => adapters.find((adapter) => adapter.id === deckId) ?? null;
398
406
 
407
+ // P0:启动时体检一次(规格 §2「插件启动时(一次)」):只记事件,不拦渲染。
408
+ try {
409
+ noteDataDirTransitions(
410
+ { config, store },
411
+ dataDirReportOf({ config, workspace, adapters, env }, { refresh: true }),
412
+ "startup",
413
+ );
414
+ } catch (error) {
415
+ logger?.warn?.(`${name}: 数据目录体检失败:${error?.message ?? error}`);
416
+ }
417
+
399
418
  // ── 出图节点 / 模型选择(ADR-0010)────────────────────────────────────────
400
419
  /** 当前 deck 的节点选择文件(工作副本;不进 git)。 */
401
420
  function renderSelectionFile(deckId) {
@@ -1981,6 +2000,15 @@ export function createService(config, logger, env = process.env) {
1981
2000
  hideShellSidebar: config.hideShellSidebar === true,
1982
2001
  workspacePath: config.workspacePath,
1983
2002
  dataRoot: config.dataRoot,
2003
+ // P0:自检面板第一行要「数据目录 + 命中来源」;黄条与设置页走同一份体检结果。
2004
+ dataDir: (() => {
2005
+ try {
2006
+ return dataDirStateOf({ config, store, workspace, adapters, env });
2007
+ } catch (error) {
2008
+ logger?.warn?.(`${name}: dataDirState 失败:${error?.message ?? error}`);
2009
+ return null;
2010
+ }
2011
+ })(),
1984
2012
  toolsMounted: config.tools,
1985
2013
  renderMode: config.render.mode,
1986
2014
  renderModel: config.render.model,
@@ -2169,11 +2197,18 @@ export function createService(config, logger, env = process.env) {
2169
2197
  appendixRows: workspace.manifest(deck.deckId)?.appendix?.rows ?? [],
2170
2198
  }));
2171
2199
  const activeId = (requestedDeckId && decks.some((deck) => deck.deckId === requestedDeckId) ? requestedDeckId : null) ?? decks[0]?.deckId ?? workspace.listDeckIds()[0] ?? null;
2200
+ // P0:进 deck 时先体检(规格 §2「进入某本 deck 时,每次,读 manifest 之前」)。
2201
+ // mismatch / partial 时不 ensureDeck:那会凭空建出空的 decks/<id>/,旧图也找不回(禁止项)。
2202
+ const dataDirInfo = dataDirStateOf({ config, store, workspace, adapters, env }, activeId);
2203
+ const activeDataDir = dataDirInfo.active;
2204
+ const dataNotHere = activeDataDir?.status === "mismatch" || activeDataDir?.status === "partial";
2205
+ // 黄条口径(规格 §2):mismatch / partial / 外部源 absent 都要出条;本机新建 deck 的首次打开静默。
2206
+ const needsBanner = activeDataDir?.needsBanner === true;
2172
2207
  let active = null;
2173
2208
  if (activeId) {
2174
2209
  const adapter = adapterOf(activeId);
2175
2210
  let deck = workspace.manifest(activeId);
2176
- if (adapter?.available()) {
2211
+ if (adapter?.available() && !dataNotHere) {
2177
2212
  try {
2178
2213
  // ensureDeck 幂等:manifest 缺失时初始化;源变了且本地还没改过时按源刷新派生字段。
2179
2214
  deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
@@ -2245,10 +2280,16 @@ export function createService(config, logger, env = process.env) {
2245
2280
  designs: designSamples(activeId),
2246
2281
  ...homeCatalog(activeId),
2247
2282
  sourceRoot: adapter?.sourceRoot ?? deck?.source?.root ?? "",
2283
+ // P0:黄条与 Inspector 用的数据目录状态(active 级别)
2284
+ dataDir: activeDataDir,
2285
+ dataDirMismatch: needsBanner,
2248
2286
  };
2249
2287
  }
2250
2288
  return {
2251
2289
  ...base,
2290
+ dataDir: dataDirInfo,
2291
+ // 顶层布尔:这本 deck 在当前 root 需要黄条吗(切换失败回滚后必须是 false)。
2292
+ dataDirMismatch: needsBanner,
2252
2293
  // F-164:会话默认落在哪个目录 / 工作区。npm 布局下 repoRootFrom 会退到插件包内的 seeds/,
2253
2294
  // 不能拿来当会话 cwd(会落到「未分组」)。优先显式配置的 workspacePath,否则用宿主启动目录。
2254
2295
  cwd: config.workspacePath || process.cwd(),
@@ -2287,14 +2328,186 @@ export function createService(config, logger, env = process.env) {
2287
2328
  return { config, store, workspace, adapters, status, state, workbench, readManual, readUserName: () => config.userName, saveUserName, readDoc, docsAsset, setDeckDensity, renderSettings, saveRenderConfig, isLocalDeck: (deckId) => localDeckIds().has(String(deckId ?? "")), assetFile, slideRenderInfo, deckCoverUrl, deckRevisions, deckDocs, deckDoc, deckDocsScaffold, deckDocFile, pageForEdit, pagePrompt, savePage, exportDeckFile, designSamples, sourceFile, homeCatalog, mediaFile, renderState, setRenderSelection, renderRuntime, createDeck, createDesign, createStyleFromDeck, designProjects, designStyle, updateDesignStyle, backfillStyleBriefs, listBindings, bindSession, unbindSession, deleteDesign, deleteDeck, setDeckDesign, addDesignSamples, seedStyleDesigns, renderStatus, exportHandoff, updateBrief, updateStatus, runSelfUpdate }
2288
2329
  }
2289
2330
 
2331
+ /* ─────────────────────────────────────────────────────────────────────────
2332
+ * P0:数据目录体检 / 自检(模块级实现,路由与服务两侧共用一份口径)
2333
+ *
2334
+ * 传入的是「服务状对象」`{ config, store, workspace, adapters, env }`,不依赖 createService
2335
+ * 的闭包——这样 index.js 末尾那条超长的 `return { … }` 不需要改,路由侧也能复用同一套判断。
2336
+ * ───────────────────────────────────────────────────────────────────────── */
2337
+
2338
+ /** 体检缓存:按 dataRoot 分桶(切换目录后自然失效),5s 轮询里只付几次 stat。 */
2339
+ const dataDirReportCache = new Map();
2340
+ /** 每本 deck 上次记录过事件的异常状态;只用于「状态变化才记」,不参与判定。 */
2341
+ const dataDirSeen = new Map();
2342
+
2343
+ /** 待体检的 deck 清单。`local` 决定 absent 口径:本机新建 deck 静默,外部受控源给 info 弱提示。 */
2344
+ function auditableDecksOf({ config, workspace, adapters }) {
2345
+ const localRoots = [join(config.dataRoot, "projects"), config.projectsRoot].filter(Boolean);
2346
+ return (adapters ?? []).map((adapter) => {
2347
+ const manifest = workspace.manifest(adapter.id);
2348
+ const sourceRoot = adapter.sourceRoot ?? "";
2349
+ return {
2350
+ id: adapter.id,
2351
+ label: adapter.label,
2352
+ title: manifest?.title ?? adapter.label,
2353
+ pageCount: manifest?.slides?.length ?? 0,
2354
+ sourceRoot,
2355
+ sourceAvailable: adapter.available(),
2356
+ local: Boolean(sourceRoot) && localRoots.some((root) => sourceRoot === root || sourceRoot.startsWith(`${root}${sep}`)),
2357
+ };
2358
+ });
2359
+ }
2360
+
2361
+ /** 体检(缓存 2s;`refresh` / `countImages` 时强制重算)。 */
2362
+ function dataDirReportOf(serviceLike, { refresh = false, countImages = false } = {}) {
2363
+ const { config, workspace, adapters, env = process.env } = serviceLike;
2364
+ const now = Date.now();
2365
+ const cached = dataDirReportCache.get(config.dataRoot);
2366
+ if (!refresh && !countImages && cached && now - cached.at < 2000) return cached.report;
2367
+ const report = assessDataDir({
2368
+ dataRoot: config.dataRoot,
2369
+ decks: auditableDecksOf({ config, workspace, adapters }),
2370
+ env,
2371
+ cwd: process.cwd(),
2372
+ profileDataRoots: [config.dataDir?.profileDataRoot].filter(Boolean),
2373
+ countImages,
2374
+ });
2375
+ if (!countImages) dataDirReportCache.set(config.dataRoot, { at: now, report });
2376
+ return report;
2377
+ }
2378
+
2379
+ /**
2380
+ * 状态变化才记事件(工作台 5s 轮询,同一状态重复记会把事件表刷满)。
2381
+ * `absent` 一律不记——首次打开不是异常,弱提示走 UI 的 notice,不进事件流水。
2382
+ */
2383
+ function noteDataDirTransitions(serviceLike, report, trigger = "poll") {
2384
+ const { config, store } = serviceLike;
2385
+ for (const deck of report?.decks ?? []) {
2386
+ if (deck.status !== "mismatch" && deck.status !== "partial") {
2387
+ dataDirSeen.delete(deck.deckId);
2388
+ continue;
2389
+ }
2390
+ if (dataDirSeen.get(deck.deckId) === deck.status) continue;
2391
+ dataDirSeen.set(deck.deckId, deck.status);
2392
+ store.recordEvent({
2393
+ type: "deck.dataDir.mismatch",
2394
+ deckId: deck.deckId,
2395
+ actor: "system",
2396
+ status: "failed",
2397
+ payload: {
2398
+ trigger,
2399
+ dataRoot: config.dataRoot,
2400
+ status: deck.status,
2401
+ imageCount: deck.imageCount,
2402
+ candidateCount: (deck.candidates ?? []).length,
2403
+ candidates: (deck.candidates ?? []).map((candidate) => ({
2404
+ dataRoot: candidate.dataRoot,
2405
+ deckCount: candidate.deckCount,
2406
+ imageCount: candidate.imageCount,
2407
+ })),
2408
+ },
2409
+ });
2410
+ }
2411
+ }
2412
+
2413
+ /**
2414
+ * 数据目录载荷:黄条 / 设置页 / 自检共用(不重算解析,只合并最新体检)。
2415
+ */
2416
+ function dataDirStateOf(serviceLike, deckId = null, { refresh = false, countImages = false } = {}) {
2417
+ const { config } = serviceLike;
2418
+ const resolved = config.dataDir ?? {};
2419
+ const report = dataDirReportOf(serviceLike, { refresh, countImages });
2420
+ noteDataDirTransitions(serviceLike, report, refresh ? "rescan" : "poll");
2421
+ const active = deckId ? report.decks.find((deck) => deck.deckId === deckId) ?? null : null;
2422
+ return {
2423
+ ok: true,
2424
+ dataRoot: resolved.dataRoot ?? null,
2425
+ projectsRoot: resolved.projectsRoot ?? null,
2426
+ dshHome: resolved.dshHome ?? null,
2427
+ pointerFile: resolved.pointerFile ?? { path: "", exists: false, error: null },
2428
+ envOverride: resolved.envOverride ?? { active: false, name: DATA_DIR_ENV },
2429
+ firstRun: resolved.firstRun === true,
2430
+ processCwd: resolved.processCwd ?? process.cwd(),
2431
+ sourceLabel: resolved.sourceLabel ?? "",
2432
+ banner: dataDirBannerFacts({ report, deckId, envOverride: resolved.envOverride?.active === true, dataRoot: config.dataRoot }),
2433
+ decks: report.decks,
2434
+ candidates: report.candidates,
2435
+ checkedAt: report.checkedAt,
2436
+ active: active
2437
+ ? {
2438
+ deckId: active.deckId,
2439
+ status: active.status,
2440
+ statusLabel: active.statusLabel,
2441
+ needsBanner: active.needsBanner === true,
2442
+ notice: active.notice ?? null,
2443
+ hasScript: active.hasScript,
2444
+ candidates: active.candidates ?? [],
2445
+ }
2446
+ : null,
2447
+ };
2448
+ }
2449
+
2450
+ /** 自检面板整表(含后端生成的脱敏文本;前端只负责复制它)。 */
2451
+ function selfCheckOf(serviceLike) {
2452
+ const { config } = serviceLike;
2453
+ const env = serviceLike.env ?? process.env;
2454
+ const resolved = config.dataDir ?? resolveDataDir({
2455
+ env,
2456
+ profileDataRoot: config.profileDataRoot,
2457
+ profileProjectsRoot: config.profileProjectsRoot,
2458
+ });
2459
+ const report = dataDirReportOf(serviceLike, { refresh: true, countImages: true });
2460
+ const active = resolveSelection(config.render, null);
2461
+ const workspaceFact = config.workspacePath
2462
+ ? { value: config.workspacePath, source: "profile", envOverridden: false, writable: dirWritable(config.workspacePath), exists: existsSync(config.workspacePath) }
2463
+ : { value: process.cwd(), source: "default", envOverridden: false, writable: dirWritable(process.cwd()), exists: true };
2464
+ return {
2465
+ ok: true,
2466
+ ...buildSelfCheckPayload({
2467
+ resolved,
2468
+ workspacePath: workspaceFact,
2469
+ report,
2470
+ diskDeckIds: listDataRootDeckIds(config.dataRoot),
2471
+ render: { mode: config.render.mode, nodeName: active?.nodeName ?? "", model: active?.model ?? config.render.model },
2472
+ versions: { plugin: pluginVersion(), dsh: process.env.DSH_VERSION ?? "", node: process.version },
2473
+ }),
2474
+ };
2475
+ }
2476
+
2290
2477
  /**
2291
2478
  * 注册路由与(可选)工具。标准 Cordis apply。
2292
2479
  * @param {import('@deepseek-ai/cordis').Context} ctx
2293
2480
  * @param {Record<string, unknown>} [rawConfig]
2294
2481
  */
2295
2482
  export function apply(ctx, rawConfig) {
2296
- const config = mergeConfig(rawConfig, { pluginDir: PLUGIN_DIR, env: process.env });
2297
- const service = createService(config, ctx.logger);
2483
+ const env = process.env;
2484
+ const config = mergeConfig(rawConfig, { pluginDir: PLUGIN_DIR, env });
2485
+ // P0:dataRoot 的第二优先级是「指针文件」。mergeConfig 只算 env / profile / 默认,
2486
+ // 这里补上 location.json($DSH_HOME/deckline/location.json,位置固定、不可被 dataRoot 配置移动)。
2487
+ config.dataDir = resolveDataDir({ env, profileDataRoot: config.profileDataRoot, profileProjectsRoot: config.profileProjectsRoot, logger: ctx.logger });
2488
+ config.dataRoot = config.dataDir.dataRoot.value;
2489
+ config.projectsRoot = config.dataDir.projectsRoot.value;
2490
+ // profile 里声明的受控源 deck;注册表里的本机 deck 由 createService 从 <dataRoot>/decks-registry.json 补。
2491
+ const profileDecks = config.decks.map((deck) => ({ ...deck }));
2492
+ let service = createService(config, ctx.logger, env);
2493
+ // P0:工具注册与 dataRoot 绑定(切目录必须重注册),disposers 在 effect 清理时统一释放。
2494
+ let toolDisposers = [];
2495
+ // P0:切换闸。切换期间新请求立即 409(不排队——排队会把毫秒窗口放大成超时)。
2496
+ let switching = false;
2497
+ /**
2498
+ * 提供给 Agent 侧的服务门面:切 dataRoot 后仍指向当前实例。
2499
+ * Cordis 同一路径不能重复 provide,所以只能提供一个稳定对象。
2500
+ */
2501
+ const deckStudioFacade = new Proxy({}, {
2502
+ get(_target, prop) {
2503
+ const current = service;
2504
+ const value = current[prop];
2505
+ return typeof value === "function" ? value.bind(current) : value;
2506
+ },
2507
+ has(_target, prop) {
2508
+ return prop in service;
2509
+ },
2510
+ });
2298
2511
  // F-72:老 design 的 style.json 没存 styleBrief(模板库提示词被丢),启动时幂等补齐。
2299
2512
  try {
2300
2513
  const backfilled = service.backfillStyleBriefs();
@@ -2332,9 +2545,324 @@ export function apply(ctx, rawConfig) {
2332
2545
  };
2333
2546
  const route = (path, handler) => registerRoute(path, async (request) => json(await handler(request)));
2334
2547
 
2335
- route(ROUTES.status, () => service.status());
2336
- route(ROUTES.state, () => service.state());
2337
- route(ROUTES.workbench, (request) => service.workbench(new URL(request?.url ?? "/", "http://localhost").searchParams.get("deckId")));
2548
+ /** 切换窗口内的统一 409:只给 code + 一句人话,前端按 code 分支(不认 message)。 */
2549
+ const switchingResponse = () => json({ ok: false, error: { code: "switching", message: "数据目录切换中,请重试" } }, 409);
2550
+ /** 读接口在切换窗口内立即 409(不排队);其它时候原样透传。 */
2551
+ const routeWhenIdle = (path, handler) => registerRoute(path, async (request) => {
2552
+ if (switching) return switchingResponse();
2553
+ return json(await handler(request));
2554
+ });
2555
+
2556
+ /**
2557
+ * P0:切换闸。切换窗口内**任何**新请求立即 409(不排队,排队会把毫秒窗口放大成超时);
2558
+ * 完成即清状态;失败由 switchDataDir 回滚到旧实例与旧指针。
2559
+ */
2560
+ async function switchDataDir(options = {}) {
2561
+ if (switching) {
2562
+ const error = new Error("数据目录切换中,请重试");
2563
+ error.code = "switching";
2564
+ throw error;
2565
+ }
2566
+ const snapshot = {
2567
+ config: {
2568
+ dataDir: config.dataDir,
2569
+ dataRoot: config.dataRoot,
2570
+ projectsRoot: config.projectsRoot,
2571
+ decks: config.decks.map((deck) => ({ ...deck })),
2572
+ },
2573
+ pointer: readLocationPointer(env),
2574
+ };
2575
+ switching = true;
2576
+ try {
2577
+ // 让一次 tick:切换本身是同步的,不让出的话并发请求根本看不到窗口,闸等于没装。
2578
+ await new Promise((resolveTurn) => setImmediate(resolveTurn));
2579
+ return await runSwitch(options);
2580
+ } catch (error) {
2581
+ // 回滚:配置、注册表、指针一起退回旧状态;service 只在成功后换,所以这里仍是旧实例。
2582
+ config.dataDir = snapshot.config.dataDir;
2583
+ config.dataRoot = snapshot.config.dataRoot;
2584
+ config.projectsRoot = snapshot.config.projectsRoot;
2585
+ config.decks.length = 0;
2586
+ for (const deck of snapshot.config.decks) config.decks.push(deck);
2587
+ try {
2588
+ restoreLocationPointer(snapshot.pointer, env);
2589
+ } catch (rollbackError) {
2590
+ ctx.logger?.warn?.(`${name}: 回滚指针文件失败:${rollbackError?.message ?? rollbackError}`);
2591
+ }
2592
+ if (error && typeof error === "object") {
2593
+ if (!error.code) error.code = "switch-failed";
2594
+ error.rolledBack = true;
2595
+ }
2596
+ throw error;
2597
+ } finally {
2598
+ switching = false;
2599
+ }
2600
+ }
2601
+
2602
+ /**
2603
+ * 切换执行体(规格 §2「切到这个目录」)。
2604
+ * 顺序:env 覆盖拦 → 校验目标(存在/可写/有这本 deck)→ **写指针** → 重建服务 → 记事件。
2605
+ * 校验不通过**不写指针**,避免留下「指针指向一个不可用目录」的半截状态。
2606
+ */
2607
+ function runSwitch({ dataRoot, projectsRoot = "", setBy = "settings", deckId = null } = {}) {
2608
+ const current = config.dataDir ?? {};
2609
+ if (current.envOverride?.active) {
2610
+ const error = new Error(`由环境变量 ${DATA_DIR_ENV} 指定(只读)`);
2611
+ error.code = "env-override";
2612
+ throw error;
2613
+ }
2614
+ const target = dirFacts(dataRoot, env);
2615
+ if (!target.value) {
2616
+ const error = new Error("数据目录不能为空");
2617
+ error.code = "bad-path";
2618
+ throw error;
2619
+ }
2620
+ if (!isAbsolute(target.value)) {
2621
+ const error = new Error(`数据目录必须是绝对路径:${target.value}`);
2622
+ error.code = "bad-path";
2623
+ throw error;
2624
+ }
2625
+ if (!target.exists) {
2626
+ const error = new Error(`目录不存在:${target.value}`);
2627
+ error.code = "not-found";
2628
+ throw error;
2629
+ }
2630
+ if (!target.writable) {
2631
+ const error = new Error(`目录不可写:${target.value}`);
2632
+ error.code = "not-writable";
2633
+ throw error;
2634
+ }
2635
+ if (deckId) {
2636
+ const health = deckDataHealth(target.value, deckId);
2637
+ if (!health.deckDirExists) {
2638
+ const error = new Error(`这个目录里没有这本 deck 的运行时数据:${join(target.value, "decks", deckId)}`);
2639
+ error.code = "deck-not-there";
2640
+ throw error;
2641
+ }
2642
+ }
2643
+ const from = config.dataRoot;
2644
+ const written = writeLocationPointer({ dataRoot: target.value, projectsRoot, setBy }, env);
2645
+ const resolved = resolveDataDir({ env, profileDataRoot: config.profileDataRoot, profileProjectsRoot: config.profileProjectsRoot, logger: ctx.logger });
2646
+ config.dataDir = resolved;
2647
+ config.dataRoot = resolved.dataRoot.value;
2648
+ config.projectsRoot = resolved.projectsRoot.value;
2649
+ // 注册表跟着 dataRoot 走:先回到 profile 声明的那批,再由 createService 补新目录的 decks-registry.json。
2650
+ config.decks.length = 0;
2651
+ for (const deck of profileDecks) config.decks.push({ ...deck });
2652
+ // 切目录不重新自举示例 deck(bootstrap 只对全新安装有意义,否则会往用户选的目标目录塞内容)。
2653
+ const bootstrap = config.bootstrap;
2654
+ config.bootstrap = false;
2655
+ let next = null;
2656
+ try {
2657
+ next = createService(config, ctx.logger, env);
2658
+ } finally {
2659
+ config.bootstrap = bootstrap;
2660
+ }
2661
+ // 新实例建好后才拆旧工具、换指针:中途抛错时旧服务仍在服务,外层 catch 回滚配置与指针。
2662
+ unregisterDeckTools();
2663
+ service = next;
2664
+ registerDeckTools();
2665
+ service.store.recordEvent({
2666
+ type: "deck.dataDir.switch",
2667
+ deckId,
2668
+ actor: "user",
2669
+ status: "succeeded",
2670
+ payload: { from, to: config.dataRoot, trigger: setBy, pointer: written.path },
2671
+ });
2672
+ return { ok: true, switched: { from, to: config.dataRoot }, dataDir: dataDirStateOf(service, deckId, { refresh: true }) };
2673
+ }
2674
+
2675
+ /**
2676
+ * deck 工具注册(preset 子树挂载 config.tools=true 才注册)。
2677
+ * P0:抽成函数,切 dataRoot 后重注册——工具持有 store / workspace / adapters,不重注册会继续写旧目录。
2678
+ */
2679
+ function registerDeckTools() {
2680
+ if (!config.tools) return;
2681
+ const toolRuntime = ctx.get("tools");
2682
+ if (!toolRuntime?.register) {
2683
+ ctx.logger?.warn?.(`${name}: config.tools=true 但 tools 服务不可用——deck 工具未注册`);
2684
+ return;
2685
+ }
2686
+ const tools = defineDeckTools({
2687
+ store: service.store,
2688
+ workspace: service.workspace,
2689
+ adapters: service.adapters,
2690
+ config,
2691
+ env,
2692
+ repoRoot: repoRootFrom(PLUGIN_DIR),
2693
+ isLocalDeck: (deckId) => service.isLocalDeck(deckId),
2694
+ resolveRender: (deckId) => service.renderRuntime(deckId),
2695
+ createStyle: (payload) => service.createStyleFromDeck(payload),
2696
+ subagents: () => ctx.get("subagents") ?? null,
2697
+ });
2698
+ for (const tool of tools) toolDisposers.push(toolRuntime.register(tool));
2699
+ ctx.logger?.info?.(`${name}: deck tools registered (${tools.map((tool) => tool.name).join(", ")})`);
2700
+ }
2701
+
2702
+ function unregisterDeckTools() {
2703
+ for (const dispose of toolDisposers) {
2704
+ try {
2705
+ dispose();
2706
+ } catch {
2707
+ /* 已卸载 / 运行时已销毁:忽略 */
2708
+ }
2709
+ }
2710
+ toolDisposers = [];
2711
+ }
2712
+
2713
+ routeWhenIdle(ROUTES.status, () => service.status());
2714
+ routeWhenIdle(ROUTES.state, () => service.state());
2715
+ routeWhenIdle(ROUTES.workbench, (request) => service.workbench(new URL(request?.url ?? "/", "http://localhost").searchParams.get("deckId")));
2716
+ // P0:数据目录(GET 读状态 / POST 切换)——顶栏黄条、首次引导、设置页共用。
2717
+ // 规格 §2「切到这个目录」:校验可读 → 写指针 → 重建 manifest → 回原 deck → 记事件 deck.dataDir.switch。
2718
+ // 切换窗口内新请求立即 409 switching(不排队);切换失败已回滚,返回的 error.code 稳定不变。
2719
+ registerRoute(
2720
+ ROUTES.dataDir,
2721
+ async (request) => {
2722
+ if (switching) return switchingResponse();
2723
+ const url = new URL(request?.url ?? "/", "http://localhost");
2724
+ if ((request?.method ?? "GET").toUpperCase() === "POST") {
2725
+ const body = await request.json().catch(() => ({}));
2726
+ try {
2727
+ return json(await switchDataDir({ dataRoot: body?.dataRoot, projectsRoot: body?.projectsRoot ?? "", setBy: body?.setBy ?? "settings", deckId: body?.deckId ?? null }));
2728
+ } catch (error) {
2729
+ const code = error?.code ?? "switch-failed";
2730
+ const conflict = code === "env-override" || code === "deck-not-there" || code === "switching";
2731
+ const badInput = code === "bad-path" || code === "not-found" || code === "not-writable";
2732
+ return json({ ok: false, error: { code, message: String(error?.message ?? error), rolledBack: error?.rolledBack === true } }, conflict ? 409 : badInput ? 400 : 500);
2733
+ }
2734
+ }
2735
+ try {
2736
+ return json(dataDirStateOf(service, url.searchParams.get("deckId") || null, { refresh: url.searchParams.get("refresh") === "1" }));
2737
+ } catch (error) {
2738
+ return json({ ok: false, error: { code: "check-failed", message: String(error?.message ?? error) } }, 500);
2739
+ }
2740
+ },
2741
+ ["GET", "POST"],
2742
+ );
2743
+
2744
+ /** 目录浏览:一次最多列 500 个子目录(超出置 truncated),deckCount 最多数 200 个 deck 就停。 */
2745
+ const BROWSE_MAX_ENTRIES = 500;
2746
+ const BROWSE_DECK_COUNT_CAP = 200;
2747
+
2748
+ /**
2749
+ * 一个子目录里有几本 deck 有正文(`decks/<id>/script/current.md`)。
2750
+ * 只做存在性判断:不递归、不读文件内容、单个 deck 判断失败就跳过。
2751
+ */
2752
+ function countDecksIn(childPath) {
2753
+ const decksDir = join(childPath, "decks");
2754
+ let entries;
2755
+ try {
2756
+ entries = readdirSync(decksDir, { withFileTypes: true });
2757
+ } catch {
2758
+ return 0;
2759
+ }
2760
+ let count = 0;
2761
+ for (const entry of entries) {
2762
+ if (count >= BROWSE_DECK_COUNT_CAP) break;
2763
+ if (!entry.isDirectory()) continue;
2764
+ if (existsSync(join(decksDir, entry.name, "script", "current.md"))) count += 1;
2765
+ }
2766
+ return count;
2767
+ }
2768
+
2769
+ /**
2770
+ * 列一个目录的**子目录**(目录选择器数据源)。
2771
+ * 只返回目录名与「这里有 N 本 deck」的粗判断:不读文件内容、不返回大小 / 权限 / 环境变量。
2772
+ * @param {string} pathValue 绝对路径;空 = 当前生效的 dataRoot
2773
+ * @returns {{path: string, parent: string, isRoot: boolean, truncated: boolean, dirs: {name: string, path: string, hidden: boolean, hasScript: boolean, deckCount: number, isDataRoot: boolean}[]}}
2774
+ */
2775
+ function browseDir(pathValue) {
2776
+ const raw = String(pathValue ?? "").trim();
2777
+ if (raw && !isAbsolute(raw)) {
2778
+ const error = new Error(`必须是绝对路径:${raw}`);
2779
+ error.code = "bad-path";
2780
+ throw error;
2781
+ }
2782
+ const currentDataRoot = String(config.dataRoot ?? "");
2783
+ const target = resolve(raw || currentDataRoot || process.cwd());
2784
+ let stats;
2785
+ try {
2786
+ stats = statSync(target);
2787
+ } catch {
2788
+ const error = new Error(`目录不存在:${target}`);
2789
+ error.code = "not-found";
2790
+ throw error;
2791
+ }
2792
+ if (!stats.isDirectory()) {
2793
+ const error = new Error(`不是目录:${target}`);
2794
+ error.code = "not-a-dir";
2795
+ throw error;
2796
+ }
2797
+ let entries;
2798
+ try {
2799
+ entries = readdirSync(target, { withFileTypes: true });
2800
+ } catch (error) {
2801
+ const failed = new Error(`读不到目录:${target}(${error?.message ?? error})`);
2802
+ failed.code = "browse-failed";
2803
+ throw failed;
2804
+ }
2805
+ const dirs = [];
2806
+ let truncated = false;
2807
+ for (const entry of entries) {
2808
+ if (dirs.length >= BROWSE_MAX_ENTRIES) {
2809
+ truncated = true;
2810
+ break;
2811
+ }
2812
+ const childPath = join(target, entry.name);
2813
+ // 目录判断:普通目录直接用 Dirent;符号链接 / 未知类型才 stat,单个失败就跳过(权限问题不该让整页崩)。
2814
+ let childIsDir = entry.isDirectory();
2815
+ if (!childIsDir && (entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory()))) {
2816
+ try {
2817
+ childIsDir = statSync(childPath).isDirectory();
2818
+ } catch {
2819
+ childIsDir = false;
2820
+ }
2821
+ }
2822
+ if (!childIsDir) continue;
2823
+ dirs.push({
2824
+ name: entry.name,
2825
+ path: childPath,
2826
+ hidden: entry.name.startsWith("."),
2827
+ hasScript: existsSync(join(childPath, "script", "current.md")),
2828
+ deckCount: countDecksIn(childPath),
2829
+ isDataRoot: Boolean(currentDataRoot) && resolve(currentDataRoot) === childPath,
2830
+ });
2831
+ }
2832
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
2833
+ const parentPath = dirname(target);
2834
+ return {
2835
+ path: target,
2836
+ parent: parentPath,
2837
+ isRoot: parentPath === target,
2838
+ truncated,
2839
+ dirs,
2840
+ };
2841
+ }
2842
+
2843
+ // 目录浏览(目录选择器):GET ?path=<绝对路径>;缺省 = 当前 dataRoot。
2844
+ // 只列子目录名 + deckCount,不读内容、不返回大小 / 权限 / 环境变量;切换窗口内复用切换闸 409。
2845
+ registerRoute(ROUTES.dirBrowse, (request) => {
2846
+ if (switching) return switchingResponse();
2847
+ const url = new URL(request?.url ?? "/", "http://localhost");
2848
+ try {
2849
+ return json({ ok: true, dir: browseDir(url.searchParams.get("path") ?? "") });
2850
+ } catch (error) {
2851
+ const code = error?.code ?? "browse-failed";
2852
+ const status = code === "switching" ? 409 : code === "browse-failed" ? 500 : 400;
2853
+ return json({ ok: false, error: { code, message: String(error?.message ?? error) } }, status);
2854
+ }
2855
+ }, ["GET"]);
2856
+
2857
+ // P0:设置页第一屏的自检整表(含后端生成的脱敏文本,前端只负责复制)。
2858
+ registerRoute(ROUTES.selfCheck, () => {
2859
+ if (switching) return switchingResponse();
2860
+ try {
2861
+ return json(selfCheckOf(service));
2862
+ } catch (error) {
2863
+ return json({ ok: false, error: { code: "self-check-failed", message: String(error?.message ?? error) } }, 500);
2864
+ }
2865
+ }, ["GET"]);
2338
2866
  registerRoute(
2339
2867
  ROUTES.app,
2340
2868
  () => new Response(workbenchPage(), { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } }),
@@ -2662,25 +3190,15 @@ export function apply(ctx, rawConfig) {
2662
3190
 
2663
3191
  ctx.effect(
2664
3192
  () => async () => {
3193
+ unregisterDeckTools();
2665
3194
  await Promise.allSettled(disposers.map((dispose) => dispose()));
2666
3195
  },
2667
3196
  `${name}: fetch routes`,
2668
3197
  );
2669
3198
 
2670
3199
  // preset 子树挂载(config.tools = true)才注册工具;"tools" 服务不存在时显式报错,
2671
- // 不静默吞——「工具没挂上」必须可见。
2672
- if (config.tools) {
2673
- const toolRuntime = ctx.get("tools");
2674
- if (!toolRuntime?.register) {
2675
- ctx.logger?.warn?.(`${name}: config.tools=true 但 tools 服务不可用——deck 工具未注册`);
2676
- } else {
2677
- const tools = defineDeckTools({ store: service.store, workspace: service.workspace, adapters: service.adapters, config, env: process.env, repoRoot: repoRootFrom(PLUGIN_DIR), isLocalDeck: (deckId) => service.isLocalDeck(deckId), resolveRender: (deckId) => service.renderRuntime(deckId), createStyle: (payload) => service.createStyleFromDeck(payload), subagents: () => ctx.get("subagents") ?? null });
2678
- for (const tool of tools) {
2679
- disposers.push(toolRuntime.register(tool));
2680
- }
2681
- ctx.logger?.info?.(`${name}: deck tools registered (${tools.map((tool) => tool.name).join(", ")})`);
2682
- }
2683
- }
3200
+ // 不静默吞——「工具没挂上」必须可见。P0:抽成函数,切 dataRoot 后要重注册(工具持有 store / adapters)。
3201
+ registerDeckTools();
2684
3202
 
2685
3203
  // 服务树里的 deckStudio 是单例:**只由根挂载(routes 开启)提供**。
2686
3204
  // preset 子树里的工具挂载不能再 provide——Cordis 同一 realm 内重复 provide 会抛
@@ -2688,7 +3206,7 @@ export function apply(ctx, rawConfig) {
2688
3206
  // 新会话报 agent-preset/invalid(2026-10-20 真机踩到并复现)。
2689
3207
  if (config.routes) {
2690
3208
  try {
2691
- ctx.provide("deckStudio", service);
3209
+ ctx.provide("deckStudio", deckStudioFacade);
2692
3210
  } catch (error) {
2693
3211
  ctx.logger?.debug?.(`${name}: deckStudio 已有提供方,跳过重复 provide(${error?.message ?? error})`);
2694
3212
  }