dsh-taskboard 0.2.2 → 0.4.0

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.
Files changed (43) hide show
  1. package/README.md +62 -6
  2. package/lib/client.js +1839 -74
  3. package/lib/host/execution.js +199 -54
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +327 -0
  6. package/lib/host/git.js.map +1 -0
  7. package/lib/host/protocol-text.js +5 -3
  8. package/lib/host/protocol-text.js.map +1 -1
  9. package/lib/host/routes.js +435 -5
  10. package/lib/host/routes.js.map +1 -1
  11. package/lib/host/store.js +12 -0
  12. package/lib/host/store.js.map +1 -1
  13. package/lib/host/templates.js +166 -0
  14. package/lib/host/templates.js.map +1 -0
  15. package/lib/host/tools.js +217 -3
  16. package/lib/host/tools.js.map +1 -1
  17. package/lib/index.js +23 -3
  18. package/lib/index.js.map +1 -1
  19. package/lib/shared/api.js.map +1 -1
  20. package/lib/shared/protocol.js +286 -1
  21. package/lib/shared/protocol.js.map +1 -1
  22. package/package.json +74 -74
  23. package/src/client/api.ts +47 -3
  24. package/src/client/board/ImportModal.tsx +182 -0
  25. package/src/client/board/TaskBoard.tsx +118 -3
  26. package/src/client/board/TaskCard.tsx +9 -0
  27. package/src/client/board/TaskDetail.tsx +360 -4
  28. package/src/client/board/TaskFormModal.tsx +193 -12
  29. package/src/client/board/TemplateManager.tsx +121 -0
  30. package/src/client/controller.ts +238 -11
  31. package/src/client/index.ts +18 -1
  32. package/src/client/styles.ts +198 -0
  33. package/src/host/execution.ts +301 -67
  34. package/src/host/git.ts +370 -0
  35. package/src/host/protocol-text.ts +5 -3
  36. package/src/host/routes.ts +483 -5
  37. package/src/host/store.ts +13 -0
  38. package/src/host/templates.ts +143 -0
  39. package/src/host/tools.ts +215 -2
  40. package/src/index.ts +30 -1
  41. package/src/shared/api.ts +89 -3
  42. package/src/shared/protocol.ts +408 -0
  43. package/src/shared/version.ts +1 -1
@@ -1,8 +1,45 @@
1
- import { asStatus, asUrgency, canTransition, newCommentId, newTaskId, normalizeBody, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim } from "../shared/protocol.js";
1
+ import { asIsolation, asStatus, asUrgency, canTransition, checklistFromTexts, newCommentId, newTaskId, normalizeBody, normalizeChecklist, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim, validateLedgerImport } from "../shared/protocol.js";
2
+ import { WORKTREE_DIR, worktreePathOf } from "./git.js";
2
3
  import { ROUTE_PREFIX, SSE_PATH } from "../shared/api.js";
4
+ import { join } from "node:path";
5
+ import { readdir, rm } from "node:fs/promises";
3
6
  //#region src/host/routes.ts
4
7
  /** Heartbeat cadence for the SSE stream. */
5
8
  const HEARTBEAT_MS = 2e4;
9
+ /** How long a workspace git-detection result stays cached (fail-soft). */
10
+ const GIT_DETECT_TTL_MS = 6e4;
11
+ /** Validate a template's task spec (routes-side, unknown → invalid_input). */
12
+ function normalizeTemplateSpec(raw) {
13
+ if (typeof raw !== "object" || raw === null) throw new Error("Error: invalid_input: task must be an object");
14
+ const e = raw;
15
+ const spec = {};
16
+ const str = (key) => {
17
+ const v = e[key];
18
+ if (v === void 0) return void 0;
19
+ if (typeof v !== "string") throw new Error(`Error: invalid_input: task.${key} must be a string`);
20
+ return v;
21
+ };
22
+ const title = str("title");
23
+ const description = str("description");
24
+ const prompt = str("prompt");
25
+ const urgency = str("urgency");
26
+ const isolation = str("isolation");
27
+ const presetId = str("presetId");
28
+ if (title !== void 0) spec.title = normalizeTitle(title);
29
+ if (description !== void 0) spec.description = description;
30
+ if (prompt !== void 0) spec.prompt = normalizePrompt(prompt);
31
+ if (urgency !== void 0) spec.urgency = asUrgency(urgency);
32
+ if (isolation !== void 0) spec.isolation = asIsolation(isolation);
33
+ if (presetId !== void 0 && presetId.trim().length > 0) spec.presetId = presetId.trim();
34
+ if (e.execution !== void 0) spec.execution = normalizeExecution(e.execution, Date.now());
35
+ if (e.model !== void 0) spec.model = normalizeModel(e.model);
36
+ if (e.checklist !== void 0) {
37
+ if (!Array.isArray(e.checklist) || e.checklist.some((c) => typeof c !== "string")) throw new Error("Error: invalid_input: task.checklist must be an array of strings");
38
+ checklistFromTexts(e.checklist);
39
+ spec.checklist = e.checklist;
40
+ }
41
+ return spec;
42
+ }
6
43
  /** Validate a pinned model: structural check always, provider route when known. */
