dsh-taskboard 0.2.1 → 0.2.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.
package/README.md CHANGED
@@ -20,6 +20,7 @@ DeepSeek Harness 的**任务看板插件**:人建卡、agent 认领执行、
20
20
  - 列头状态色圆点:待规划灰 / 待办蓝 / 进行中橙 / 待验收紫 / 已完成绿 / 已删除红
21
21
  - 新建/编辑弹窗:项目、模型、紧急度、执行方式、cron 实时校验与下次运行预览
22
22
  - 详情面板:状态流转(done 仅限人工)、agent/用户评论流、执行记录(倒序,最新在最上;会话 ID 点击跳转打开该执行会话;已删除/已归档分开提示)、停止执行
23
+ - 待验收列卡片快捷操作:「✓ 完成」一键验收、「✗ 退回」退回待办并可附退回原因(agent 开工前会读)
23
24
 
24
25
  **Agent 工具(taskboard_\*)**
25
26
  - 8 个工具:查板 / 建卡 / 改卡 / 移卡 / 评论 / 软删除,任何会话可用
@@ -43,12 +44,17 @@ dsh plugin --profile <name> add github:cloader/dsh-taskboard # GitHub 源
43
44
 
44
45
  ```bash
45
46
  npm install && npm run build # host ESM + client CJS 双构建
46
- npm test # vitest 64
47
+ npm test # vitest 67
47
48
  node scripts/screenshot.mjs # 重新生成 img/ 截图(需本机 Edge)
48
49
  ```
49
50
 
50
51
  ## 升级日志
51
52
 
53
+ ### 0.2.2
54
+
55
+ - **待验收快捷操作**:待验收列卡片上直接「✓ 完成 / ✗ 退回」,退回可附原因(可留空),退回与评语一次原子提交(移卡失败不会产生孤儿评论)
56
+ - 非法状态流转的 HTTP 状态码由 500 修正为 400
57
+
52
58
  ### 0.2.1
53
59
 
54
60
  - 调整任务发起时的提示词
package/lib/client.js CHANGED
@@ -34,6 +34,7 @@ window.__ModuleLoader__.load({
34
34
  get: (id) => unwrap(fetch(`/dsh-taskboard/tasks/${encodeURIComponent(id)}`)),
35
35
  update: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/update`, body),
36
36
  move: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/move`, body),
37
+ reject: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/reject`, body),
37
38
  comment: (id, bodyText) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/comment`, { body: bodyText }),
38
39
  remove: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/delete`, body),
39
40
  run: (id) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/run`, {}),
@@ -474,6 +475,27 @@ window.__ModuleLoader__.load({
474
475
  this.setState({ error: error instanceof Error ? error.message : String(error) });
475
476
  }
476
477
  }
478
+ /**
479
+ * Quick-reject (card ✗ button): move back to todo with an optional user
480
+ * comment, committed atomically host-side. Returns whether the task moved.
481
+ * @param id - task id.
482
+ * @param ifVersion - optimistic version (captured at click time).
483
+ * @param comment - optional comment text; blank = move only.
484
+ */
485
+ async reject(id, ifVersion, comment) {
486
+ const body = comment !== void 0 && comment.trim().length > 0 ? comment.trim() : void 0;
487
+ try {
488
+ await this.client.reject(id, body === void 0 ? { ifVersion } : {
489
+ ifVersion,
490
+ body
491
+ });
492
+ await this.refresh();
493
+ return true;
494
+ } catch (error) {
495
+ this.setState({ error: error instanceof Error ? error.message : String(error) });
496
+ return false;
497
+ }
498
+ }
477
499
  /** Toggle the blocked marker. */
478
500
  async toggleBlocked(task) {
479
501
  try {
@@ -763,6 +785,21 @@ window.__ModuleLoader__.load({
763
785
  .dsh-atb-badge[data-kind="done"] { background: rgba(46,160,67,.16); color: #2ea043; }
764
786
  .dsh-atb-badge[data-kind="running"] { background: rgba(229,152,42,.16); color: #e69842; }
765
787
 
788
+ /* ---------- card quick review (in_review column) ---------- */
789
+ .dsh-atb-quick { display: flex; gap: 6px; margin-top: 7px; }
790
+ .dsh-atb-quickbtn {
791
+ flex: 1; font-size: 11.5px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
792
+ border: 1px solid var(--dsw-border, rgba(128,128,128,.3));
793
+ background: var(--dsw-bg-elevated, rgba(128,128,128,.12)); color: inherit;
794
+ }
795
+ .dsh-atb-quickbtn:hover { border-color: var(--dsw-border-strong, rgba(128,128,128,.6)); }
796
+ .dsh-atb-quickbtn[data-act="done"] { background: rgba(46,160,67,.14); color: #2ea043; border-color: rgba(46,160,67,.4); }
797
+ .dsh-atb-quickbtn[data-act="done"]:hover { background: rgba(46,160,67,.22); }
798
+ .dsh-atb-quickbtn[data-act="reject"] { background: rgba(229,152,42,.12); color: #d9822b; border-color: rgba(229,152,42,.4); }
799
+ .dsh-atb-quickbtn[data-act="reject"]:hover { background: rgba(229,152,42,.2); }
800
+ .dsh-atb-quick-reject { display: flex; gap: 6px; margin-top: 7px; align-items: stretch; }
801
+ .dsh-atb-quick-note { flex: 1; min-width: 0; font-size: 11.5px; padding: 3px 8px; }
802
+
766
803
  .dsh-atb-error { font-size: 12px; color: #e5484d; padding: 4px 8px; border-radius: 6px; background: rgba(229,72,77,.1); }
767
804
  .dsh-atb-empty { font-size: 12px; color: var(--dsw-text-secondary, gray); padding: 10px 4px; }
768
805
 
@@ -1312,10 +1349,23 @@ window.__ModuleLoader__.load({
1312
1349
  * @module dsh-taskboard/shared/version
1313
1350
  */
1314
1351
  /** The package version (must equal package.json "version"). */
1315
- const PLUGIN_VERSION = "0.2.1";
1352
+ const PLUGIN_VERSION = "0.2.2";
1316
1353
 
1317
1354
  //#endregion
1318
1355
  //#region src/client/board/TaskCard.tsx
1356
+ /**
1357
+ * One board card: urgency edge, title, project/urgency/model/schedule/
1358
+ * blocked/trashed badges, comment count, and the last execution outcome.
1359
+ * Click opens the detail pane; cards in the backlog/todo columns are
1360
+ * draggable between those two columns (HTML5 drag & drop). Cards sitting
1361
+ * in the in_review column also carry quick-review actions (✓ complete /
1362
+ * ✗ send back with an optional note).
1363
+ *
1364
+ * The root is a div[role=button] (not a <button>) so the quick actions can
1365
+ * be real nested buttons — valid HTML and native keyboard activation.
1366
+ *
1367
+ * @module dsh-taskboard/client/board/TaskCard
1368
+ */
1319
1369
  const URGENCY_LABEL$1 = {
1320
1370
  urgent: "紧急",
1321
1371
  normal: "一般",
@@ -1338,14 +1388,27 @@ window.__ModuleLoader__.load({
1338
1388
  * @param onAlert - show an alert message (replaces native alert).
1339
1389
  */
1340
1390
  function TaskCard({ task, controller, draggable = false, now, onAlert }) {
1391
+ const [rejectOpen, setRejectOpen] = (0, react.useState)(false);
1392
+ const [note, setNote] = (0, react.useState)("");
1341
1393
  const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : void 0;
1342
1394
  const running = task.executions.find((ex) => ex.outcome === "running");
1343
1395
  const stale = now !== void 0 && isStaleClaim(task, now);
1344
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1345
- type: "button",
1396
+ const reviewing = task.status === "in_review" && task.trashedAt === void 0;
1397
+ /** Submit the quick-reject: one atomic route (move + optional note). */
1398
+ const submitReject = () => {
1399
+ controller.reject(task.id, task.version, note).then((ok) => {
1400
+ if (ok) {
1401
+ setRejectOpen(false);
1402
+ setNote("");
1403
+ }
1404
+ });
1405
+ };
1406
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1407
+ role: "button",
1408
+ tabIndex: 0,
1346
1409
  className: "dsh-atb-card",
1347
1410
  "data-urgency": task.urgency,
1348
- draggable,
1411
+ draggable: draggable && !rejectOpen,
1349
1412
  onDragStart: (e) => {
1350
1413
  if (running !== void 0) {
1351
1414
  e.preventDefault();
@@ -1362,57 +1425,123 @@ window.__ModuleLoader__.load({
1362
1425
  delete e.currentTarget.dataset.dragging;
1363
1426
  },
1364
1427
  onClick: () => controller.select(task.id),
1365
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1366
- className: "dsh-atb-card-title",
1367
- children: task.title
1368
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1369
- className: "dsh-atb-card-meta",
1370
- children: [
1371
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1372
- className: "dsh-atb-badge",
1373
- children: URGENCY_LABEL$1[task.urgency]
1374
- }),
1375
- task.blocked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1376
- className: "dsh-atb-badge",
1377
- "data-kind": "blocked",
1378
- children: "受阻"
1379
- }),
1380
- stale && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1381
- className: "dsh-atb-badge",
1382
- "data-kind": "stale",
1383
- children: "⏱ 认领超时"
1384
- }),
1385
- task.execution.mode === "scheduled" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1386
- className: "dsh-atb-badge",
1387
- "data-kind": "scheduled",
1388
- children: ["⏰ ", fmtTime(task.execution.nextRunAt)]
1389
- }),
1390
- task.model !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1391
- className: "dsh-atb-badge",
1392
- children: task.model.model
1393
- }),
1394
- task.status === "done" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1395
- className: "dsh-atb-badge",
1396
- "data-kind": "done",
1397
- children: "完成"
1398
- }),
1399
- last !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1400
- className: "dsh-atb-badge",
1401
- "data-kind": last.outcome === "running" ? "running" : last.outcome,
1402
- children: OUTCOME_LABEL$1[last.outcome] ?? last.outcome
1403
- }),
1404
- task.comments.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["💬 ", task.comments.length] }),
1405
- task.trashedAt !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1406
- className: "dsh-atb-badge",
1407
- "data-kind": "trashed",
1408
- children: "待清除"
1409
- }),
1410
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1411
- style: { marginLeft: "auto" },
1412
- children: fmtTime(task.updatedAt)
1413
- })
1414
- ]
1415
- })]
1428
+ onKeyDown: (e) => {
1429
+ if (e.target !== e.currentTarget) return;
1430
+ if (e.key === "Enter" || e.key === " ") {
1431
+ e.preventDefault();
1432
+ controller.select(task.id);
1433
+ }
1434
+ },
1435
+ children: [
1436
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1437
+ className: "dsh-atb-card-title",
1438
+ children: task.title
1439
+ }),
1440
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1441
+ className: "dsh-atb-card-meta",
1442
+ children: [
1443
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1444
+ className: "dsh-atb-badge",
1445
+ children: URGENCY_LABEL$1[task.urgency]
1446
+ }),
1447
+ task.blocked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1448
+ className: "dsh-atb-badge",
1449
+ "data-kind": "blocked",
1450
+ children: "受阻"
1451
+ }),
1452
+ stale && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1453
+ className: "dsh-atb-badge",
1454
+ "data-kind": "stale",
1455
+ children: "⏱ 认领超时"
1456
+ }),
1457
+ task.execution.mode === "scheduled" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1458
+ className: "dsh-atb-badge",
1459
+ "data-kind": "scheduled",
1460
+ children: ["", fmtTime(task.execution.nextRunAt)]
1461
+ }),
1462
+ task.model !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1463
+ className: "dsh-atb-badge",
1464
+ children: task.model.model
1465
+ }),
1466
+ task.status === "done" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1467
+ className: "dsh-atb-badge",
1468
+ "data-kind": "done",
1469
+ children: "完成"
1470
+ }),
1471
+ last !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1472
+ className: "dsh-atb-badge",
1473
+ "data-kind": last.outcome === "running" ? "running" : last.outcome,
1474
+ children: OUTCOME_LABEL$1[last.outcome] ?? last.outcome
1475
+ }),
1476
+ task.comments.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["💬 ", task.comments.length] }),
1477
+ task.trashedAt !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1478
+ className: "dsh-atb-badge",
1479
+ "data-kind": "trashed",
1480
+ children: "待清除"
1481
+ }),
1482
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1483
+ style: { marginLeft: "auto" },
1484
+ children: fmtTime(task.updatedAt)
1485
+ })
1486
+ ]
1487
+ }),
1488
+ reviewing && (rejectOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1489
+ className: "dsh-atb-quick-reject",
1490
+ onClick: (e) => e.stopPropagation(),
1491
+ children: [
1492
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1493
+ className: "dsh-atb-input dsh-atb-quick-note",
1494
+ value: note,
1495
+ placeholder: "退回原因(可选,agent 开工前会读)…",
1496
+ autoFocus: true,
1497
+ spellCheck: false,
1498
+ onChange: (e) => setNote(e.target.value),
1499
+ onKeyDown: (e) => {
1500
+ if (e.key === "Enter") submitReject();
1501
+ else if (e.key === "Escape") {
1502
+ setRejectOpen(false);
1503
+ setNote("");
1504
+ }
1505
+ }
1506
+ }),
1507
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1508
+ type: "button",
1509
+ className: "dsh-atb-quickbtn",
1510
+ "data-act": "reject-confirm",
1511
+ onClick: submitReject,
1512
+ children: "退回待办"
1513
+ }),
1514
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1515
+ type: "button",
1516
+ className: "dsh-atb-quickbtn",
1517
+ "data-act": "reject-cancel",
1518
+ onClick: () => {
1519
+ setRejectOpen(false);
1520
+ setNote("");
1521
+ },
1522
+ children: "取消"
1523
+ })
1524
+ ]
1525
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1526
+ className: "dsh-atb-quick",
1527
+ onClick: (e) => e.stopPropagation(),
1528
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1529
+ type: "button",
1530
+ className: "dsh-atb-quickbtn",
1531
+ "data-act": "done",
1532
+ title: "验收完成:移至已完成",
1533
+ onClick: () => void controller.move(task.id, task.version, "done"),
1534
+ children: "✓ 完成"
1535
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1536
+ type: "button",
1537
+ className: "dsh-atb-quickbtn",
1538
+ "data-act": "reject",
1539
+ title: "退回待办,可附退回原因",
1540
+ onClick: () => setRejectOpen(true),
1541
+ children: "✗ 退回"
1542
+ })]
1543
+ }))
1544
+ ]
1416
1545
  });
