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