7
44
  function checkModel(raw, modelProviders) {
8
45
  const model = normalizeModel(raw);
@@ -55,6 +92,11 @@ function num(body, key) {
55
92
  if (v === void 0) return void 0;
56
93
  return typeof v === "number" && Number.isFinite(v) ? v : null;
57
94
  }
95
+ /** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
96
+ function normalizePresetId(raw) {
97
+ const t = (raw ?? "").trim();
98
+ return t.length === 0 ? void 0 : t;
99
+ }
58
100
  /** Map a thrown domain error to the envelope. */
59
101
  function toFail(error) {
60
102
  const message = error instanceof Error ? error.message : String(error);
@@ -89,9 +131,72 @@ function registerTaskboardRoutes(ctx, options) {
89
131
  for (const res of subscribers) res.write(frame);
90
132
  };
91
133
  store.subscribe(broadcast);
134
+ const gitCache = /* @__PURE__ */ new Map();
135
+ const gitHinted = /* @__PURE__ */ new Set();
136
+ /** Whether <root>/.gitignore (missing file counts as missing) ignores our worktree dir. */
137
+ const gitignoreMissing = async (path) => {
138
+ try {
139
+ const { readFile } = await import("node:fs/promises");
140
+ return !(await readFile(join(path, ".gitignore"), "utf8")).split("\n").some((l) => {
141
+ const t = l.trim().replace(/\/+$/, "");
142
+ return t === ".dsh-worktrees" || t === `/.dsh-worktrees`;
143
+ });
144
+ } catch {
145
+ return true;
146
+ }
147
+ };
148
+ const gitAvailable = async (path) => {
149
+ if (options.git === void 0) return false;
150
+ const hit = gitCache.get(path);
151
+ if (hit !== void 0 && options.now() - hit.at < GIT_DETECT_TTL_MS) return hit.value;
152
+ let value = false;
153
+ try {
154
+ value = await options.git.detect(path);
155
+ } catch {}
156
+ gitCache.set(path, {
157
+ value,
158
+ at: options.now()
159
+ });
160
+ if (value && !gitHinted.has(path)) {
161
+ gitHinted.add(path);
162
+ if (await gitignoreMissing(path)) console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`);
163
+ }
164
+ return value;
165
+ };
166
+ /** List orphan worktree dirs: entries under <ws>/.dsh-worktrees owned by no ledger task. */
167
+ const listOrphanWorktrees = async () => {
168
+ const orphans = [];
169
+ const known = new Set(store.snapshot().tasks.map((t) => t.id));
170
+ for (const ws of workspaces.list()) {
171
+ let entries = [];
172
+ try {
173
+ entries = (await readdir(join(ws.path, WORKTREE_DIR), { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
174
+ } catch {}
175
+ for (const taskId of entries) if (!known.has(taskId)) orphans.push({
176
+ workspaceId: ws.id,
177
+ workspacePath: ws.path,
178
+ taskId,
179
+ path: worktreePathOf(ws.path, taskId)
180
+ });
181
+ }
182
+ return orphans;
183
+ };
184
+ /** Git-enabled workspaces whose .gitignore does not cover the worktree dir. */
185
+ const listGitignoreSuggestions = async () => {
186
+ const suggestions = [];
187
+ for (const ws of workspaces.list()) {
188
+ if (!await gitAvailable(ws.path)) continue;
189
+ if (await gitignoreMissing(ws.path)) suggestions.push({
190
+ workspaceId: ws.id,
191
+ workspacePath: ws.path
192
+ });
193
+ }
194
+ return suggestions;
195
+ };
92
196
  const handler = async (req, res) => {
93
197
  try {
94
- const pathname = new URL(req.url ?? "/", "http://x").pathname;
198
+ const url = new URL(req.url ?? "/", "http://x");
199
+ const pathname = url.pathname;
95
200
  if (req.method === "GET") {
96
201
  if (pathname === `/dsh-taskboard/state`) {
97
202
  await store.load();
@@ -102,9 +207,74 @@ function registerTaskboardRoutes(ctx, options) {
102
207
  return;
103
208
  }
104
209
  if (pathname === `/dsh-taskboard/workspaces`) {
210
+ const list = workspaces.list();
211
+ const flags = await Promise.all(list.map((ws) => gitAvailable(ws.path)));
105
212
  json(res, {
106
213
  ok: true,
107
- value: workspaces.list()
214
+ value: list.map((ws, i) => ({
215
+ ...ws,
216
+ sessionCount: 0,
217
+ gitAvailable: flags[i]
218
+ }))
219
+ });
220
+ return;
221
+ }
222
+ if (pathname === `/dsh-taskboard/diagnostics`) {
223
+ const ledger = store.snapshot();
224
+ let staleRunning = 0;
225
+ for (const t of ledger.tasks) for (const e of t.executions) if (e.outcome === "running") staleRunning += 1;
226
+ json(res, {
227
+ ok: true,
228
+ value: {
229
+ revision: ledger.revision,
230
+ tasks: ledger.tasks.length,
231
+ staleRunning,
232
+ orphanWorktrees: await listOrphanWorktrees(),
233
+ gitIgnoreSuggestions: await listGitignoreSuggestions()
234
+ }
235
+ });
236
+ return;
237
+ }
238
+ const diffMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`));
239
+ if (diffMatch !== null) {
240
+ try {
241
+ if (options.git === void 0) {
242
+ json(res, fail("invalid_input", "git integration unavailable").res, 501);
243
+ return;
244
+ }
245
+ const task = store.get(diffMatch[1]);
246
+ if (task === void 0) throw new Error("Error: not_found: no such task");
247
+ const execution = task.executions.find((e) => e.id === url.searchParams.get("execution"));
248
+ if (execution === void 0) throw new Error("Error: not_found: no such execution");
249
+ const commit = url.searchParams.get("commit");
250
+ const filePath = url.searchParams.get("path");
251
+ const ws = workspaces.get(task.workspaceId);
252
+ if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
253
+ const cwd = execution.worktreePath ?? ws.path;
254
+ let result = commit !== null ? await options.git.showCommit(cwd, commit) : filePath !== null ? await options.git.showPathDiff(cwd, filePath, execution.baseCommit) : void 0;
255
+ if (result === void 0 && execution.worktreePath !== void 0 && cwd !== ws.path) result = commit !== null ? await options.git.showCommit(ws.path, commit) : filePath !== null && execution.baseCommit !== void 0 ? await options.git.showPathDiff(ws.path, filePath, execution.baseCommit) : void 0;
256
+ if (result === void 0) throw new Error("Error: invalid_input: 无法获取 diff(git 报错、对象不存在,或仅存于已删除的 worktree 且无基线)");
257
+ json(res, {
258
+ ok: true,
259
+ value: {
260
+ diff: result.text,
261
+ truncated: result.truncated
262
+ }
263
+ });
264
+ } catch (error) {
265
+ const f = toFail(error);
266
+ json(res, f.res, f.status);
267
+ }
268
+ return;
269
+ }
270
+ if (pathname === `/dsh-taskboard/templates`) {
271
+ if (options.templates === void 0) {
272
+ json(res, fail("invalid_input", "template store unavailable").res, 501);
273
+ return;
274
+ }
275
+ json(res, {
276
+ ok: true,
277
+ value: { templates: await options.templates.list() }
108
278
  });
109
279
  return;
110
280
  }
@@ -149,6 +319,15 @@ function registerTaskboardRoutes(ctx, options) {
149
319
  const status = str(body, "status") === null ? "todo" : asStatus(str(body, "status"));
150
320
  const execution = normalizeExecution(body.execution ?? {}, options.now());
151
321
  const model = body.model === void 0 ? void 0 : checkModel(body.model, options.modelProviders);
322
+ const isolationRaw = str(body, "isolation");
323
+ const isolation = isolationRaw === null ? void 0 : asIsolation(isolationRaw);
324
+ const presetId = normalizePresetId(str(body, "presetId"));
325
+ let checklist = void 0;
326
+ if (body.checklist !== void 0) {
327
+ if (!Array.isArray(body.checklist) || body.checklist.some((c) => typeof c !== "string")) throw new Error("Error: invalid_input: checklist must be an array of strings");
328
+ const texts = body.checklist.map((c) => c.trim()).filter((c) => c.length > 0);
329
+ if (texts.length > 0) checklist = checklistFromTexts(texts);
330
+ }
152
331
  const now = options.now();
153
332
  const task = {
154
333
  id: newTaskId(),
@@ -161,6 +340,9 @@ function registerTaskboardRoutes(ctx, options) {
161
340
  blocked: false,
162
341
  execution,
163
342
  model,
343
+ ...isolation !== void 0 ? { isolation } : {},
344
+ ...presetId !== void 0 ? { presetId } : {},
345
+ ...checklist !== void 0 ? { checklist } : {},
164
346
  version: 1,
165
347
  createdAt: now,
166
348
  updatedAt: now,
@@ -183,7 +365,7 @@ function registerTaskboardRoutes(ctx, options) {
183
365
  }
184
366
  return;
185
367
  }
186
- const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/(\\w+)$`));
368
+ const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`));
187
369
  if (actionMatch !== null) {
188
370
  const id = actionMatch[1];
189
371
  const action = actionMatch[2];
@@ -212,6 +394,19 @@ function registerTaskboardRoutes(ctx, options) {
212
394
  if (body.execution !== void 0) next.execution = normalizeExecution(body.execution, options.now());
213
395
  if (body.model === null) next.model = void 0;
214
396
  else if (body.model !== void 0) next.model = checkModel(body.model, options.modelProviders);
397
+ const isolationRaw = str(body, "isolation");
398
+ if (isolationRaw !== null) {
399
+ if (task.executions.length > 0 || task.status === "in_progress") throw new Error("Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改");
400
+ next.isolation = asIsolation(isolationRaw);
401
+ }
402
+ if (body.presetId === null) delete next.presetId;
403
+ else if (body.presetId !== void 0) next.presetId = normalizePresetId(str(body, "presetId"));
404
+ if (body.checklist === null) delete next.checklist;
405
+ else if (body.checklist !== void 0) {
406
+ const items = normalizeChecklist(body.checklist);
407
+ if (items.length > 0) next.checklist = items;
408
+ else delete next.checklist;
409
+ }
215
410
  next.version = task.version + 1;
216
411
  next.updatedAt = options.now();
217
412
  next.updatedBy = { kind: "user" };
@@ -306,6 +501,26 @@ function registerTaskboardRoutes(ctx, options) {
306
501
  if (action === "delete") {
307
502
  if (body.purge === true) {
308
503
  if (task.trashedAt === void 0) throw new Error("Error: invalid_input: purge requires a trashed task (soft-delete first)");
504
+ if (options.git !== void 0) {
505
+ const ws = workspaces.get(task.workspaceId);
506
+ if (ws !== void 0) {
507
+ const path = worktreePathOf(ws.path, id);
508
+ try {
509
+ await options.git.removeWorktree(ws.path, path);
510
+ } catch (error) {
511
+ const message = error instanceof Error ? error.message : String(error);
512
+ if (message.includes("未提交修改")) throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`);
513
+ if (/not a working tree|not a working-tree/i.test(message)) await rm(path, {
514
+ recursive: true,
515
+ force: true
516
+ });
517
+ else throw new Error(`Error: invalid_input: ${message}`);
518
+ }
519
+ if (task.branch !== void 0) try {
520
+ await options.git.deleteBranch(ws.path, task.branch);
521
+ } catch {}
522
+ }
523
+ }
309
524
  await store.mutate("task-deleted", (ledger) => {
310
525
  ledger.tasks = ledger.tasks.filter((t) => t.id !== id);
311
526
  return [];
@@ -338,7 +553,8 @@ function registerTaskboardRoutes(ctx, options) {
338
553
  json(res, fail("invalid_input", "execution service unavailable").res, 501);
339
554
  return;
340
555
  }
341
- const result = await options.run(id);
556
+ const runOptions = body.reuse === true ? { reuseWorktree: true } : void 0;
557
+ const result = await options.run(id, runOptions);
342
558
  if (result.ok) json(res, {
343
559
  ok: true,
344
560
  value: result
@@ -368,6 +584,92 @@ function registerTaskboardRoutes(ctx, options) {
368
584
  }
369
585
  return;
370
586
  }
587
+ if (action === "merge") {
588
+ if (options.git === void 0) {
589
+ json(res, fail("invalid_input", "git integration unavailable").res, 501);
590
+ return;
591
+ }
592
+ if (task.branch === void 0) throw new Error("Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)");
593
+ if (task.status === "in_progress") throw new Error("Error: invalid_input: 任务执行中,不能合并");
594
+ if (task.executions.some((e) => e.outcome === "running")) throw new Error("Error: invalid_input: 任务执行中,不能合并");
595
+ const ws = workspaces.get(task.workspaceId);
596
+ if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
597
+ let noop = false;
598
+ try {
599
+ noop = await options.git.isAncestor(ws.path, task.branch);
600
+ } catch {}
601
+ if (noop) {
602
+ json(res, {
603
+ ok: true,
604
+ value: {
605
+ merged: false,
606
+ noop: true,
607
+ branch: task.branch
608
+ }
609
+ });
610
+ return;
611
+ }
612
+ try {
613
+ await options.git.merge(ws.path, task.branch);
614
+ } catch (error) {
615
+ throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
616
+ }
617
+ const mergedComment = {
618
+ id: newCommentId(),
619
+ body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`),
620
+ version: 1,
621
+ createdAt: options.now()
622
+ };
623
+ const next = structuredClone(task);
624
+ next.comments.push(mergedComment);
625
+ next.version = task.version + 1;
626
+ next.updatedAt = options.now();
627
+ await store.mutate("comment-added", (ledger) => {
628
+ const i = ledger.tasks.findIndex((t) => t.id === id);
629
+ ledger.tasks[i] = next;
630
+ return [next];
631
+ });
632
+ json(res, {
633
+ ok: true,
634
+ value: {
635
+ merged: true,
636
+ branch: task.branch
637
+ }
638
+ });
639
+ return;
640
+ }
641
+ if (action === "worktree-remove") {
642
+ if (options.git === void 0) {
643
+ json(res, fail("invalid_input", "git integration unavailable").res, 501);
644
+ return;
645
+ }
646
+ if (task.executions.some((e) => e.outcome === "running")) throw new Error("Error: invalid_input: 任务执行中,不能删除 worktree");
647
+ const ws = workspaces.get(task.workspaceId);
648
+ if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
649
+ const path = worktreePathOf(ws.path, id);
650
+ try {
651
+ await options.git.removeWorktree(ws.path, path);
652
+ } catch (error) {
653
+ throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
654
+ }
655
+ let branchDeleted = false;
656
+ let branchError;
657
+ if (body.deleteBranch === true && task.branch !== void 0) try {
658
+ await options.git.deleteBranch(ws.path, task.branch);
659
+ branchDeleted = true;
660
+ } catch (error) {
661
+ branchError = error instanceof Error ? error.message : String(error);
662
+ }
663
+ json(res, {
664
+ ok: true,
665
+ value: {
666
+ removed: true,
667
+ branchDeleted,
668
+ ...branchError !== void 0 ? { branchError } : {}
669
+ }
670
+ });
671
+ return;
672
+ }
371
673
  const f = fail("not_found", `unknown action ${action}`);
372
674
  json(res, f.res, f.status);
373
675
  } catch (error) {
@@ -376,6 +678,134 @@ function registerTaskboardRoutes(ctx, options) {
376
678
  }
377
679
  return;
378
680
  }
681
+ if (pathname === `/dsh-taskboard/worktree-cleanup`) {
682
+ try {
683
+ if (options.git === void 0) {
684
+ json(res, fail("invalid_input", "git integration unavailable").res, 501);
685
+ return;
686
+ }
687
+ const workspaceId = str(body, "workspaceId") ?? "";
688
+ const taskId = str(body, "taskId") ?? "";
689
+ const ws = workspaces.get(workspaceId);
690
+ if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
691
+ if (store.get(taskId) !== void 0) throw new Error("Error: invalid_input: 任务仍在看板中,请从任务详情页删除其 worktree");
692
+ const path = worktreePathOf(ws.path, taskId);
693
+ try {
694
+ await options.git.removeWorktree(ws.path, path);
695
+ } catch (error) {
696
+ const message = error instanceof Error ? error.message : String(error);
697
+ if (/not a working tree|not a working-tree/i.test(message)) await rm(path, {
698
+ recursive: true,
699
+ force: true
700
+ });
701
+ else throw new Error(`Error: invalid_input: ${message}`);
702
+ }
703
+ json(res, {
704
+ ok: true,
705
+ value: {
706
+ cleaned: true,
707
+ path
708
+ }
709
+ });
710
+ } catch (error) {
711
+ const f = toFail(error);
712
+ json(res, f.res, f.status);
713
+ }
714
+ return;
715
+ }
716
+ if (pathname === `/dsh-taskboard/import/preview`) {
717
+ try {
718
+ const plan = validateLedgerImport(body, new Set(store.snapshot().tasks.map((t) => t.id)), options.now());
719
+ json(res, {
720
+ ok: true,
721
+ value: { plan: {
722
+ create: plan.create.map((t) => ({
723
+ id: t.id,
724
+ title: t.title,
725
+ status: t.status
726
+ })),
727
+ overwrite: plan.overwrite.map((t) => ({
728
+ id: t.id,
729
+ title: t.title,
730
+ status: t.status
731
+ })),
732
+ invalid: plan.invalid
733
+ } }
734
+ });
735
+ } catch (error) {
736
+ const f = toFail(error);
737
+ json(res, f.res, f.status);
738
+ }
739
+ return;
740
+ }
741
+ if (pathname === `/dsh-taskboard/import`) {
742
+ try {
743
+ const mode = str(body, "mode") === "replace" ? "replace" : "merge";
744
+ const raw = body.ledger;
745
+ const plan = validateLedgerImport(raw, new Set(store.snapshot().tasks.map((t) => t.id)), options.now());
746
+ const imported = [...plan.create, ...plan.overwrite];
747
+ if (mode === "replace" && imported.length === 0) throw new Error("Error: invalid_input: 导入文件没有可导入的任务,已拒绝整册替换");
748
+ let backupFile;
749
+ if (mode === "replace" && store.snapshot().tasks.length > 0) backupFile = await store.backup();
750
+ let replacedTotal;
751
+ await store.mutate("task-created", (ledger) => {
752
+ if (mode === "replace") {
753
+ replacedTotal = ledger.tasks.length;
754
+ ledger.tasks = structuredClone(imported);
755
+ return ledger.tasks;
756
+ }
757
+ const byId = new Map(ledger.tasks.map((t) => [t.id, t]));
758
+ for (const task of imported) byId.set(task.id, structuredClone(task));
759
+ ledger.tasks = [...byId.values()];
760
+ return structuredClone(imported);
761
+ });
762
+ json(res, {
763
+ ok: true,
764
+ value: {
765
+ mode,
766
+ created: plan.create.length,
767
+ overwritten: plan.overwrite.length,
768
+ ...mode === "replace" ? { replacedTotal } : {},
769
+ ...backupFile !== void 0 ? { backupFile } : {}
770
+ }
771
+ });
772
+ } catch (error) {
773
+ const f = toFail(error);
774
+ json(res, f.res, f.status);
775
+ }
776
+ return;
777
+ }
778
+ if (pathname === `/dsh-taskboard/templates` || pathname === `/dsh-taskboard/templates/delete`) {
779
+ try {
780
+ if (options.templates === void 0) {
781
+ json(res, fail("invalid_input", "template store unavailable").res, 501);
782
+ return;
783
+ }
784
+ if (pathname.endsWith("/delete")) {
785
+ const id = str(body, "id") ?? "";
786
+ if (id.length === 0) throw new Error("Error: invalid_input: id required");
787
+ json(res, {
788
+ ok: true,
789
+ value: { deleted: await options.templates.remove(id) }
790
+ });
791
+ return;
792
+ }
793
+ const name = str(body, "name") ?? "";
794
+ if (name.trim().length === 0) throw new Error("Error: invalid_input: name required");
795
+ json(res, {
796
+ ok: true,
797
+ value: await options.templates.upsert({
798
+ id: str(body, "id") ?? void 0,
799
+ name,
800
+ task: normalizeTemplateSpec(body.task)
801
+ })
802
+ }, 201);
803
+ } catch (error) {
804
+ const f = toFail(error);
805
+ json(res, f.res, f.status);
806
+ }
807
+ return;
808
+ }
379
809
  res.writeHead(404);
380
810
  res.end();
381
811
  } catch (error) {