1417
1546
  }
1418
1547
 
@@ -29,7 +29,7 @@ function fail(code, message) {
29
29
  message
30
30
  }
31
31
  },
32
- status: code === "invalid_input" ? 400 : code === "not_found" ? 404 : code === "version_conflict" ? 409 : code === "forbidden" ? 403 : 500
32
+ status: code === "invalid_input" || code === "invalid_transition" ? 400 : code === "not_found" ? 404 : code === "version_conflict" ? 409 : code === "forbidden" ? 403 : 500
33
33
  };
34
34
  }
35
35
  /** Read one JSON body (null on parse failure). */
@@ -251,6 +251,35 @@ function registerTaskboardRoutes(ctx, options) {
251
251
  });
252
252
  return;
253
253
  }
254
+ if (action === "reject") {
255
+ const ifVersion = num(body, "ifVersion");
256
+ if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
257
+ if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
258
+ if (!canTransition(task.status, "todo")) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`);
259
+ const next = structuredClone(task);
260
+ next.status = "todo";
261
+ next.version = task.version + 1;
262
+ next.updatedAt = options.now();
263
+ next.updatedBy = { kind: "user" };
264
+ syncClaim(next, "todo", options.now());
265
+ const commentText = str(body, "body") ?? "";
266
+ if (commentText.trim().length > 0) next.comments.push({
267
+ id: newCommentId(),
268
+ body: normalizeBody(commentText),
269
+ version: 1,
270
+ createdAt: options.now()
271
+ });
272
+ await store.mutate("task-moved", (ledger) => {
273
+ const i = ledger.tasks.findIndex((t) => t.id === id);
274
+ ledger.tasks[i] = next;
275
+ return [next];
276
+ });
277
+ json(res, {
278
+ ok: true,
279
+ value: summarize(next)
280
+ });
281
+ return;
282
+ }
254
283
  if (action === "comment") {
255
284
  const bodyText = str(body, "body") ?? "";
256
285
  const comment = {
@@ -1 +1 @@
1
- {"version":3,"file":"routes.js","names":[],"sources":["../../src/host/routes.ts"],"sourcesContent":["/**\n * /dsh-taskboard routes on the shared DSH webserver: a JSON API for the\n * GUI's human operations (create/update/move/comment/delete — actor `user`,\n * the done move IS allowed here) plus an SSE stream mirroring every\n * committed ledger mutation.\n *\n * All domain validation goes through the shared protocol pure functions; the\n * route layer only maps transport to envelope.\n *\n * @module dsh-taskboard/host/routes\n */\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { Context } from '@deepseek-ai/cordis'\n// Type-only: pulls the webServer Context merge (ctx.webServer).\nimport type {} from '@deepseek-ai/dsh-host-webserver'\nimport {\n asStatus,\n asUrgency,\n canTransition,\n newCommentId,\n newTaskId,\n normalizeBody,\n normalizeExecution,\n normalizeModel,\n normalizePrompt,\n normalizeTitle,\n summarize,\n syncClaim,\n type TaskModel,\n type TaskRecord,\n} from '../shared/protocol.ts'\nimport { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'\nimport type { TaskStore } from './store.ts'\nimport type { WorkspaceFace } from './tools.ts'\n\n/** Heartbeat cadence for the SSE stream. */\nconst HEARTBEAT_MS = 20_000\n\n/** The workspaces face routes need (same narrow shape as tools). */\nexport type RoutesWorkspaceFace = WorkspaceFace\n\n/** Options. */\nexport interface TaskboardRoutesOptions {\n store: TaskStore\n workspaces: RoutesWorkspaceFace\n now: () => number\n /** Manual-run hook (the execution service); absent → 501. */\n run?: (taskId: string) => Promise<{ ok: true; executionId: string; sessionId: string } | { ok: false; error: string }>\n /** Cancel hook (the execution service); absent → 501. */\n cancel?: (taskId: string) => Promise<{ ok: true; executionId: string } | { ok: false; error: string }>\n /**\n * Registered model provider routes (from the host llm runtime), for\n * advisory validation of pinned models; undefined = runtime unavailable.\n */\n modelProviders?: () => string[] | undefined\n}\n\n/** Validate a pinned model: structural check always, provider route when known. */\nfunction checkModel(raw: unknown, modelProviders?: () => string[] | undefined): TaskModel {\n const model = normalizeModel(raw)\n const providers = modelProviders?.()\n if (providers !== undefined && !providers.includes(model.provider)) {\n throw new Error(`Error: invalid_input: model provider \"${model.provider}\" has no registered route (available: ${providers.join(', ')})`)\n }\n return model\n}\n\n/** JSON-envelope writer. */\nfunction json(res: ServerResponse, payload: ApiResult<unknown>, status = 200): void {\n const body = JSON.stringify(payload)\n res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })\n res.end(body)\n}\n\n/** Domain failure → envelope + HTTP status. */\nfunction fail(code: ApiFail['error']['code'], message: string): { res: ApiFail; status: number } {\n const status = code === 'invalid_input' ? 400\n : code === 'not_found' ? 404\n : code === 'version_conflict' ? 409\n : code === 'forbidden' ? 403\n : 500\n return { res: { ok: false, error: { code, message } }, status }\n}\n\n/** Read one JSON body (null on parse failure). */\nasync function readBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {\n const chunks: Buffer[] = []\n for await (const chunk of req) chunks.push(chunk as Buffer)\n if (chunks.length === 0) return {}\n try {\n const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))\n return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null\n } catch {\n return null\n }\n}\n\n/** String field accessor (null when absent/not a string). */\nfunction str(body: Record<string, unknown>, key: string): string | null {\n const v = body[key]\n return typeof v === 'string' ? v : null\n}\n\n/** Number field accessor (undefined when absent; null when present but not a number). */\nfunction num(body: Record<string, unknown>, key: string): number | undefined | null {\n const v = body[key]\n if (v === undefined) return undefined\n return typeof v === 'number' && Number.isFinite(v) ? v : null\n}\n\n/** Map a thrown domain error to the envelope. */\nfunction toFail(error: unknown): { res: ApiFail; status: number } {\n const message = error instanceof Error ? error.message : String(error)\n const code = message.startsWith('Error: ') ? message.slice(7).split(':')[0] : undefined\n const known: ApiFail['error']['code'][] = ['invalid_input', 'not_found', 'version_conflict', 'invalid_transition', 'forbidden', 'internal']\n if (code !== undefined && (known as string[]).includes(code)) {\n return fail(code as ApiFail['error']['code'], message.slice(7 + code.length + 2))\n }\n if (code === 'workspace_mismatch') return fail('forbidden', message.slice(7 + code.length + 2))\n return fail('invalid_input', message)\n}\n\n/**\n * Register the taskboard routes.\n * @param ctx - context carrying the webServer service.\n * @param options - store + workspaces + clock.\n * @returns the disposer.\n */\nexport function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOptions): () => void {\n const { store, workspaces } = options\n const subscribers = new Set<ServerResponse>()\n let heartbeat: NodeJS.Timeout | undefined\n\n const broadcast = (change: { revision: number; kind: string; tasks: readonly TaskRecord[] }): void => {\n const frame = `event: change\\ndata: ${JSON.stringify({ revision: change.revision, kind: change.kind, tasks: change.tasks.map(summarize) })}\\n\\n`\n for (const res of subscribers) res.write(frame)\n }\n store.subscribe(broadcast)\n\n const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {\n try {\n const url = new URL(req.url ?? '/', 'http://x')\n const pathname = url.pathname\n\n // ---------------------------------------------------------------- GET\n if (req.method === 'GET') {\n if (pathname === `${ROUTE_PREFIX}/state`) {\n await store.load()\n json(res, { ok: true, value: store.snapshot() })\n return\n }\n if (pathname === `${ROUTE_PREFIX}/workspaces`) {\n json(res, { ok: true, value: workspaces.list() })\n return\n }\n const taskMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`))\n if (taskMatch !== null) {\n const task = store.get(taskMatch[1]!)\n if (task === undefined) { const f = fail('not_found', 'no such task'); json(res, f.res, f.status); return }\n json(res, { ok: true, value: task })\n return\n }\n res.writeHead(404)\n res.end()\n return\n }\n\n if (req.method !== 'POST') {\n res.writeHead(405)\n res.end()\n return\n }\n // CSRF fence: cross-site simple requests cannot set application/json.\n const contentType = req.headers['content-type'] ?? ''\n if (!contentType.toLowerCase().startsWith('application/json')) {\n const f = fail('invalid_input', 'content-type must be application/json')\n json(res, f.res, 415)\n return\n }\n const body = await readBody(req)\n if (body === null) {\n const f = fail('invalid_input', 'body is not a JSON object')\n json(res, f.res, 400)\n return\n }\n\n // ------------------------------------------------- POST /tasks (create)\n if (pathname === `${ROUTE_PREFIX}/tasks`) {\n try {\n const title = normalizeTitle(str(body, 'title') ?? '')\n const workspaceId = str(body, 'workspaceId') ?? ''\n if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')\n const urgency = asUrgency(str(body, 'urgency') ?? '')\n const status = str(body, 'status') === null ? 'todo' as const : asStatus(str(body, 'status')!)\n const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())\n const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)\n const now = options.now()\n const task: TaskRecord = {\n id: newTaskId(),\n title,\n description: (str(body, 'description') ?? '').trim(),\n prompt: normalizePrompt(str(body, 'prompt') ?? undefined),\n workspaceId,\n urgency,\n status,\n blocked: false,\n execution,\n model,\n version: 1,\n createdAt: now,\n updatedAt: now,\n createdBy: { kind: 'user' },\n updatedBy: { kind: 'user' },\n comments: [],\n executions: [],\n }\n await store.mutate('task-created', ledger => {\n ledger.tasks.push(task)\n return [task]\n })\n json(res, { ok: true, value: summarize(task) }, 201)\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n // ------------------------------------------- POST /tasks/:id/{action}\n const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/(\\\\w+)$`))\n if (actionMatch !== null) {\n const id = actionMatch[1]!\n const action = actionMatch[2]!\n try {\n const task = store.get(id)\n if (task === undefined) throw new Error('Error: not_found: no such task')\n if (action === 'update') {\n const ifVersion = num(body, 'ifVersion')\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n const next = structuredClone(task)\n const title = str(body, 'title')\n if (title !== null) next.title = normalizeTitle(title)\n const description = str(body, 'description')\n if (description !== null) next.description = description.trim()\n const prompt = str(body, 'prompt')\n if (prompt !== null) next.prompt = normalizePrompt(prompt)\n const urgency = str(body, 'urgency')\n if (urgency !== null) next.urgency = asUrgency(urgency)\n // GUI-only rebind to another project; validated against the workspace registry.\n const workspaceId = str(body, 'workspaceId')\n if (workspaceId !== null) {\n if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')\n next.workspaceId = workspaceId\n }\n if (typeof body.blocked === 'boolean') next.blocked = body.blocked\n // The GUI (task owner surface) may edit model/execution; null clears the model.\n if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())\n if (body.model === null) next.model = undefined\n else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)\n next.version = task.version + 1\n next.updatedAt = options.now()\n next.updatedBy = { kind: 'user' }\n await store.mutate('task-updated', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: summarize(next) })\n return\n }\n if (action === 'move') {\n const ifVersion = num(body, 'ifVersion')\n const status = str(body, 'status') ?? ''\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n const to = asStatus(status)\n if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)\n const next = structuredClone(task)\n next.status = to\n next.version = task.version + 1\n next.updatedAt = options.now()\n next.updatedBy = { kind: 'user' }\n if (task.status === 'todo' && to === 'in_progress') next.blocked = false\n // A user move records no holder; leaving in_progress releases any hold.\n syncClaim(next, to, options.now())\n await store.mutate('task-moved', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: summarize(next) })\n return\n }\n if (action === 'comment') {\n const bodyText = str(body, 'body') ?? ''\n const comment = { id: newCommentId(), body: normalizeBody(bodyText), version: 1, createdAt: options.now() }\n const next = structuredClone(task)\n next.comments.push(comment)\n next.version = task.version + 1\n next.updatedAt = options.now()\n await store.mutate('comment-added', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: comment }, 201)\n return\n }\n if (action === 'delete') {\n const purge = body.purge === true\n if (purge) {\n if (task.trashedAt === undefined) throw new Error('Error: invalid_input: purge requires a trashed task (soft-delete first)')\n await store.mutate('task-deleted', ledger => {\n ledger.tasks = ledger.tasks.filter(t => t.id !== id)\n return []\n })\n json(res, { ok: true, value: { purged: true } })\n return\n }\n const ifVersion = num(body, 'ifVersion')\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n const next = structuredClone(task)\n next.trashedAt = options.now()\n next.version = task.version + 1\n await store.mutate('task-deleted', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: { trashed: true } })\n return\n }\n if (action === 'run') {\n if (options.run === undefined) {\n const f = fail('invalid_input', 'execution service unavailable')\n json(res, f.res, 501)\n return\n }\n const result = await options.run(id)\n if (result.ok) json(res, { ok: true, value: result }, 202)\n else {\n const f = fail('invalid_input', result.error)\n json(res, f.res, f.status)\n }\n return\n }\n if (action === 'cancel') {\n if (options.cancel === undefined) {\n const f = fail('invalid_input', 'execution service unavailable')\n json(res, f.res, 501)\n return\n }\n const result = await options.cancel(id)\n if (result.ok) json(res, { ok: true, value: { cancelled: true, executionId: result.executionId } }, 202)\n else {\n const f = fail('invalid_input', result.error)\n json(res, f.res, f.status)\n }\n return\n }\n const f = fail('not_found', `unknown action ${action}`)\n json(res, f.res, f.status)\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n res.writeHead(404)\n res.end()\n } catch (error) {\n const f = fail('internal', error instanceof Error ? error.message : String(error))\n json(res, f.res, f.status)\n }\n }\n\n const sse = (req: IncomingMessage, res: ServerResponse): void => {\n res.writeHead(200, {\n 'content-type': 'text/event-stream; charset=utf-8',\n 'cache-control': 'no-cache',\n connection: 'keep-alive',\n })\n res.write('retry: 2000\\n\\n')\n // Baseline frame: the client reconciles by revision and refetches state on gaps.\n res.write(`event: hello\\ndata: ${JSON.stringify({ revision: store.snapshot().revision })}\\n\\n`)\n subscribers.add(res)\n if (heartbeat === undefined) {\n heartbeat = setInterval(() => {\n for (const current of subscribers) current.write(': ping\\n\\n')\n }, HEARTBEAT_MS)\n }\n req.on('close', () => {\n subscribers.delete(res)\n if (subscribers.size === 0 && heartbeat !== undefined) {\n clearInterval(heartbeat)\n heartbeat = undefined\n }\n })\n }\n\n const disposers = [\n ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler }),\n ctx.webServer.register({ kind: 'exact', path: SSE_PATH, handler: sse }),\n ]\n return () => {\n for (const dispose of disposers) dispose()\n if (heartbeat !== undefined) clearInterval(heartbeat)\n for (const res of subscribers) res.end()\n subscribers.clear()\n }\n}\n"],"mappings":";;;;AAoCA,MAAM,eAAe;;AAsBrB,SAAS,WAAW,KAAc,gBAAwD;CACxF,MAAM,QAAQ,eAAe,GAAG;CAChC,MAAM,YAAY,iBAAiB;CACnC,IAAI,cAAc,KAAA,KAAa,CAAC,UAAU,SAAS,MAAM,QAAQ,GAC/D,MAAM,IAAI,MAAM,yCAAyC,MAAM,SAAS,wCAAwC,UAAU,KAAK,IAAI,EAAE,EAAE;CAEzI,OAAO;AACT;;AAGA,SAAS,KAAK,KAAqB,SAA6B,SAAS,KAAW;CAClF,MAAM,OAAO,KAAK,UAAU,OAAO;CACnC,IAAI,UAAU,QAAQ;EAAE,gBAAgB;EAAmC,iBAAiB;CAAW,CAAC;CACxG,IAAI,IAAI,IAAI;AACd;;AAGA,SAAS,KAAK,MAAgC,SAAmD;CAM/F,OAAO;EAAE,KAAK;GAAE,IAAI;GAAO,OAAO;IAAE;IAAM;GAAQ;EAAE;EAAG,QALxC,SAAS,kBAAkB,MACtC,SAAS,cAAc,MACrB,SAAS,qBAAqB,MAC5B,SAAS,cAAc,MACrB;CACoD;AAChE;;AAGA,eAAe,SAAS,KAA+D;CACrF,MAAM,SAAmB,CAAC;CAC1B,WAAW,MAAM,SAAS,KAAK,OAAO,KAAK,KAAe;CAC1D,IAAI,OAAO,WAAW,GAAG,OAAO,CAAC;CACjC,IAAI;EACF,MAAM,SAAS,KAAK,MAAM,OAAO,OAAO,MAAM,CAAC,CAAC,SAAS,MAAM,CAAC;EAChE,OAAO,OAAO,WAAW,YAAY,WAAW,OAAO,SAAoC;CAC7F,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAS,IAAI,MAA+B,KAA4B;CACtE,MAAM,IAAI,KAAK;CACf,OAAO,OAAO,MAAM,WAAW,IAAI;AACrC;;AAGA,SAAS,IAAI,MAA+B,KAAwC;CAClF,MAAM,IAAI,KAAK;CACf,IAAI,MAAM,KAAA,GAAW,OAAO,KAAA;CAC5B,OAAO,OAAO,MAAM,YAAY,OAAO,SAAS,CAAC,IAAI,IAAI;AAC3D;;AAGA,SAAS,OAAO,OAAkD;CAChE,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;CACrE,MAAM,OAAO,QAAQ,WAAW,SAAS,IAAI,QAAQ,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,KAAA;CAE9E,IAAI,SAAS,KAAA,KAAc;EADgB;EAAiB;EAAa;EAAoB;EAAsB;EAAa;CACjG,CAAC,CAAc,SAAS,IAAI,GACzD,OAAO,KAAK,MAAkC,QAAQ,MAAM,IAAI,KAAK,SAAS,CAAC,CAAC;CAElF,IAAI,SAAS,sBAAsB,OAAO,KAAK,aAAa,QAAQ,MAAM,IAAI,KAAK,SAAS,CAAC,CAAC;CAC9F,OAAO,KAAK,iBAAiB,OAAO;AACtC;;;;;;;AAQA,SAAgB,wBAAwB,KAAc,SAA6C;CACjG,MAAM,EAAE,OAAO,eAAe;CAC9B,MAAM,8BAAc,IAAI,IAAoB;CAC5C,IAAI;CAEJ,MAAM,aAAa,WAAmF;EACpG,MAAM,QAAQ,wBAAwB,KAAK,UAAU;GAAE,UAAU,OAAO;GAAU,MAAM,OAAO;GAAM,OAAO,OAAO,MAAM,IAAI,SAAS;EAAE,CAAC,EAAE;EAC3I,KAAK,MAAM,OAAO,aAAa,IAAI,MAAM,KAAK;CAChD;CACA,MAAM,UAAU,SAAS;CAEzB,MAAM,UAAU,OAAO,KAAsB,QAAuC;EAClF,IAAI;GAEF,MAAM,WAAW,IADD,IAAI,IAAI,OAAO,KAAK,UACjB,CAAC,CAAC;GAGrB,IAAI,IAAI,WAAW,OAAO;IACxB,IAAI,aAAa,wBAAyB;KACxC,MAAM,MAAM,KAAK;KACjB,KAAK,KAAK;MAAE,IAAI;MAAM,OAAO,MAAM,SAAS;KAAE,CAAC;KAC/C;IACF;IACA,IAAI,aAAa,6BAA8B;KAC7C,KAAK,KAAK;MAAE,IAAI;MAAM,OAAO,WAAW,KAAK;KAAE,CAAC;KAChD;IACF;IACA,MAAM,YAAY,SAAS,MAAM,IAAI,OAAO,IAAI,aAAa,gBAAgB,CAAC;IAC9E,IAAI,cAAc,MAAM;KACtB,MAAM,OAAO,MAAM,IAAI,UAAU,EAAG;KACpC,IAAI,SAAS,KAAA,GAAW;MAAE,MAAM,IAAI,KAAK,aAAa,cAAc;MAAG,KAAK,KAAK,EAAE,KAAK,EAAE,MAAM;MAAG;KAAO;KAC1G,KAAK,KAAK;MAAE,IAAI;MAAM,OAAO;KAAK,CAAC;KACnC;IACF;IACA,IAAI,UAAU,GAAG;IACjB,IAAI,IAAI;IACR;GACF;GAEA,IAAI,IAAI,WAAW,QAAQ;IACzB,IAAI,UAAU,GAAG;IACjB,IAAI,IAAI;IACR;GACF;GAGA,IAAI,EADgB,IAAI,QAAQ,mBAAmB,GAAA,CAClC,YAAY,CAAC,CAAC,WAAW,kBAAkB,GAAG;IAE7D,KAAK,KADK,KAAK,iBAAiB,uCACtB,CAAC,CAAC,KAAK,GAAG;IACpB;GACF;GACA,MAAM,OAAO,MAAM,SAAS,GAAG;GAC/B,IAAI,SAAS,MAAM;IAEjB,KAAK,KADK,KAAK,iBAAiB,2BACtB,CAAC,CAAC,KAAK,GAAG;IACpB;GACF;GAGA,IAAI,aAAa,wBAAyB;IACxC,IAAI;KACF,MAAM,QAAQ,eAAe,IAAI,MAAM,OAAO,KAAK,EAAE;KACrD,MAAM,cAAc,IAAI,MAAM,aAAa,KAAK;KAChD,IAAI,WAAW,IAAI,WAAW,MAAM,KAAA,GAAW,MAAM,IAAI,MAAM,qCAAqC;KACpG,MAAM,UAAU,UAAU,IAAI,MAAM,SAAS,KAAK,EAAE;KACpD,MAAM,SAAS,IAAI,MAAM,QAAQ,MAAM,OAAO,SAAkB,SAAS,IAAI,MAAM,QAAQ,CAAE;KAC7F,MAAM,YAAY,mBAAoB,KAAK,aAA8D,CAAC,GAAG,QAAQ,IAAI,CAAC;KAC1H,MAAM,QAAQ,KAAK,UAAU,KAAA,IAAY,KAAA,IAAY,WAAW,KAAK,OAAO,QAAQ,cAAc;KAClG,MAAM,MAAM,QAAQ,IAAI;KACxB,MAAM,OAAmB;MACvB,IAAI,UAAU;MACd;MACA,cAAc,IAAI,MAAM,aAAa,KAAK,GAAA,CAAI,KAAK;MACnD,QAAQ,gBAAgB,IAAI,MAAM,QAAQ,KAAK,KAAA,CAAS;MACxD;MACA;MACA;MACA,SAAS;MACT;MACA;MACA,SAAS;MACT,WAAW;MACX,WAAW;MACX,WAAW,EAAE,MAAM,OAAO;MAC1B,WAAW,EAAE,MAAM,OAAO;MAC1B,UAAU,CAAC;MACX,YAAY,CAAC;KACf;KACA,MAAM,MAAM,OAAO,iBAAgB,WAAU;MAC3C,OAAO,MAAM,KAAK,IAAI;MACtB,OAAO,CAAC,IAAI;KACd,CAAC;KACD,KAAK,KAAK;MAAE,IAAI;MAAM,OAAO,UAAU,IAAI;KAAE,GAAG,GAAG;IACrD,SAAS,OAAO;KACd,MAAM,IAAI,OAAO,KAAK;KACtB,KAAK,KAAK,EAAE,KAAK,EAAE,MAAM;IAC3B;IACA;GACF;GAGA,MAAM,cAAc,SAAS,MAAM,IAAI,OAAO,IAAI,aAAa,uBAAuB,CAAC;GACvF,IAAI,gBAAgB,MAAM;IACxB,MAAM,KAAK,YAAY;IACvB,MAAM,SAAS,YAAY;IAC3B,IAAI;KACF,MAAM,OAAO,MAAM,IAAI,EAAE;KACzB,IAAI,SAAS,KAAA,GAAW,MAAM,IAAI,MAAM,gCAAgC;KACxE,IAAI,WAAW,UAAU;MACvB,MAAM,YAAY,IAAI,MAAM,WAAW;MACvC,IAAI,cAAc,KAAA,KAAa,cAAc,MAAM,MAAM,IAAI,MAAM,6CAA6C;MAChH,IAAI,cAAc,KAAK,SAAS,MAAM,IAAI,MAAM,0CAA0C,UAAU,YAAY,KAAK,QAAQ,EAAE;MAC/H,MAAM,OAAO,gBAAgB,IAAI;MACjC,MAAM,QAAQ,IAAI,MAAM,OAAO;MAC/B,IAAI,UAAU,MAAM,KAAK,QAAQ,eAAe,KAAK;MACrD,MAAM,cAAc,IAAI,MAAM,aAAa;MAC3C,IAAI,gBAAgB,MAAM,KAAK,cAAc,YAAY,KAAK;MAC9D,MAAM,SAAS,IAAI,MAAM,QAAQ;MACjC,IAAI,WAAW,MAAM,KAAK,SAAS,gBAAgB,MAAM;MACzD,MAAM,UAAU,IAAI,MAAM,SAAS;MACnC,IAAI,YAAY,MAAM,KAAK,UAAU,UAAU,OAAO;MAEtD,MAAM,cAAc,IAAI,MAAM,aAAa;MAC3C,IAAI,gBAAgB,MAAM;OACxB,IAAI,WAAW,IAAI,WAAW,MAAM,KAAA,GAAW,MAAM,IAAI,MAAM,qCAAqC;OACpG,KAAK,cAAc;MACrB;MACA,IAAI,OAAO,KAAK,YAAY,WAAW,KAAK,UAAU,KAAK;MAE3D,IAAI,KAAK,cAAc,KAAA,GAAW,KAAK,YAAY,mBAAmB,KAAK,WAA+C,QAAQ,IAAI,CAAC;MACvI,IAAI,KAAK,UAAU,MAAM,KAAK,QAAQ,KAAA;WACjC,IAAI,KAAK,UAAU,KAAA,GAAW,KAAK,QAAQ,WAAW,KAAK,OAAO,QAAQ,cAAc;MAC7F,KAAK,UAAU,KAAK,UAAU;MAC9B,KAAK,YAAY,QAAQ,IAAI;MAC7B,KAAK,YAAY,EAAE,MAAM,OAAO;MAChC,MAAM,MAAM,OAAO,iBAAgB,WAAU;OAC3C,MAAM,IAAI,OAAO,MAAM,WAAU,MAAK,EAAE,OAAO,EAAE;OACjD,OAAO,MAAM,KAAK;OAClB,OAAO,CAAC,IAAI;MACd,CAAC;MACD,KAAK,KAAK;OAAE,IAAI;OAAM,OAAO,UAAU,IAAI;MAAE,CAAC;MAC9C;KACF;KACA,IAAI,WAAW,QAAQ;MACrB,MAAM,YAAY,IAAI,MAAM,WAAW;MACvC,MAAM,SAAS,IAAI,MAAM,QAAQ,KAAK;MACtC,IAAI,cAAc,KAAA,KAAa,cAAc,MAAM,MAAM,IAAI,MAAM,6CAA6C;MAChH,IAAI,cAAc,KAAK,SAAS,MAAM,IAAI,MAAM,0CAA0C,UAAU,YAAY,KAAK,QAAQ,EAAE;MAC/H,MAAM,KAAK,SAAS,MAAM;MAC1B,IAAI,CAAC,cAAc,KAAK,QAAQ,EAAE,GAAG,MAAM,IAAI,MAAM,iDAAiD,KAAK,OAAO,KAAK,IAAI;MAC3H,MAAM,OAAO,gBAAgB,IAAI;MACjC,KAAK,SAAS;MACd,KAAK,UAAU,KAAK,UAAU;MAC9B,KAAK,YAAY,QAAQ,IAAI;MAC7B,KAAK,YAAY,EAAE,MAAM,OAAO;MAChC,IAAI,KAAK,WAAW,UAAU,OAAO,eAAe,KAAK,UAAU;MAEnE,UAAU,MAAM,IAAI,QAAQ,IAAI,CAAC;MACjC,MAAM,MAAM,OAAO,eAAc,WAAU;OACzC,MAAM,IAAI,OAAO,MAAM,WAAU,MAAK,EAAE,OAAO,EAAE;OACjD,OAAO,MAAM,KAAK;OAClB,OAAO,CAAC,IAAI;MACd,CAAC;MACD,KAAK,KAAK;OAAE,IAAI;OAAM,OAAO,UAAU,IAAI;MAAE,CAAC;MAC9C;KACF;KACA,IAAI,WAAW,WAAW;MACxB,MAAM,WAAW,IAAI,MAAM,MAAM,KAAK;MACtC,MAAM,UAAU;OAAE,IAAI,aAAa;OAAG,MAAM,cAAc,QAAQ;OAAG,SAAS;OAAG,WAAW,QAAQ,IAAI;MAAE;MAC1G,MAAM,OAAO,gBAAgB,IAAI;MACjC,KAAK,SAAS,KAAK,OAAO;MAC1B,KAAK,UAAU,KAAK,UAAU;MAC9B,KAAK,YAAY,QAAQ,IAAI;MAC7B,MAAM,MAAM,OAAO,kBAAiB,WAAU;OAC5C,MAAM,IAAI,OAAO,MAAM,WAAU,MAAK,EAAE,OAAO,EAAE;OACjD,OAAO,MAAM,KAAK;OAClB,OAAO,CAAC,IAAI;MACd,CAAC;MACD,KAAK,KAAK;OAAE,IAAI;OAAM,OAAO;MAAQ,GAAG,GAAG;MAC3C;KACF;KACA,IAAI,WAAW,UAAU;MAEvB,IADc,KAAK,UAAU,MAClB;OACT,IAAI,KAAK,cAAc,KAAA,GAAW,MAAM,IAAI,MAAM,yEAAyE;OAC3H,MAAM,MAAM,OAAO,iBAAgB,WAAU;QAC3C,OAAO,QAAQ,OAAO,MAAM,QAAO,MAAK,EAAE,OAAO,EAAE;QACnD,OAAO,CAAC;OACV,CAAC;OACD,KAAK,KAAK;QAAE,IAAI;QAAM,OAAO,EAAE,QAAQ,KAAK;OAAE,CAAC;OAC/C;MACF;MACA,MAAM,YAAY,IAAI,MAAM,WAAW;MACvC,IAAI,cAAc,KAAA,KAAa,cAAc,MAAM,MAAM,IAAI,MAAM,6CAA6C;MAChH,IAAI,cAAc,KAAK,SAAS,MAAM,IAAI,MAAM,0CAA0C,UAAU,YAAY,KAAK,QAAQ,EAAE;MAC/H,MAAM,OAAO,gBAAgB,IAAI;MACjC,KAAK,YAAY,QAAQ,IAAI;MAC7B,KAAK,UAAU,KAAK,UAAU;MAC9B,MAAM,MAAM,OAAO,iBAAgB,WAAU;OAC3C,MAAM,IAAI,OAAO,MAAM,WAAU,MAAK,EAAE,OAAO,EAAE;OACjD,OAAO,MAAM,KAAK;OAClB,OAAO,CAAC,IAAI;MACd,CAAC;MACD,KAAK,KAAK;OAAE,IAAI;OAAM,OAAO,EAAE,SAAS,KAAK;MAAE,CAAC;MAChD;KACF;KACA,IAAI,WAAW,OAAO;MACpB,IAAI,QAAQ,QAAQ,KAAA,GAAW;OAE7B,KAAK,KADK,KAAK,iBAAiB,+BACtB,CAAC,CAAC,KAAK,GAAG;OACpB;MACF;MACA,MAAM,SAAS,MAAM,QAAQ,IAAI,EAAE;MACnC,IAAI,OAAO,IAAI,KAAK,KAAK;OAAE,IAAI;OAAM,OAAO;MAAO,GAAG,GAAG;WACpD;OACH,MAAM,IAAI,KAAK,iBAAiB,OAAO,KAAK;OAC5C,KAAK,KAAK,EAAE,KAAK,EAAE,MAAM;MAC3B;MACA;KACF;KACA,IAAI,WAAW,UAAU;MACvB,IAAI,QAAQ,WAAW,KAAA,GAAW;OAEhC,KAAK,KADK,KAAK,iBAAiB,+BACtB,CAAC,CAAC,KAAK,GAAG;OACpB;MACF;MACA,MAAM,SAAS,MAAM,QAAQ,OAAO,EAAE;MACtC,IAAI,OAAO,IAAI,KAAK,KAAK;OAAE,IAAI;OAAM,OAAO;QAAE,WAAW;QAAM,aAAa,OAAO;OAAY;MAAE,GAAG,GAAG;WAClG;OACH,MAAM,IAAI,KAAK,iBAAiB,OAAO,KAAK;OAC5C,KAAK,KAAK,EAAE,KAAK,EAAE,MAAM;MAC3B;MACA;KACF;KACA,MAAM,IAAI,KAAK,aAAa,kBAAkB,QAAQ;KACtD,KAAK,KAAK,EAAE,KAAK,EAAE,MAAM;IAC3B,SAAS,OAAO;KACd,MAAM,IAAI,OAAO,KAAK;KACtB,KAAK,KAAK,EAAE,KAAK,EAAE,MAAM;IAC3B;IACA;GACF;GAEA,IAAI,UAAU,GAAG;GACjB,IAAI,IAAI;EACV,SAAS,OAAO;GACd,MAAM,IAAI,KAAK,YAAY,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;GACjF,KAAK,KAAK,EAAE,KAAK,EAAE,MAAM;EAC3B;CACF;CAEA,MAAM,OAAO,KAAsB,QAA8B;EAC/D,IAAI,UAAU,KAAK;GACjB,gBAAgB;GAChB,iBAAiB;GACjB,YAAY;EACd,CAAC;EACD,IAAI,MAAM,iBAAiB;EAE3B,IAAI,MAAM,uBAAuB,KAAK,UAAU,EAAE,UAAU,MAAM,SAAS,CAAC,CAAC,SAAS,CAAC,EAAE,KAAK;EAC9F,YAAY,IAAI,GAAG;EACnB,IAAI,cAAc,KAAA,GAChB,YAAY,kBAAkB;GAC5B,KAAK,MAAM,WAAW,aAAa,QAAQ,MAAM,YAAY;EAC/D,GAAG,YAAY;EAEjB,IAAI,GAAG,eAAe;GACpB,YAAY,OAAO,GAAG;GACtB,IAAI,YAAY,SAAS,KAAK,cAAc,KAAA,GAAW;IACrD,cAAc,SAAS;IACvB,YAAY,KAAA;GACd;EACF,CAAC;CACH;CAEA,MAAM,YAAY,CAChB,IAAI,UAAU,SAAS;EAAE,MAAM;EAAU,MAAM;EAAc;CAAQ,CAAC,GACtE,IAAI,UAAU,SAAS;EAAE,MAAM;EAAS,MAAM;EAAU,SAAS;CAAI,CAAC,CACxE;CACA,aAAa;EACX,KAAK,MAAM,WAAW,WAAW,QAAQ;EACzC,IAAI,cAAc,KAAA,GAAW,cAAc,SAAS;EACpD,KAAK,MAAM,OAAO,aAAa,IAAI,IAAI;EACvC,YAAY,MAAM;CACpB;AACF"}
1
+ {"version":3,"file":"routes.js","names":[],"sources":["../../src/host/routes.ts"],"sourcesContent":["/**\n * /dsh-taskboard routes on the shared DSH webserver: a JSON API for the\n * GUI's human operations (create/update/move/comment/delete — actor `user`,\n * the done move IS allowed here) plus an SSE stream mirroring every\n * committed ledger mutation.\n *\n * All domain validation goes through the shared protocol pure functions; the\n * route layer only maps transport to envelope.\n *\n * @module dsh-taskboard/host/routes\n */\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { Context } from '@deepseek-ai/cordis'\n// Type-only: pulls the webServer Context merge (ctx.webServer).\nimport type {} from '@deepseek-ai/dsh-host-webserver'\nimport {\n asStatus,\n asUrgency,\n canTransition,\n newCommentId,\n newTaskId,\n normalizeBody,\n normalizeExecution,\n normalizeModel,\n normalizePrompt,\n normalizeTitle,\n summarize,\n syncClaim,\n type TaskModel,\n type TaskRecord,\n} from '../shared/protocol.ts'\nimport { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'\nimport type { TaskStore } from './store.ts'\nimport type { WorkspaceFace } from './tools.ts'\n\n/** Heartbeat cadence for the SSE stream. */\nconst HEARTBEAT_MS = 20_000\n\n/** The workspaces face routes need (same narrow shape as tools). */\nexport type RoutesWorkspaceFace = WorkspaceFace\n\n/** Options. */\nexport interface TaskboardRoutesOptions {\n store: TaskStore\n workspaces: RoutesWorkspaceFace\n now: () => number\n /** Manual-run hook (the execution service); absent → 501. */\n run?: (taskId: string) => Promise<{ ok: true; executionId: string; sessionId: string } | { ok: false; error: string }>\n /** Cancel hook (the execution service); absent → 501. */\n cancel?: (taskId: string) => Promise<{ ok: true; executionId: string } | { ok: false; error: string }>\n /**\n * Registered model provider routes (from the host llm runtime), for\n * advisory validation of pinned models; undefined = runtime unavailable.\n */\n modelProviders?: () => string[] | undefined\n}\n\n/** Validate a pinned model: structural check always, provider route when known. */\nfunction checkModel(raw: unknown, modelProviders?: () => string[] | undefined): TaskModel {\n const model = normalizeModel(raw)\n const providers = modelProviders?.()\n if (providers !== undefined && !providers.includes(model.provider)) {\n throw new Error(`Error: invalid_input: model provider \"${model.provider}\" has no registered route (available: ${providers.join(', ')})`)\n }\n return model\n}\n\n/** JSON-envelope writer. */\nfunction json(res: ServerResponse, payload: ApiResult<unknown>, status = 200): void {\n const body = JSON.stringify(payload)\n res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })\n res.end(body)\n}\n\n/** Domain failure → envelope + HTTP status. */\nfunction fail(code: ApiFail['error']['code'], message: string): { res: ApiFail; status: number } {\n const status = code === 'invalid_input' || code === 'invalid_transition' ? 400\n : code === 'not_found' ? 404\n : code === 'version_conflict' ? 409\n : code === 'forbidden' ? 403\n : 500\n return { res: { ok: false, error: { code, message } }, status }\n}\n\n/** Read one JSON body (null on parse failure). */\nasync function readBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {\n const chunks: Buffer[] = []\n for await (const chunk of req) chunks.push(chunk as Buffer)\n if (chunks.length === 0) return {}\n try {\n const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))\n return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null\n } catch {\n return null\n }\n}\n\n/** String field accessor (null when absent/not a string). */\nfunction str(body: Record<string, unknown>, key: string): string | null {\n const v = body[key]\n return typeof v === 'string' ? v : null\n}\n\n/** Number field accessor (undefined when absent; null when present but not a number). */\nfunction num(body: Record<string, unknown>, key: string): number | undefined | null {\n const v = body[key]\n if (v === undefined) return undefined\n return typeof v === 'number' && Number.isFinite(v) ? v : null\n}\n\n/** Map a thrown domain error to the envelope. */\nfunction toFail(error: unknown): { res: ApiFail; status: number } {\n const message = error instanceof Error ? error.message : String(error)\n const code = message.startsWith('Error: ') ? message.slice(7).split(':')[0] : undefined\n const known: ApiFail['error']['code'][] = ['invalid_input', 'not_found', 'version_conflict', 'invalid_transition', 'forbidden', 'internal']\n if (code !== undefined && (known as string[]).includes(code)) {\n return fail(code as ApiFail['error']['code'], message.slice(7 + code.length + 2))\n }\n if (code === 'workspace_mismatch') return fail('forbidden', message.slice(7 + code.length + 2))\n return fail('invalid_input', message)\n}\n\n/**\n * Register the taskboard routes.\n * @param ctx - context carrying the webServer service.\n * @param options - store + workspaces + clock.\n * @returns the disposer.\n */\nexport function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOptions): () => void {\n const { store, workspaces } = options\n const subscribers = new Set<ServerResponse>()\n let heartbeat: NodeJS.Timeout | undefined\n\n const broadcast = (change: { revision: number; kind: string; tasks: readonly TaskRecord[] }): void => {\n const frame = `event: change\\ndata: ${JSON.stringify({ revision: change.revision, kind: change.kind, tasks: change.tasks.map(summarize) })}\\n\\n`\n for (const res of subscribers) res.write(frame)\n }\n store.subscribe(broadcast)\n\n const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {\n try {\n const url = new URL(req.url ?? '/', 'http://x')\n const pathname = url.pathname\n\n // ---------------------------------------------------------------- GET\n if (req.method === 'GET') {\n if (pathname === `${ROUTE_PREFIX}/state`) {\n await store.load()\n json(res, { ok: true, value: store.snapshot() })\n return\n }\n if (pathname === `${ROUTE_PREFIX}/workspaces`) {\n json(res, { ok: true, value: workspaces.list() })\n return\n }\n const taskMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`))\n if (taskMatch !== null) {\n const task = store.get(taskMatch[1]!)\n if (task === undefined) { const f = fail('not_found', 'no such task'); json(res, f.res, f.status); return }\n json(res, { ok: true, value: task })\n return\n }\n res.writeHead(404)\n res.end()\n return\n }\n\n if (req.method !== 'POST') {\n res.writeHead(405)\n res.end()\n return\n }\n // CSRF fence: cross-site simple requests cannot set application/json.\n const contentType = req.headers['content-type'] ?? ''\n if (!contentType.toLowerCase().startsWith('application/json')) {\n const f = fail('invalid_input', 'content-type must be application/json')\n json(res, f.res, 415)\n return\n }\n const body = await readBody(req)\n if (body === null) {\n const f = fail('invalid_input', 'body is not a JSON object')\n json(res, f.res, 400)\n return\n }\n\n // ------------------------------------------------- POST /tasks (create)\n if (pathname === `${ROUTE_PREFIX}/tasks`) {\n try {\n const title = normalizeTitle(str(body, 'title') ?? '')\n const workspaceId = str(body, 'workspaceId') ?? ''\n if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')\n const urgency = asUrgency(str(body, 'urgency') ?? '')\n const status = str(body, 'status') === null ? 'todo' as const : asStatus(str(body, 'status')!)\n const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())\n const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)\n const now = options.now()\n const task: TaskRecord = {\n id: newTaskId(),\n title,\n description: (str(body, 'description') ?? '').trim(),\n prompt: normalizePrompt(str(body, 'prompt') ?? undefined),\n workspaceId,\n urgency,\n status,\n blocked: false,\n execution,\n model,\n version: 1,\n createdAt: now,\n updatedAt: now,\n createdBy: { kind: 'user' },\n updatedBy: { kind: 'user' },\n comments: [],\n executions: [],\n }\n await store.mutate('task-created', ledger => {\n ledger.tasks.push(task)\n return [task]\n })\n json(res, { ok: true, value: summarize(task) }, 201)\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n // ------------------------------------------- POST /tasks/:id/{action}\n const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/(\\\\w+)$`))\n if (actionMatch !== null) {\n const id = actionMatch[1]!\n const action = actionMatch[2]!\n try {\n const task = store.get(id)\n if (task === undefined) throw new Error('Error: not_found: no such task')\n if (action === 'update') {\n const ifVersion = num(body, 'ifVersion')\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n const next = structuredClone(task)\n const title = str(body, 'title')\n if (title !== null) next.title = normalizeTitle(title)\n const description = str(body, 'description')\n if (description !== null) next.description = description.trim()\n const prompt = str(body, 'prompt')\n if (prompt !== null) next.prompt = normalizePrompt(prompt)\n const urgency = str(body, 'urgency')\n if (urgency !== null) next.urgency = asUrgency(urgency)\n // GUI-only rebind to another project; validated against the workspace registry.\n const workspaceId = str(body, 'workspaceId')\n if (workspaceId !== null) {\n if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')\n next.workspaceId = workspaceId\n }\n if (typeof body.blocked === 'boolean') next.blocked = body.blocked\n // The GUI (task owner surface) may edit model/execution; null clears the model.\n if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())\n if (body.model === null) next.model = undefined\n else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)\n next.version = task.version + 1\n next.updatedAt = options.now()\n next.updatedBy = { kind: 'user' }\n await store.mutate('task-updated', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: summarize(next) })\n return\n }\n if (action === 'move') {\n const ifVersion = num(body, 'ifVersion')\n const status = str(body, 'status') ?? ''\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n const to = asStatus(status)\n if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)\n const next = structuredClone(task)\n next.status = to\n next.version = task.version + 1\n next.updatedAt = options.now()\n next.updatedBy = { kind: 'user' }\n if (task.status === 'todo' && to === 'in_progress') next.blocked = false\n // A user move records no holder; leaving in_progress releases any hold.\n syncClaim(next, to, options.now())\n await store.mutate('task-moved', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: summarize(next) })\n return\n }\n if (action === 'reject') {\n // Card quick-reject: back to todo + optional user comment in one\n // atomic mutation (a failed move never strands an orphan comment).\n const ifVersion = num(body, 'ifVersion')\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n if (!canTransition(task.status, 'todo')) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`)\n const next = structuredClone(task)\n next.status = 'todo'\n next.version = task.version + 1\n next.updatedAt = options.now()\n next.updatedBy = { kind: 'user' }\n syncClaim(next, 'todo', options.now())\n const commentText = str(body, 'body') ?? ''\n if (commentText.trim().length > 0) {\n next.comments.push({ id: newCommentId(), body: normalizeBody(commentText), version: 1, createdAt: options.now() })\n }\n await store.mutate('task-moved', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: summarize(next) })\n return\n }\n if (action === 'comment') {\n const bodyText = str(body, 'body') ?? ''\n const comment = { id: newCommentId(), body: normalizeBody(bodyText), version: 1, createdAt: options.now() }\n const next = structuredClone(task)\n next.comments.push(comment)\n next.version = task.version + 1\n next.updatedAt = options.now()\n await store.mutate('comment-added', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: comment }, 201)\n return\n }\n if (action === 'delete') {\n const purge = body.purge === true\n if (purge) {\n if (task.trashedAt === undefined) throw new Error('Error: invalid_input: purge requires a trashed task (soft-delete first)')\n await store.mutate('task-deleted', ledger => {\n ledger.tasks = ledger.tasks.filter(t => t.id !== id)\n return []\n })\n json(res, { ok: true, value: { purged: true } })\n return\n }\n const ifVersion = num(body, 'ifVersion')\n if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')\n if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)\n const next = structuredClone(task)\n next.trashedAt = options.now()\n next.version = task.version + 1\n await store.mutate('task-deleted', ledger => {\n const i = ledger.tasks.findIndex(t => t.id === id)\n ledger.tasks[i] = next\n return [next]\n })\n json(res, { ok: true, value: { trashed: true } })\n return\n }\n if (action === 'run') {\n if (options.run === undefined) {\n const f = fail('invalid_input', 'execution service unavailable')\n json(res, f.res, 501)\n return\n }\n const result = await options.run(id)\n if (result.ok) json(res, { ok: true, value: result }, 202)\n else {\n const f = fail('invalid_input', result.error)\n json(res, f.res, f.status)\n }\n return\n }\n if (action === 'cancel') {\n if (options.cancel === undefined) {\n const f = fail('invalid_input', 'execution service unavailable')\n json(res, f.res, 501)\n return\n }\n const result = await options.cancel(id)\n if (result.ok) json(res, { ok: true, value: { cancelled: true, executionId: result.executionId } }, 202)\n else {\n const f = fail('invalid_input', result.error)\n json(res, f.res, f.status)\n }\n return\n }\n const f = fail('not_found', `unknown action ${action}`)\n json(res, f.res, f.status)\n } catch (error) {\n const f = toFail(error)\n json(res, f.res, f.status)\n }\n return\n }\n\n res.writeHead(404)\n res.end()\n } catch (error) {\n const f = fail('internal', error instanceof Error ? error.message : String(error))\n json(res, f.res, f.status)\n }\n }\n\n const sse = (req: IncomingMessage, res: ServerResponse): void => {\n res.writeHead(200, {\n 'content-type': 'text/event-stream; charset=utf-8',\n 'cache-control': 'no-cache',\n connection: 'keep-alive',\n })\n res.write('retry: 2000\\n\\n')\n // Baseline frame: the client reconciles by revision and refetches state on gaps.\n res.write(`event: hello\\ndata: ${JSON.stringify({ revision: store.snapshot().revision })}\\n\\n`)\n subscribers.add(res)\n if (heartbeat === undefined) {\n heartbeat = setInterval(() => {\n for (const current of subscribers) current.write(': ping\\n\\n')\n }, HEARTBEAT_MS)\n }\n req.on('close', () => {\n subscribers.delete(res)\n if (subscribers.size === 0 && heartbeat !== undefined) {\n clearInterval(heartbeat)\n heartbeat = undefined\n }\n })\n }\n\n const disposers = [\n ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler }),\n ctx.webServer.register({ kind: 'exact', path: SSE_PATH, handler: sse }),\n ]\n return () => {\n for (const dispose of disposers) dispose()\n if (heartbeat !== undefined) clearInterval(heartbeat)\n for (const res of subscribers) res.end()\n subscribers.clear()\n }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"api.js","names":[],"sources":["../../src/shared/api.ts"],"sourcesContent":["/**\n * Wire contract for the /taskboard host routes: the JSON envelope,\n * request/response shapes, and SSE event payloads shared by the host routes\n * and the browser client.\n *\n * @module dsh-taskboard/shared/api\n */\nimport type { TaskLedger, TaskRecord, TaskSummary } from './protocol.ts'\n\nexport type { TaskRecord }\n\n/** Route prefix on the shared DSH webserver (same origin as the GUI). */\nexport const ROUTE_PREFIX = '/dsh-taskboard'\n\n/** SSE stream path (exact route; longest-prefix wins keep it disjoint). */\nexport const SSE_PATH = '/dsh-taskboard/events'\n\n/** Stable error codes (mirror the tool-level codes plus HTTP mapping). */\nexport type ApiErrorCode =\n | 'invalid_input'\n | 'not_found'\n | 'version_conflict'\n | 'invalid_transition'\n | 'forbidden'\n | 'internal'\n\n/** Success envelope. */\nexport type ApiOk<T> = { ok: true; value: T }\n\n/** Failure envelope. */\nexport type ApiFail = { ok: false; error: { code: ApiErrorCode; message: string } }\n\n/** The envelope either way. */\nexport type ApiResult<T> = ApiOk<T> | ApiFail\n\n// ---------------------------------------------------------------------------\n// payloads\n// ---------------------------------------------------------------------------\n\n/** Full-state response (the reconnect baseline after an SSE gap). */\nexport type StateResponse = TaskLedger\n\n/** Workspace listing for the UI pickers. */\nexport type WorkspaceView = { id: string; path: string; title: string; sessionCount: number }\n\n/** Create-task request body (actor is always the GUI user). */\nexport type CreateTaskBody = {\n title: string\n workspaceId: string\n urgency: string\n description?: string\n prompt?: string\n execution?: { mode?: string; cron?: string }\n model?: { provider: string; model: string }\n}\n\n/** Update-task request body (ifVersion mandatory). */\nexport type UpdateTaskBody = {\n ifVersion: number\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n blocked?: boolean\n /** Rebind the task to another project (GUI owner surface only). */\n workspaceId?: string\n execution?: { mode?: string; cron?: string }\n model?: { provider: string; model: string } | null\n}\n\n/** Move-task request body (ifVersion mandatory; the user MAY move to done). */\nexport type MoveTaskBody = { ifVersion: number; status: string }\n\n/** Comment request body. */\nexport type CommentBody = { body: string }\n\n/** Delete request body (purge=true physically removes a trashed task). */\nexport type DeleteTaskBody = { ifVersion?: number; purge?: boolean }\n\n/** Run request body (P3). */\nexport type RunTaskBody = Record<string, never>\n\n/** One task (full record) response. */\nexport type TaskResponse = TaskRecord\n\n/** Summary response used by list-ish endpoints. */\nexport type SummaryResponse = { tasks: TaskSummary[] }\n\n// ---------------------------------------------------------------------------\n// SSE\n// ---------------------------------------------------------------------------\n\n/** Change frame pushed on every committed ledger mutation. */\nexport type ChangeEvent = {\n revision: number\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded'\n tasks: TaskSummary[]\n}\n"],"mappings":";;AAYA,MAAa,eAAe;;AAG5B,MAAa,WAAW"}
1
+ {"version":3,"file":"api.js","names":[],"sources":["../../src/shared/api.ts"],"sourcesContent":["/**\n * Wire contract for the /taskboard host routes: the JSON envelope,\n * request/response shapes, and SSE event payloads shared by the host routes\n * and the browser client.\n *\n * @module dsh-taskboard/shared/api\n */\nimport type { TaskLedger, TaskRecord, TaskSummary } from './protocol.ts'\n\nexport type { TaskRecord }\n\n/** Route prefix on the shared DSH webserver (same origin as the GUI). */\nexport const ROUTE_PREFIX = '/dsh-taskboard'\n\n/** SSE stream path (exact route; longest-prefix wins keep it disjoint). */\nexport const SSE_PATH = '/dsh-taskboard/events'\n\n/** Stable error codes (mirror the tool-level codes plus HTTP mapping). */\nexport type ApiErrorCode =\n | 'invalid_input'\n | 'not_found'\n | 'version_conflict'\n | 'invalid_transition'\n | 'forbidden'\n | 'internal'\n\n/** Success envelope. */\nexport type ApiOk<T> = { ok: true; value: T }\n\n/** Failure envelope. */\nexport type ApiFail = { ok: false; error: { code: ApiErrorCode; message: string } }\n\n/** The envelope either way. */\nexport type ApiResult<T> = ApiOk<T> | ApiFail\n\n// ---------------------------------------------------------------------------\n// payloads\n// ---------------------------------------------------------------------------\n\n/** Full-state response (the reconnect baseline after an SSE gap). */\nexport type StateResponse = TaskLedger\n\n/** Workspace listing for the UI pickers. */\nexport type WorkspaceView = { id: string; path: string; title: string; sessionCount: number }\n\n/** Create-task request body (actor is always the GUI user). */\nexport type CreateTaskBody = {\n title: string\n workspaceId: string\n urgency: string\n description?: string\n prompt?: string\n execution?: { mode?: string; cron?: string }\n model?: { provider: string; model: string }\n}\n\n/** Update-task request body (ifVersion mandatory). */\nexport type UpdateTaskBody = {\n ifVersion: number\n title?: string\n description?: string\n prompt?: string\n urgency?: string\n blocked?: boolean\n /** Rebind the task to another project (GUI owner surface only). */\n workspaceId?: string\n execution?: { mode?: string; cron?: string }\n model?: { provider: string; model: string } | null\n}\n\n/** Move-task request body (ifVersion mandatory; the user MAY move to done). */\nexport type MoveTaskBody = { ifVersion: number; status: string }\n\n/**\n * Quick-reject request body (card ✗ button): move back to todo plus an\n * optional user comment, committed as ONE ledger mutation so a failed move\n * can never strand an orphan comment.\n */\nexport type RejectTaskBody = { ifVersion: number; body?: string }\n\n/** Comment request body. */\nexport type CommentBody = { body: string }\n\n/** Delete request body (purge=true physically removes a trashed task). */\nexport type DeleteTaskBody = { ifVersion?: number; purge?: boolean }\n\n/** Run request body (P3). */\nexport type RunTaskBody = Record<string, never>\n\n/** One task (full record) response. */\nexport type TaskResponse = TaskRecord\n\n/** Summary response used by list-ish endpoints. */\nexport type SummaryResponse = { tasks: TaskSummary[] }\n\n// ---------------------------------------------------------------------------\n// SSE\n// ---------------------------------------------------------------------------\n\n/** Change frame pushed on every committed ledger mutation. */\nexport type ChangeEvent = {\n revision: number\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded'\n tasks: TaskSummary[]\n}\n"],"mappings":";;AAYA,MAAa,eAAe;;AAG5B,MAAa,WAAW"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-taskboard",
3
3
  "description": "Agent-first task board for the DSH web GUI: host-authoritative task ledger with taskboard_* agent tools, project (= workspace) claim boundaries, per-task model execution in fresh sessions, host-side cron scheduling, and a live SSE kanban view. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.2.1",
4
+ "version": "0.2.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
package/src/client/api.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  CreateTaskBody,
12
12
  DeleteTaskBody,
13
13
  MoveTaskBody,
14
+ RejectTaskBody,
14
15
  StateResponse,
15
16
  TaskRecord,
16
17
  UpdateTaskBody,
@@ -44,6 +45,8 @@ export interface TaskboardClient {
44
45
  get(id: string): Promise<TaskRecord>
45
46
  update(id: string, body: UpdateTaskBody): Promise<TaskSummary>
46
47
  move(id: string, body: MoveTaskBody): Promise<TaskSummary>
48
+ /** Quick-reject (card ✗): back to todo + optional comment, one mutation. */
49
+ reject(id: string, body: RejectTaskBody): Promise<TaskSummary>
47
50
  comment(id: string, bodyText: string): Promise<CommentRecord>
48
51
  remove(id: string, body: DeleteTaskBody): Promise<{ trashed?: boolean; purged?: boolean }>
49
52
  /** Trigger a manual run (fresh in-project session). */
@@ -63,6 +66,7 @@ export function createClient(): TaskboardClient {
63
66
  get: id => unwrap<TaskRecord>(fetch(`/dsh-taskboard/tasks/${encodeURIComponent(id)}`)),
64
67
  update: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/update`, body),
65
68
  move: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/move`, body),
69
+ reject: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/reject`, body),
66
70
  comment: (id, bodyText) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/comment`, { body: bodyText }),
67
71
  remove: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/delete`, body),
68
72
  run: id => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/run`, {}),
@@ -2,10 +2,16 @@
2
2
  * One board card: urgency edge, title, project/urgency/model/schedule/
3
3
  * blocked/trashed badges, comment count, and the last execution outcome.
4
4
  * Click opens the detail pane; cards in the backlog/todo columns are
5
- * draggable between those two columns (HTML5 drag & drop).
5
+ * draggable between those two columns (HTML5 drag & drop). Cards sitting
6
+ * in the in_review column also carry quick-review actions (✓ complete /
7
+ * ✗ send back with an optional note).
8
+ *
9
+ * The root is a div[role=button] (not a <button>) so the quick actions can
10
+ * be real nested buttons — valid HTML and native keyboard activation.
6
11
  *
7
12
  * @module dsh-taskboard/client/board/TaskCard
8
13
  */
14
+ import { useState } from 'react'
9
15
  import type { BoardController } from '../controller.ts'
10
16
  import type { TaskRecord } from '../../shared/protocol.ts'
11
17
  import { fmtTime, isStaleClaim } from './TaskBoard.tsx'
@@ -25,15 +31,28 @@ export const DRAG_TYPE = 'application/x-dsh-atb-task'
25
31
  * @param onAlert - show an alert message (replaces native alert).
26
32
  */
27
33
  export function TaskCard({ task, controller, draggable = false, now, onAlert }: { task: TaskRecord; controller: BoardController; draggable?: boolean; now?: number; onAlert?: (msg: string) => void }) {
34
+ const [rejectOpen, setRejectOpen] = useState(false)
35
+ const [note, setNote] = useState('')
28
36
  const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : undefined
29
37
  const running = task.executions.find(ex => ex.outcome === 'running')
30
38
  const stale = now !== undefined && isStaleClaim(task, now)
39
+ const reviewing = task.status === 'in_review' && task.trashedAt === undefined
40
+
41
+ /** Submit the quick-reject: one atomic route (move + optional note). */
42
+ const submitReject = (): void => {
43
+ void controller.reject(task.id, task.version, note).then(ok => {
44
+ if (ok) { setRejectOpen(false); setNote('') }
45
+ // On failure the error surface explains; the form stays open for retry.
46
+ })
47
+ }
48
+
31
49
  return (
32
- <button
33
- type="button"
50
+ <div
51
+ role="button"
52
+ tabIndex={0}
34
53
  className="dsh-atb-card"
35
54
  data-urgency={task.urgency}
36
- draggable={draggable}
55
+ draggable={draggable && !rejectOpen}
37
56
  onDragStart={(e) => {
38
57
  // Block drag if a session is still executing this task
39
58
  if (running !== undefined) {
@@ -49,6 +68,14 @@ export function TaskCard({ task, controller, draggable = false, now, onAlert }:
49
68
  }}
50
69
  onDragEnd={(e) => { delete e.currentTarget.dataset.dragging }}
51
70
  onClick={() => controller.select(task.id)}
71
+ onKeyDown={(e) => {
72
+ // Only the card itself (not the nested quick-action controls).
73
+ if (e.target !== e.currentTarget) return
74
+ if (e.key === 'Enter' || e.key === ' ') {
75
+ e.preventDefault()
76
+ controller.select(task.id)
77
+ }
78
+ }}
52
79
  >
53
80
  <div className="dsh-atb-card-title">{task.title}</div>
54
81
  <div className="dsh-atb-card-meta">
@@ -69,6 +96,47 @@ export function TaskCard({ task, controller, draggable = false, now, onAlert }:
69
96
  {task.trashedAt !== undefined && <span className="dsh-atb-badge" data-kind="trashed">待清除</span>}
70
97
  <span style={{ marginLeft: 'auto' }}>{fmtTime(task.updatedAt)}</span>
71
98
  </div>
72
- </button>
99
+ {reviewing && (rejectOpen
100
+ ? (
101
+ <div className="dsh-atb-quick-reject" onClick={e => e.stopPropagation()}>
102
+ <input
103
+ className="dsh-atb-input dsh-atb-quick-note"
104
+ value={note}
105
+ placeholder="退回原因(可选,agent 开工前会读)…"
106
+ autoFocus
107
+ spellCheck={false}
108
+ onChange={e => setNote(e.target.value)}
109
+ onKeyDown={e => {
110
+ if (e.key === 'Enter') submitReject()
111
+ else if (e.key === 'Escape') { setRejectOpen(false); setNote('') }
112
+ }}
113
+ />
114
+ <button type="button" className="dsh-atb-quickbtn" data-act="reject-confirm" onClick={submitReject}>退回待办</button>
115
+ <button type="button" className="dsh-atb-quickbtn" data-act="reject-cancel" onClick={() => { setRejectOpen(false); setNote('') }}>取消</button>
116
+ </div>
117
+ )
118
+ : (
119
+ <div className="dsh-atb-quick" onClick={e => e.stopPropagation()}>
120
+ <button
121
+ type="button"
122
+ className="dsh-atb-quickbtn"
123
+ data-act="done"
124
+ title="验收完成:移至已完成"
125
+ onClick={() => void controller.move(task.id, task.version, 'done')}
126
+ >
127
+ ✓ 完成
128
+ </button>
129
+ <button
130
+ type="button"
131
+ className="dsh-atb-quickbtn"
132
+ data-act="reject"
133
+ title="退回待办,可附退回原因"
134
+ onClick={() => setRejectOpen(true)}
135
+ >
136
+ ✗ 退回
137
+ </button>
138
+ </div>
139
+ ))}
140
+ </div>
73
141
  )
74
142
  }
@@ -286,6 +286,25 @@ export class BoardController {
286
286
  }
287
287
  }
288
288
 
289
+ /**
290
+ * Quick-reject (card ✗ button): move back to todo with an optional user
291
+ * comment, committed atomically host-side. Returns whether the task moved.
292
+ * @param id - task id.
293
+ * @param ifVersion - optimistic version (captured at click time).
294
+ * @param comment - optional comment text; blank = move only.
295
+ */
296
+ async reject(id: string, ifVersion: number, comment?: string): Promise<boolean> {
297
+ const body = comment !== undefined && comment.trim().length > 0 ? comment.trim() : undefined
298
+ try {
299
+ await this.client.reject(id, body === undefined ? { ifVersion } : { ifVersion, body })
300
+ await this.refresh()
301
+ return true
302
+ } catch (error) {
303
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
304
+ return false
305
+ }
306
+ }
307
+
289
308
  /** Toggle the blocked marker. */
290
309
  async toggleBlocked(task: TaskRecord): Promise<void> {
291
310
  try {
@@ -148,6 +148,21 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
148
148
  .dsh-atb-badge[data-kind="done"] { background: rgba(46,160,67,.16); color: #2ea043; }
149
149
  .dsh-atb-badge[data-kind="running"] { background: rgba(229,152,42,.16); color: #e69842; }
150
150
 
151
+ /* ---------- card quick review (in_review column) ---------- */
152
+ .dsh-atb-quick { display: flex; gap: 6px; margin-top: 7px; }
153
+ .dsh-atb-quickbtn {
154
+ flex: 1; font-size: 11.5px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
155
+ border: 1px solid var(--dsw-border, rgba(128,128,128,.3));
156
+ background: var(--dsw-bg-elevated, rgba(128,128,128,.12)); color: inherit;
157
+ }
158
+ .dsh-atb-quickbtn:hover { border-color: var(--dsw-border-strong, rgba(128,128,128,.6)); }
159
+ .dsh-atb-quickbtn[data-act="done"] { background: rgba(46,160,67,.14); color: #2ea043; border-color: rgba(46,160,67,.4); }
160
+ .dsh-atb-quickbtn[data-act="done"]:hover { background: rgba(46,160,67,.22); }
161
+ .dsh-atb-quickbtn[data-act="reject"] { background: rgba(229,152,42,.12); color: #d9822b; border-color: rgba(229,152,42,.4); }
162
+ .dsh-atb-quickbtn[data-act="reject"]:hover { background: rgba(229,152,42,.2); }
163
+ .dsh-atb-quick-reject { display: flex; gap: 6px; margin-top: 7px; align-items: stretch; }
164
+ .dsh-atb-quick-note { flex: 1; min-width: 0; font-size: 11.5px; padding: 3px 8px; }
165
+
151
166
  .dsh-atb-error { font-size: 12px; color: #e5484d; padding: 4px 8px; border-radius: 6px; background: rgba(229,72,77,.1); }
152
167
  .dsh-atb-empty { font-size: 12px; color: var(--dsw-text-secondary, gray); padding: 10px 4px; }
153
168
 
@@ -74,7 +74,7 @@ function json(res: ServerResponse, payload: ApiResult<unknown>, status = 200): v
74
74
 
75
75
  /** Domain failure → envelope + HTTP status. */
76
76
  function fail(code: ApiFail['error']['code'], message: string): { res: ApiFail; status: number } {
77
- const status = code === 'invalid_input' ? 400
77
+ const status = code === 'invalid_input' || code === 'invalid_transition' ? 400
78
78
  : code === 'not_found' ? 404
79
79
  : code === 'version_conflict' ? 409
80
80
  : code === 'forbidden' ? 403
@@ -292,6 +292,31 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
292
292
  json(res, { ok: true, value: summarize(next) })
293
293
  return
294
294
  }
295
+ if (action === 'reject') {
296
+ // Card quick-reject: back to todo + optional user comment in one
297
+ // atomic mutation (a failed move never strands an orphan comment).
298
+ const ifVersion = num(body, 'ifVersion')
299
+ if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
300
+ if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
301
+ if (!canTransition(task.status, 'todo')) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`)
302
+ const next = structuredClone(task)
303
+ next.status = 'todo'
304
+ next.version = task.version + 1
305
+ next.updatedAt = options.now()
306
+ next.updatedBy = { kind: 'user' }
307
+ syncClaim(next, 'todo', options.now())
308
+ const commentText = str(body, 'body') ?? ''
309
+ if (commentText.trim().length > 0) {
310
+ next.comments.push({ id: newCommentId(), body: normalizeBody(commentText), version: 1, createdAt: options.now() })
311
+ }
312
+ await store.mutate('task-moved', ledger => {
313
+ const i = ledger.tasks.findIndex(t => t.id === id)
314
+ ledger.tasks[i] = next
315
+ return [next]
316
+ })
317
+ json(res, { ok: true, value: summarize(next) })
318
+ return
319
+ }
295
320
  if (action === 'comment') {
296
321
  const bodyText = str(body, 'body') ?? ''
297
322
  const comment = { id: newCommentId(), body: normalizeBody(bodyText), version: 1, createdAt: options.now() }
package/src/shared/api.ts CHANGED
@@ -71,6 +71,13 @@ export type UpdateTaskBody = {
71
71
  /** Move-task request body (ifVersion mandatory; the user MAY move to done). */
72
72
  export type MoveTaskBody = { ifVersion: number; status: string }
73
73
 
74
+ /**
75
+ * Quick-reject request body (card ✗ button): move back to todo plus an
76
+ * optional user comment, committed as ONE ledger mutation so a failed move
77
+ * can never strand an orphan comment.
78
+ */
79
+ export type RejectTaskBody = { ifVersion: number; body?: string }
80
+
74
81
  /** Comment request body. */
75
82
  export type CommentBody = { body: string }
76
83
 
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  /** The package version (must equal package.json "version"). */
9
- export const PLUGIN_VERSION = '0.2.1'
9
+ export const PLUGIN_VERSION = '0.2.2'