dsh-taskboard 0.6.6 → 0.7.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 (64) hide show
  1. package/README.md +305 -288
  2. package/lib/client.js +985 -931
  3. package/lib/host/archive-sessions.js +30 -0
  4. package/lib/host/archive-sessions.js.map +1 -0
  5. package/lib/host/assets.js +139 -0
  6. package/lib/host/assets.js.map +1 -0
  7. package/lib/host/execution.js +3 -0
  8. package/lib/host/execution.js.map +1 -1
  9. package/lib/host/locale.js +17 -0
  10. package/lib/host/locale.js.map +1 -0
  11. package/lib/host/routes.js +132 -6
  12. package/lib/host/routes.js.map +1 -1
  13. package/lib/host/scheduler.js +2 -0
  14. package/lib/host/scheduler.js.map +1 -1
  15. package/lib/host/session-sync.js +4 -1
  16. package/lib/host/session-sync.js.map +1 -1
  17. package/lib/host/storage-queue.js +14 -0
  18. package/lib/host/storage-queue.js.map +1 -0
  19. package/lib/host/storage.js +249 -0
  20. package/lib/host/storage.js.map +1 -0
  21. package/lib/host/store.js +34 -7
  22. package/lib/host/store.js.map +1 -1
  23. package/lib/host/templates.js +73 -113
  24. package/lib/host/templates.js.map +1 -1
  25. package/lib/host/tools.js +23 -8
  26. package/lib/host/tools.js.map +1 -1
  27. package/lib/index.js +83 -27
  28. package/lib/index.js.map +1 -1
  29. package/lib/shared/api.js.map +1 -1
  30. package/lib/shared/builtin-templates.js +155 -0
  31. package/lib/shared/builtin-templates.js.map +1 -0
  32. package/lib/shared/protocol.js +39 -2
  33. package/lib/shared/protocol.js.map +1 -1
  34. package/package.json +90 -89
  35. package/src/client/api.ts +35 -1
  36. package/src/client/board/SettingsModal.tsx +67 -4
  37. package/src/client/board/SlashPromptInput.tsx +80 -1
  38. package/src/client/board/TaskBoard.tsx +16 -11
  39. package/src/client/board/TaskDetail.tsx +186 -17
  40. package/src/client/board/TaskFormModal.tsx +8 -5
  41. package/src/client/board/TemplateManager.tsx +22 -10
  42. package/src/client/controller.ts +67 -5
  43. package/src/client/i18n/en.ts +35 -3
  44. package/src/client/i18n/templates.ts +25 -0
  45. package/src/client/i18n/zh.ts +35 -3
  46. package/src/client/image-insert.ts +29 -0
  47. package/src/client/styles.ts +35 -2
  48. package/src/host/archive-sessions.ts +18 -0
  49. package/src/host/assets.ts +120 -0
  50. package/src/host/execution.ts +4 -1
  51. package/src/host/locale.ts +44 -0
  52. package/src/host/routes.ts +132 -7
  53. package/src/host/scheduler.ts +2 -0
  54. package/src/host/session-sync.ts +4 -1
  55. package/src/host/storage-queue.ts +10 -0
  56. package/src/host/storage.ts +212 -0
  57. package/src/host/store.ts +40 -12
  58. package/src/host/templates.ts +38 -66
  59. package/src/host/tools.ts +28 -11
  60. package/src/index.ts +88 -33
  61. package/src/shared/api.ts +32 -3
  62. package/src/shared/builtin-templates.ts +153 -0
  63. package/src/shared/protocol.ts +64 -0
  64. package/src/shared/version.ts +9 -9
