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 +7 -1
- package/lib/client.js +184 -55
- package/lib/host/routes.js +30 -1
- package/lib/host/routes.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +4 -0
- package/src/client/board/TaskCard.tsx +73 -5
- package/src/client/controller.ts +19 -0
- package/src/client/styles.ts +15 -0
- package/src/host/routes.ts +26 -1
- package/src/shared/api.ts +7 -0
- package/src/shared/version.ts +1 -1
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
|
|
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.
|
|
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
|
-
|
|
1345
|
-
|
|
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
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
task.
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
|
package/lib/host/routes.js
CHANGED
|
@@ -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 = {
|
package/lib/host/routes.js.map
CHANGED
|
@@ -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":"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"}
|
|
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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"}
|
package/lib/shared/api.js.map
CHANGED
|
@@ -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.
|
|
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
|
-
<
|
|
33
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/client/controller.ts
CHANGED
|
@@ -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 {
|
package/src/client/styles.ts
CHANGED
|
@@ -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
|
|
package/src/host/routes.ts
CHANGED
|
@@ -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
|
|
package/src/shared/version.ts
CHANGED