@@ -2,6 +2,9 @@ import { asBoardSettings, asIsolation, asPermission, asStatus, asUrgency, canTra
2
2
  import { WORKTREE_DIR, worktreePathOf } from "./git.js";
3
3
  import { removeMirror, repoMainPath } from "./isolation.js";
4
4
  import { createRepoScanner } from "./repos.js";
5
+ import { archiveTaskSessions } from "./archive-sessions.js";
6
+ import { activeHostLocale } from "./locale.js";
7
+ import { MAX_ASSET_BYTES } from "./assets.js";
5
8
  import { ROUTE_PREFIX, SSE_PATH } from "../shared/api.js";
6
9
  import { ERR, ToolError } from "./tools.js";
7
10
  import { join, resolve, sep } from "node:path";
@@ -15,6 +18,7 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024;
15
18
  const TASK_DIFF_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`);
16
19
  const TASK_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`);
17
20
  const TASK_ACTION_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`);
21
+ const ASSET_RE = new RegExp(`^${ROUTE_PREFIX}/assets/([a-f0-9]{64}\\.(?:png|jpg|gif|webp))$`);
18
22
  /** How long a workspace git-detection result stays cached (fail-soft). */
19
23
  const GIT_DETECT_TTL_MS = 6e4;
20
24
  /** Validate a template's task spec (routes-side, unknown → invalid_input). */
@@ -101,6 +105,18 @@ async function readBody(req) {
101
105
  return null;
102
106
  }
103
107
  }
108
+ /** Read one bounded binary upload without ever buffering beyond the file cap. */
109
+ async function readBytes(req, limit) {
110
+ const chunks = [];
111
+ let total = 0;
112
+ for await (const chunk of req) {
113
+ const bytes = chunk;
114
+ total += bytes.length;
115
+ if (total > limit) throw new Error("body too large");
116
+ chunks.push(bytes);
117
+ }
118
+ return Buffer.concat(chunks);
119
+ }
104
120
  /** String field accessor (null when absent/not a string). */
105
121
  function str(body, key) {
106
122
  const v = body[key];
@@ -228,7 +244,11 @@ function registerTaskboardRoutes(ctx, options) {
228
244
  } catch {}
229
245
  if (rootRepo && !gitHinted.has(path)) {
230
246
  gitHinted.add(path);
231
- if (await gitignoreMissing(path)) console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`);
247
+ if (await gitignoreMissing(path)) {
248
+ const file = `${path}/.gitignore`;
249
+ const hint = activeHostLocale(ctx) === "zh" ? `建议在 ${file} 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)` : `suggests adding one line to ${file}: ${WORKTREE_DIR}/ to hide the task worktree directory (no automatic edits)`;
250
+ console.info(`[dsh-taskboard] ${hint}`);
251
+ }
232
252
  }
233
253
  let nestedCount = 0;
234
254
  try {
@@ -281,11 +301,44 @@ function registerTaskboardRoutes(ctx, options) {
281
301
  const url = new URL(req.url ?? "/", "http://x");
282
302
  const pathname = url.pathname;
283
303
  if (req.method === "GET") {
304
+ if (pathname === `/dsh-taskboard/storage`) {
305
+ if (options.storage === void 0) {
306
+ res.writeHead(501);
307
+ res.end();
308
+ return;
309
+ }
310
+ json(res, {
311
+ ok: true,
312
+ value: await options.storage.status()
313
+ });
314
+ return;
315
+ }
316
+ await options.ready?.();
317
+ const assetMatch = pathname.match(ASSET_RE);
318
+ if (assetMatch !== null) {
319
+ const asset = await options.assets?.read(assetMatch[1]);
320
+ if (asset === void 0) {
321
+ res.writeHead(404);
322
+ res.end();
323
+ return;
324
+ }
325
+ res.writeHead(200, {
326
+ "content-type": asset.mime,
327
+ "content-length": asset.bytes.length,
328
+ "cache-control": "public, max-age=31536000, immutable",
329
+ "x-content-type-options": "nosniff"
330
+ });
331
+ res.end(asset.bytes);
332
+ return;
333
+ }
284
334
  if (pathname === `/dsh-taskboard/state`) {
285
335
  await store.load();
286
336
  json(res, {
287
337
  ok: true,
288
- value: store.snapshot()
338
+ value: {
339
+ ...store.snapshot(),
340
+ capabilities: { archiveSessions: typeof workspaces.archiveSession === "function" }
341
+ }
289
342
  });
290
343
  return;
291
344
  }
@@ -426,6 +479,31 @@ function registerTaskboardRoutes(ctx, options) {
426
479
  res.end();
427
480
  return;
428
481
  }
482
+ if (pathname === `/dsh-taskboard/assets`) {
483
+ await options.ready?.();
484
+ if (options.assets === void 0) {
485
+ json(res, fail("invalid_input", "image attachments unavailable").res, 501);
486
+ return;
487
+ }
488
+ if (req.headers["x-dsh-taskboard-upload"] !== "1") {
489
+ json(res, fail("forbidden", "missing upload header").res, 403);
490
+ return;
491
+ }
492
+ const declaredMime = String(req.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase();
493
+ try {
494
+ const bytes = await readBytes(req, MAX_ASSET_BYTES);
495
+ await options.assets.cleanup(JSON.stringify(store.snapshot()));
496
+ json(res, {
497
+ ok: true,
498
+ value: await options.assets.put(bytes, declaredMime)
499
+ }, 201);
500
+ } catch (error) {
501
+ const message = error instanceof Error ? error.message : String(error);
502
+ const status = message.includes("1..") ? 413 : message.includes("quota") ? 507 : 400;
503
+ json(res, fail("invalid_input", message).res, status);
504
+ }
505
+ return;
506
+ }
429
507
  if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
430
508
  json(res, fail("invalid_input", "content-type must be application/json").res, 415);
431
509
  return;
@@ -441,6 +519,24 @@ function registerTaskboardRoutes(ctx, options) {
441
519
  json(res, fail("invalid_input", "body is not a JSON object").res, 400);
442
520
  return;
443
521
  }
522
+ if (pathname === `/dsh-taskboard/storage/check` || pathname === `/dsh-taskboard/storage/migrate`) {
523
+ if (options.storage === void 0) {
524
+ json(res, fail("invalid_input", "storage configuration unavailable").res, 501);
525
+ return;
526
+ }
527
+ try {
528
+ const directory = str(body, "directory") ?? "";
529
+ json(res, {
530
+ ok: true,
531
+ value: pathname.endsWith("/check") ? await options.storage.check(directory) : await options.storage.migrate(directory)
532
+ });
533
+ } catch (error) {
534
+ const f = fail("invalid_input", error instanceof Error ? error.message : String(error));
535
+ json(res, f.res, f.status);
536
+ }
537
+ return;
538
+ }
539
+ await options.ready?.();
444
540
  if (pathname === `/dsh-taskboard/tasks`) {
445
541
  try {
446
542
  const title = normalizeTitle(str(body, "title") ?? "");
@@ -507,6 +603,14 @@ function registerTaskboardRoutes(ctx, options) {
507
603
  try {
508
604
  const task = store.get(id);
509
605
  if (task === void 0) throw new Error("Error: not_found: no such task");
606
+ if (action === "archive-sessions") {
607
+ if (task.trashedAt !== void 0 || task.status !== "archived") throw new Error("Error: invalid_transition: only archived live tasks can retry session archiving");
608
+ json(res, {
609
+ ok: true,
610
+ value: await archiveTaskSessions(task, workspaces.archiveSession)
611
+ });
612
+ return;
613
+ }
510
614
  if (action === "update") {
511
615
  const ifVersion = num(body, "ifVersion");
512
616
  if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
@@ -563,13 +667,16 @@ function registerTaskboardRoutes(ctx, options) {
563
667
  if (action === "move") {
564
668
  const ifVersion = num(body, "ifVersion");
565
669
  const status = str(body, "status") ?? "";
670
+ const archiveSessions = body.archiveSessions === true;
566
671
  if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
567
672
  const to = asStatus(status);
568
673
  let next;
674
+ let beforeTask;
569
675
  await store.mutate("task-moved", (ledger) => {
570
676
  const { index, task } = liveTaskAt(ledger, id);
571
677
  if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
572
678
  if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`);
679
+ beforeTask = task;
573
680
  next = structuredClone(task);
574
681
  next.status = to;
575
682
  next.version = task.version + 1;
@@ -580,9 +687,13 @@ function registerTaskboardRoutes(ctx, options) {
580
687
  ledger.tasks[index] = next;
581
688
  return [next];
582
689
  });
690
+ const sessionArchive = to === "archived" && archiveSessions ? await archiveTaskSessions(beforeTask ?? next, workspaces.archiveSession) : void 0;
583
691
  json(res, {
584
692
  ok: true,
585
- value: summarize(next)
693
+ value: {
694
+ ...summarize(next),
695
+ ...sessionArchive !== void 0 ? { sessionArchive } : {}
696
+ }
586
697
  });
587
698
  return;
588
699
  }
@@ -799,12 +910,17 @@ function registerTaskboardRoutes(ctx, options) {
799
910
  });
800
911
  }
801
912
  }
802
- const pushComment = (body) => store.mutate("comment-added", (ledger) => {
913
+ const pushComment = (body, system) => store.mutate("comment-added", (ledger) => {
803
914
  const { index, task: fresh } = liveTaskAt(ledger, id);
804
915
  const next = structuredClone(fresh);
805
916
  next.comments.push({
806
917
  id: newCommentId(),
807
918
  body: normalizeBody(body),
919
+ ...system !== void 0 ? {
920
+ systemKey: system.key,
921
+ ...system.params !== void 0 ? { systemParams: system.params } : {},
922
+ ...system.rows !== void 0 ? { systemRows: system.rows } : {}
923
+ } : {},
808
924
  version: 1,
809
925
  createdAt: options.now()
810
926
  });
@@ -828,7 +944,10 @@ function registerTaskboardRoutes(ctx, options) {
828
944
  return;
829
945
  }
830
946
  if (root.outcome === "failed") throw new Error(`Error: invalid_input: ${root.error ?? "合并失败"}`);
831
- await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`);
947
+ await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`, {
948
+ key: "sys.mergeSingle",
949
+ params: { branch: root.branch }
950
+ });
832
951
  json(res, {
833
952
  ok: true,
834
953
  value: {
@@ -841,7 +960,14 @@ function registerTaskboardRoutes(ctx, options) {
841
960
  const labelOf = (repo) => repo === "" ? "根仓库" : repo;
842
961
  const mergedCount = results.filter((r) => r.outcome === "merged").length;
843
962
  const failedCount = results.filter((r) => r.outcome === "failed").length;
844
- await pushComment(`[系统] 分支已按仓库合并(--no-ff):${results.map((r) => r.outcome === "merged" ? `${labelOf(r.repo)} ✓ 已合并` : r.outcome === "noop" ? `${labelOf(r.repo)} ⟲ 无新提交` : `${labelOf(r.repo)} ✗ ${(r.error ?? "合并失败").slice(0, 150)}`).join(";")}`);
963
+ await pushComment(`[系统] 分支已按仓库合并(--no-ff):${results.map((r) => r.outcome === "merged" ? `${labelOf(r.repo)} ✓ 已合并` : r.outcome === "noop" ? `${labelOf(r.repo)} ⟲ 无新提交` : `${labelOf(r.repo)} ✗ ${(r.error ?? "合并失败").slice(0, 150)}`).join(";")}`, {
964
+ key: "sys.mergeMulti",
965
+ rows: results.map((r) => ({
966
+ repo: r.repo,
967
+ outcome: r.outcome,
968
+ ...r.error !== void 0 ? { error: r.error.slice(0, 150) } : {}
969
+ }))
970
+ });
845
971
  json(res, {
846
972
  ok: true,
847
973
  value: {