dsh-taskboard 0.4.5 → 0.5.1
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 +24 -1
- package/lib/client.js +434 -193
- package/lib/host/execution.js +80 -33
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +49 -5
- package/lib/host/git.js.map +1 -1
- package/lib/host/routes.js +210 -112
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +50 -28
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/sdk.js +7 -2
- package/lib/host/sdk.js.map +1 -1
- package/lib/host/store.js +41 -8
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +10 -3
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +128 -97
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +3 -1
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +48 -5
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +9 -8
- package/src/client/api.ts +26 -8
- package/src/client/board/ImportModal.tsx +1 -1
- package/src/client/board/SettingsModal.tsx +84 -0
- package/src/client/board/TaskBoard.tsx +47 -40
- package/src/client/board/TaskCard.tsx +3 -5
- package/src/client/board/TaskDetail.tsx +30 -21
- package/src/client/board/TaskFormModal.tsx +39 -31
- package/src/client/board/format.ts +26 -0
- package/src/client/board/labels.ts +44 -0
- package/src/client/controller.ts +86 -34
- package/src/client/index.ts +7 -5
- package/src/client/sidebar-entry.ts +5 -1
- package/src/client/styles.ts +4 -0
- package/src/host/execution.ts +90 -16
- package/src/host/git.ts +39 -10
- package/src/host/routes.ts +263 -128
- package/src/host/scheduler.ts +62 -36
- package/src/host/sdk.ts +12 -1
- package/src/host/store.ts +53 -7
- package/src/host/templates.ts +12 -3
- package/src/host/tools.ts +187 -126
- package/src/index.ts +10 -1
- package/src/shared/api.ts +11 -2
- package/src/shared/protocol.ts +83 -6
- package/src/shared/version.ts +1 -1
- package/src/client/board/NewTaskModal.tsx +0 -8
package/lib/host/routes.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import { asIsolation, asStatus, asUrgency, canTransition, checklistFromTexts, newCommentId, newTaskId, normalizeBody, normalizeChecklist, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim, validateLedgerImport } from "../shared/protocol.js";
|
|
1
|
+
import { asBoardSettings, asIsolation, asStatus, asUrgency, canTransition, checklistFromTexts, defaultIsolationOf, newCommentId, newTaskId, normalizeBody, normalizeChecklist, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim, validateLedgerImport } from "../shared/protocol.js";
|
|
2
2
|
import { WORKTREE_DIR, worktreePathOf } from "./git.js";
|
|
3
3
|
import { ROUTE_PREFIX, SSE_PATH } from "../shared/api.js";
|
|
4
|
-
import {
|
|
4
|
+
import { ERR, ToolError } from "./tools.js";
|
|
5
|
+
import { join, resolve, sep } from "node:path";
|
|
5
6
|
import { readdir, rm } from "node:fs/promises";
|
|
6
7
|
//#region src/host/routes.ts
|
|
7
8
|
/** Heartbeat cadence for the SSE stream. */
|
|
8
9
|
const HEARTBEAT_MS = 2e4;
|
|
10
|
+
/** Max accepted JSON body bytes (S8: unbounded buffering is a local OOM vector). */
|
|
11
|
+
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
12
|
+
/** Route shapes (T2: compiled once at module load, not on every request). */
|
|
13
|
+
const TASK_DIFF_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`);
|
|
14
|
+
const TASK_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`);
|
|
15
|
+
const TASK_ACTION_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`);
|
|
9
16
|
/** How long a workspace git-detection result stays cached (fail-soft). */
|
|
10
17
|
const GIT_DETECT_TTL_MS = 6e4;
|
|
11
18
|
/** Validate a template's task spec (routes-side, unknown → invalid_input). */
|
|
12
|
-
function normalizeTemplateSpec(raw) {
|
|
19
|
+
function normalizeTemplateSpec(raw, now) {
|
|
13
20
|
if (typeof raw !== "object" || raw === null) throw new Error("Error: invalid_input: task must be an object");
|
|
14
21
|
const e = raw;
|
|
15
22
|
const spec = {};
|
|
@@ -31,7 +38,7 @@ function normalizeTemplateSpec(raw) {
|
|
|
31
38
|
if (urgency !== void 0) spec.urgency = asUrgency(urgency);
|
|
32
39
|
if (isolation !== void 0) spec.isolation = asIsolation(isolation);
|
|
33
40
|
if (presetId !== void 0 && presetId.trim().length > 0) spec.presetId = presetId.trim();
|
|
34
|
-
if (e.execution !== void 0) spec.execution = normalizeExecution(e.execution,
|
|
41
|
+
if (e.execution !== void 0) spec.execution = normalizeExecution(e.execution, now);
|
|
35
42
|
if (e.model !== void 0) spec.model = normalizeModel(e.model);
|
|
36
43
|
if (e.checklist !== void 0) {
|
|
37
44
|
if (!Array.isArray(e.checklist) || e.checklist.some((c) => typeof c !== "string")) throw new Error("Error: invalid_input: task.checklist must be an array of strings");
|
|
@@ -69,10 +76,19 @@ function fail(code, message) {
|
|
|
69
76
|
status: code === "invalid_input" || code === "invalid_transition" ? 400 : code === "not_found" ? 404 : code === "version_conflict" ? 409 : code === "forbidden" ? 403 : 500
|
|
70
77
|
};
|
|
71
78
|
}
|
|
72
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Read one JSON body (null on parse failure). S8: rejects bodies over
|
|
81
|
+
* MAX_BODY_BYTES by throwing — the local, unauthenticated HTTP surface must
|
|
82
|
+
* not be an unbounded memory sink.
|
|
83
|
+
*/
|
|
73
84
|
async function readBody(req) {
|
|
74
85
|
const chunks = [];
|
|
75
|
-
|
|
86
|
+
let total = 0;
|
|
87
|
+
for await (const chunk of req) {
|
|
88
|
+
total += chunk.length;
|
|
89
|
+
if (total > MAX_BODY_BYTES) throw new Error("body too large");
|
|
90
|
+
chunks.push(chunk);
|
|
91
|
+
}
|
|
76
92
|
if (chunks.length === 0) return {};
|
|
77
93
|
try {
|
|
78
94
|
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
@@ -92,6 +108,15 @@ function num(body, key) {
|
|
|
92
108
|
if (v === void 0) return void 0;
|
|
93
109
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
94
110
|
}
|
|
111
|
+
/** Find a live task INSIDE a mutator (R1: guards run on the fresh draft). */
|
|
112
|
+
function liveTaskAt(ledger, id) {
|
|
113
|
+
const index = ledger.tasks.findIndex((t) => t.id === id);
|
|
114
|
+
if (index < 0 || ledger.tasks[index].trashedAt !== void 0) throw new Error("Error: not_found: no such task");
|
|
115
|
+
return {
|
|
116
|
+
index,
|
|
117
|
+
task: ledger.tasks[index]
|
|
118
|
+
};
|
|
119
|
+
}
|
|
95
120
|
/** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
|
|
96
121
|
function normalizePresetId(raw) {
|
|
97
122
|
const t = (raw ?? "").trim();
|
|
@@ -100,6 +125,17 @@ function normalizePresetId(raw) {
|
|
|
100
125
|
/** Map a thrown domain error to the envelope. */
|
|
101
126
|
function toFail(error) {
|
|
102
127
|
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
+
if (error instanceof ToolError) {
|
|
129
|
+
const mapped = error.code === ERR.workspaceMismatch ? "forbidden" : error.code;
|
|
130
|
+
if ([
|
|
131
|
+
"invalid_input",
|
|
132
|
+
"not_found",
|
|
133
|
+
"version_conflict",
|
|
134
|
+
"invalid_transition",
|
|
135
|
+
"forbidden",
|
|
136
|
+
"internal"
|
|
137
|
+
].includes(mapped)) return fail(mapped, message.slice(7 + error.code.length + 2));
|
|
138
|
+
}
|
|
103
139
|
const code = message.startsWith("Error: ") ? message.slice(7).split(":")[0] : void 0;
|
|
104
140
|
if (code !== void 0 && [
|
|
105
141
|
"invalid_input",
|
|
@@ -122,6 +158,12 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
122
158
|
const { store, workspaces } = options;
|
|
123
159
|
const subscribers = /* @__PURE__ */ new Set();
|
|
124
160
|
let heartbeat;
|
|
161
|
+
/** R4③: a cleanup/purge target must resolve INSIDE <ws>/.dsh-worktrees — string joining alone is never trusted with an rm. */
|
|
162
|
+
const insideWorktreeScope = (wsPath, target) => {
|
|
163
|
+
const scope = resolve(wsPath, WORKTREE_DIR);
|
|
164
|
+
const resolved = resolve(target);
|
|
165
|
+
return resolved === scope || resolved.startsWith(scope + sep);
|
|
166
|
+
};
|
|
125
167
|
const broadcast = (change) => {
|
|
126
168
|
const frame = `event: change\ndata: ${JSON.stringify({
|
|
127
169
|
revision: change.revision,
|
|
@@ -130,7 +172,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
130
172
|
})}\n\n`;
|
|
131
173
|
for (const res of subscribers) res.write(frame);
|
|
132
174
|
};
|
|
133
|
-
store.subscribe(broadcast);
|
|
175
|
+
const unsubscribeBroadcast = store.subscribe(broadcast);
|
|
134
176
|
const gitCache = /* @__PURE__ */ new Map();
|
|
135
177
|
const gitHinted = /* @__PURE__ */ new Set();
|
|
136
178
|
/** Whether <root>/.gitignore (missing file counts as missing) ignores our worktree dir. */
|
|
@@ -235,7 +277,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
235
277
|
});
|
|
236
278
|
return;
|
|
237
279
|
}
|
|
238
|
-
const diffMatch = pathname.match(
|
|
280
|
+
const diffMatch = pathname.match(TASK_DIFF_RE);
|
|
239
281
|
if (diffMatch !== null) {
|
|
240
282
|
try {
|
|
241
283
|
if (options.git === void 0) {
|
|
@@ -278,7 +320,15 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
278
320
|
});
|
|
279
321
|
return;
|
|
280
322
|
}
|
|
281
|
-
|
|
323
|
+
if (pathname === `/dsh-taskboard/settings`) {
|
|
324
|
+
await store.load();
|
|
325
|
+
json(res, {
|
|
326
|
+
ok: true,
|
|
327
|
+
value: store.snapshot().settings ?? {}
|
|
328
|
+
});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const taskMatch = pathname.match(TASK_RE);
|
|
282
332
|
if (taskMatch !== null) {
|
|
283
333
|
const task = store.get(taskMatch[1]);
|
|
284
334
|
if (task === void 0) {
|
|
@@ -297,7 +347,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
297
347
|
return;
|
|
298
348
|
}
|
|
299
349
|
if (req.method !== "POST") {
|
|
300
|
-
res.writeHead(405);
|
|
350
|
+
res.writeHead(405, { allow: "GET, POST" });
|
|
301
351
|
res.end();
|
|
302
352
|
return;
|
|
303
353
|
}
|
|
@@ -305,7 +355,13 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
305
355
|
json(res, fail("invalid_input", "content-type must be application/json").res, 415);
|
|
306
356
|
return;
|
|
307
357
|
}
|
|
308
|
-
|
|
358
|
+
let body;
|
|
359
|
+
try {
|
|
360
|
+
body = await readBody(req);
|
|
361
|
+
} catch {
|
|
362
|
+
json(res, fail("invalid_input", `request body exceeds ${MAX_BODY_BYTES} bytes`).res, 413);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
309
365
|
if (body === null) {
|
|
310
366
|
json(res, fail("invalid_input", "body is not a JSON object").res, 400);
|
|
311
367
|
return;
|
|
@@ -317,10 +373,11 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
317
373
|
if (workspaces.get(workspaceId) === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
318
374
|
const urgency = asUrgency(str(body, "urgency") ?? "");
|
|
319
375
|
const status = str(body, "status") === null ? "todo" : asStatus(str(body, "status"));
|
|
376
|
+
if (status !== "backlog" && status !== "todo") throw new Error("Error: invalid_transition: a new task must start as backlog or todo");
|
|
320
377
|
const execution = normalizeExecution(body.execution ?? {}, options.now());
|
|
321
378
|
const model = body.model === void 0 ? void 0 : checkModel(body.model, options.modelProviders);
|
|
322
379
|
const isolationRaw = str(body, "isolation");
|
|
323
|
-
const isolation = isolationRaw === null ?
|
|
380
|
+
const isolation = isolationRaw === null ? defaultIsolationOf(store.snapshot().settings) : asIsolation(isolationRaw);
|
|
324
381
|
const presetId = normalizePresetId(str(body, "presetId"));
|
|
325
382
|
let checklist = void 0;
|
|
326
383
|
if (body.checklist !== void 0) {
|
|
@@ -340,7 +397,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
340
397
|
blocked: false,
|
|
341
398
|
execution,
|
|
342
399
|
model,
|
|
343
|
-
|
|
400
|
+
isolation,
|
|
344
401
|
...presetId !== void 0 ? { presetId } : {},
|
|
345
402
|
...checklist !== void 0 ? { checklist } : {},
|
|
346
403
|
version: 1,
|
|
@@ -365,7 +422,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
365
422
|
}
|
|
366
423
|
return;
|
|
367
424
|
}
|
|
368
|
-
const actionMatch = pathname.match(
|
|
425
|
+
const actionMatch = pathname.match(TASK_ACTION_RE);
|
|
369
426
|
if (actionMatch !== null) {
|
|
370
427
|
const id = actionMatch[1];
|
|
371
428
|
const action = actionMatch[2];
|
|
@@ -375,44 +432,46 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
375
432
|
if (action === "update") {
|
|
376
433
|
const ifVersion = num(body, "ifVersion");
|
|
377
434
|
if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
|
|
378
|
-
|
|
379
|
-
const next = structuredClone(task);
|
|
380
|
-
const title = str(body, "title");
|
|
381
|
-
if (title !== null) next.title = normalizeTitle(title);
|
|
382
|
-
const description = str(body, "description");
|
|
383
|
-
if (description !== null) next.description = description.trim();
|
|
384
|
-
const prompt = str(body, "prompt");
|
|
385
|
-
if (prompt !== null) next.prompt = normalizePrompt(prompt);
|
|
386
|
-
const urgency = str(body, "urgency");
|
|
387
|
-
if (urgency !== null) next.urgency = asUrgency(urgency);
|
|
388
|
-
const workspaceId = str(body, "workspaceId");
|
|
389
|
-
if (workspaceId !== null) {
|
|
390
|
-
if (workspaces.get(workspaceId) === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
391
|
-
next.workspaceId = workspaceId;
|
|
392
|
-
}
|
|
393
|
-
if (typeof body.blocked === "boolean") next.blocked = body.blocked;
|
|
394
|
-
if (body.execution !== void 0) next.execution = normalizeExecution(body.execution, options.now());
|
|
395
|
-
if (body.model === null) next.model = void 0;
|
|
396
|
-
else if (body.model !== void 0) next.model = checkModel(body.model, options.modelProviders);
|
|
397
|
-
const isolationRaw = str(body, "isolation");
|
|
398
|
-
if (isolationRaw !== null) {
|
|
399
|
-
if (task.executions.length > 0 || task.status === "in_progress") throw new Error("Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改");
|
|
400
|
-
next.isolation = asIsolation(isolationRaw);
|
|
401
|
-
}
|
|
402
|
-
if (body.presetId === null) delete next.presetId;
|
|
403
|
-
else if (body.presetId !== void 0) next.presetId = normalizePresetId(str(body, "presetId"));
|
|
404
|
-
if (body.checklist === null) delete next.checklist;
|
|
405
|
-
else if (body.checklist !== void 0) {
|
|
406
|
-
const items = normalizeChecklist(body.checklist);
|
|
407
|
-
if (items.length > 0) next.checklist = items;
|
|
408
|
-
else delete next.checklist;
|
|
409
|
-
}
|
|
410
|
-
next.version = task.version + 1;
|
|
411
|
-
next.updatedAt = options.now();
|
|
412
|
-
next.updatedBy = { kind: "user" };
|
|
435
|
+
let next;
|
|
413
436
|
await store.mutate("task-updated", (ledger) => {
|
|
414
|
-
const
|
|
415
|
-
|
|
437
|
+
const { index, task } = liveTaskAt(ledger, id);
|
|
438
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
439
|
+
if (task.status === "archived") throw new Error("Error: invalid_transition: archived tasks are immutable");
|
|
440
|
+
next = structuredClone(task);
|
|
441
|
+
const title = str(body, "title");
|
|
442
|
+
if (title !== null) next.title = normalizeTitle(title);
|
|
443
|
+
const description = str(body, "description");
|
|
444
|
+
if (description !== null) next.description = description.trim();
|
|
445
|
+
const prompt = str(body, "prompt");
|
|
446
|
+
if (prompt !== null) next.prompt = normalizePrompt(prompt);
|
|
447
|
+
const urgency = str(body, "urgency");
|
|
448
|
+
if (urgency !== null) next.urgency = asUrgency(urgency);
|
|
449
|
+
const workspaceId = str(body, "workspaceId");
|
|
450
|
+
if (workspaceId !== null) {
|
|
451
|
+
if (workspaces.get(workspaceId) === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
452
|
+
next.workspaceId = workspaceId;
|
|
453
|
+
}
|
|
454
|
+
if (typeof body.blocked === "boolean") next.blocked = body.blocked;
|
|
455
|
+
if (body.execution !== void 0) next.execution = normalizeExecution(body.execution, options.now());
|
|
456
|
+
if (body.model === null) next.model = void 0;
|
|
457
|
+
else if (body.model !== void 0) next.model = checkModel(body.model, options.modelProviders);
|
|
458
|
+
const isolationRaw = str(body, "isolation");
|
|
459
|
+
if (isolationRaw !== null) {
|
|
460
|
+
if (task.executions.length > 0 || task.status === "in_progress") throw new Error("Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改");
|
|
461
|
+
next.isolation = asIsolation(isolationRaw);
|
|
462
|
+
}
|
|
463
|
+
if (body.presetId === null) delete next.presetId;
|
|
464
|
+
else if (body.presetId !== void 0) next.presetId = normalizePresetId(str(body, "presetId"));
|
|
465
|
+
if (body.checklist === null) delete next.checklist;
|
|
466
|
+
else if (body.checklist !== void 0) {
|
|
467
|
+
const items = normalizeChecklist(body.checklist);
|
|
468
|
+
if (items.length > 0) next.checklist = items;
|
|
469
|
+
else delete next.checklist;
|
|
470
|
+
}
|
|
471
|
+
next.version = task.version + 1;
|
|
472
|
+
next.updatedAt = options.now();
|
|
473
|
+
next.updatedBy = { kind: "user" };
|
|
474
|
+
ledger.tasks[index] = next;
|
|
416
475
|
return [next];
|
|
417
476
|
});
|
|
418
477
|
json(res, {
|
|
@@ -425,19 +484,20 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
425
484
|
const ifVersion = num(body, "ifVersion");
|
|
426
485
|
const status = str(body, "status") ?? "";
|
|
427
486
|
if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
|
|
428
|
-
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
429
487
|
const to = asStatus(status);
|
|
430
|
-
|
|
431
|
-
const next = structuredClone(task);
|
|
432
|
-
next.status = to;
|
|
433
|
-
next.version = task.version + 1;
|
|
434
|
-
next.updatedAt = options.now();
|
|
435
|
-
next.updatedBy = { kind: "user" };
|
|
436
|
-
if (task.status === "todo" && to === "in_progress") next.blocked = false;
|
|
437
|
-
syncClaim(next, to, options.now());
|
|
488
|
+
let next;
|
|
438
489
|
await store.mutate("task-moved", (ledger) => {
|
|
439
|
-
const
|
|
440
|
-
|
|
490
|
+
const { index, task } = liveTaskAt(ledger, id);
|
|
491
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
492
|
+
if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`);
|
|
493
|
+
next = structuredClone(task);
|
|
494
|
+
next.status = to;
|
|
495
|
+
next.version = task.version + 1;
|
|
496
|
+
next.updatedAt = options.now();
|
|
497
|
+
next.updatedBy = { kind: "user" };
|
|
498
|
+
if (task.status === "todo" && to === "in_progress") next.blocked = false;
|
|
499
|
+
syncClaim(next, to, options.now());
|
|
500
|
+
ledger.tasks[index] = next;
|
|
441
501
|
return [next];
|
|
442
502
|
});
|
|
443
503
|
json(res, {
|
|
@@ -449,24 +509,25 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
449
509
|
if (action === "reject") {
|
|
450
510
|
const ifVersion = num(body, "ifVersion");
|
|
451
511
|
if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
|
|
452
|
-
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
453
|
-
if (!canTransition(task.status, "todo")) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`);
|
|
454
|
-
const next = structuredClone(task);
|
|
455
|
-
next.status = "todo";
|
|
456
|
-
next.version = task.version + 1;
|
|
457
|
-
next.updatedAt = options.now();
|
|
458
|
-
next.updatedBy = { kind: "user" };
|
|
459
|
-
syncClaim(next, "todo", options.now());
|
|
460
512
|
const commentText = str(body, "body") ?? "";
|
|
461
|
-
|
|
462
|
-
id: newCommentId(),
|
|
463
|
-
body: normalizeBody(commentText),
|
|
464
|
-
version: 1,
|
|
465
|
-
createdAt: options.now()
|
|
466
|
-
});
|
|
513
|
+
let next;
|
|
467
514
|
await store.mutate("task-moved", (ledger) => {
|
|
468
|
-
const
|
|
469
|
-
|
|
515
|
+
const { index, task } = liveTaskAt(ledger, id);
|
|
516
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
517
|
+
if (!canTransition(task.status, "todo")) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`);
|
|
518
|
+
next = structuredClone(task);
|
|
519
|
+
next.status = "todo";
|
|
520
|
+
next.version = task.version + 1;
|
|
521
|
+
next.updatedAt = options.now();
|
|
522
|
+
next.updatedBy = { kind: "user" };
|
|
523
|
+
syncClaim(next, "todo", options.now());
|
|
524
|
+
if (commentText.trim().length > 0) next.comments.push({
|
|
525
|
+
id: newCommentId(),
|
|
526
|
+
body: normalizeBody(commentText),
|
|
527
|
+
version: 1,
|
|
528
|
+
createdAt: options.now()
|
|
529
|
+
});
|
|
530
|
+
ledger.tasks[index] = next;
|
|
470
531
|
return [next];
|
|
471
532
|
});
|
|
472
533
|
json(res, {
|
|
@@ -483,13 +544,14 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
483
544
|
version: 1,
|
|
484
545
|
createdAt: options.now()
|
|
485
546
|
};
|
|
486
|
-
const next = structuredClone(task);
|
|
487
|
-
next.comments.push(comment);
|
|
488
|
-
next.version = task.version + 1;
|
|
489
|
-
next.updatedAt = options.now();
|
|
490
547
|
await store.mutate("comment-added", (ledger) => {
|
|
491
|
-
const
|
|
492
|
-
|
|
548
|
+
const { index, task } = liveTaskAt(ledger, id);
|
|
549
|
+
if (task.status === "archived") throw new Error("Error: invalid_transition: archived tasks are immutable");
|
|
550
|
+
const next = structuredClone(task);
|
|
551
|
+
next.comments.push(comment);
|
|
552
|
+
next.version = task.version + 1;
|
|
553
|
+
next.updatedAt = options.now();
|
|
554
|
+
ledger.tasks[index] = next;
|
|
493
555
|
return [next];
|
|
494
556
|
});
|
|
495
557
|
json(res, {
|
|
@@ -505,16 +567,16 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
505
567
|
const ws = workspaces.get(task.workspaceId);
|
|
506
568
|
if (ws !== void 0) {
|
|
507
569
|
const path = worktreePathOf(ws.path, id);
|
|
570
|
+
if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
|
|
508
571
|
try {
|
|
509
|
-
await options.git.removeWorktree(ws.path, path)
|
|
510
|
-
} catch (error) {
|
|
511
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
512
|
-
if (message.includes("未提交修改")) throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`);
|
|
513
|
-
if (/not a working tree|not a working-tree/i.test(message)) await rm(path, {
|
|
572
|
+
if (await options.git.removeWorktree(ws.path, path) === "unregistered") await rm(path, {
|
|
514
573
|
recursive: true,
|
|
515
574
|
force: true
|
|
516
575
|
});
|
|
517
|
-
|
|
576
|
+
} catch (error) {
|
|
577
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
578
|
+
if (error.code === "dirty-worktree" || message.includes("未提交修改")) throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`);
|
|
579
|
+
throw new Error(`Error: invalid_input: ${message}`);
|
|
518
580
|
}
|
|
519
581
|
if (task.branch !== void 0) try {
|
|
520
582
|
await options.git.deleteBranch(ws.path, task.branch);
|
|
@@ -533,13 +595,17 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
533
595
|
}
|
|
534
596
|
const ifVersion = num(body, "ifVersion");
|
|
535
597
|
if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
|
|
536
|
-
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
537
|
-
const next = structuredClone(task);
|
|
538
|
-
next.trashedAt = options.now();
|
|
539
|
-
next.version = task.version + 1;
|
|
540
598
|
await store.mutate("task-deleted", (ledger) => {
|
|
541
|
-
const
|
|
542
|
-
|
|
599
|
+
const { index, task } = liveTaskAt(ledger, id);
|
|
600
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
|
|
601
|
+
if (task.executions.some((e) => e.outcome === "running")) throw new Error("Error: invalid_input: 任务有正在运行的执行,请先取消或等它结束再删除");
|
|
602
|
+
const next = structuredClone(task);
|
|
603
|
+
next.trashedAt = options.now();
|
|
604
|
+
next.version = task.version + 1;
|
|
605
|
+
delete next.claimedBy;
|
|
606
|
+
delete next.claimedAt;
|
|
607
|
+
next.blocked = false;
|
|
608
|
+
ledger.tasks[index] = next;
|
|
543
609
|
return [next];
|
|
544
610
|
});
|
|
545
611
|
json(res, {
|
|
@@ -620,13 +686,13 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
620
686
|
version: 1,
|
|
621
687
|
createdAt: options.now()
|
|
622
688
|
};
|
|
623
|
-
const next = structuredClone(task);
|
|
624
|
-
next.comments.push(mergedComment);
|
|
625
|
-
next.version = task.version + 1;
|
|
626
|
-
next.updatedAt = options.now();
|
|
627
689
|
await store.mutate("comment-added", (ledger) => {
|
|
628
|
-
const
|
|
629
|
-
|
|
690
|
+
const { index, task: fresh } = liveTaskAt(ledger, id);
|
|
691
|
+
const next = structuredClone(fresh);
|
|
692
|
+
next.comments.push(mergedComment);
|
|
693
|
+
next.version = fresh.version + 1;
|
|
694
|
+
next.updatedAt = options.now();
|
|
695
|
+
ledger.tasks[index] = next;
|
|
630
696
|
return [next];
|
|
631
697
|
});
|
|
632
698
|
json(res, {
|
|
@@ -647,8 +713,12 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
647
713
|
const ws = workspaces.get(task.workspaceId);
|
|
648
714
|
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
649
715
|
const path = worktreePathOf(ws.path, id);
|
|
716
|
+
if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
|
|
650
717
|
try {
|
|
651
|
-
await options.git.removeWorktree(ws.path, path)
|
|
718
|
+
if (await options.git.removeWorktree(ws.path, path) === "unregistered") await rm(path, {
|
|
719
|
+
recursive: true,
|
|
720
|
+
force: true
|
|
721
|
+
});
|
|
652
722
|
} catch (error) {
|
|
653
723
|
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
|
|
654
724
|
}
|
|
@@ -690,15 +760,14 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
690
760
|
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
691
761
|
if (store.get(taskId) !== void 0) throw new Error("Error: invalid_input: 任务仍在看板中,请从任务详情页删除其 worktree");
|
|
692
762
|
const path = worktreePathOf(ws.path, taskId);
|
|
763
|
+
if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
|
|
693
764
|
try {
|
|
694
|
-
await options.git.removeWorktree(ws.path, path)
|
|
695
|
-
} catch (error) {
|
|
696
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
697
|
-
if (/not a working tree|not a working-tree/i.test(message)) await rm(path, {
|
|
765
|
+
if (await options.git.removeWorktree(ws.path, path) === "unregistered") await rm(path, {
|
|
698
766
|
recursive: true,
|
|
699
767
|
force: true
|
|
700
768
|
});
|
|
701
|
-
|
|
769
|
+
} catch (error) {
|
|
770
|
+
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
|
|
702
771
|
}
|
|
703
772
|
json(res, {
|
|
704
773
|
ok: true,
|
|
@@ -715,7 +784,8 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
715
784
|
}
|
|
716
785
|
if (pathname === `/dsh-taskboard/import/preview`) {
|
|
717
786
|
try {
|
|
718
|
-
const
|
|
787
|
+
const known = new Set(store.snapshot().tasks.map((t) => t.id));
|
|
788
|
+
const plan = validateLedgerImport(body, known, options.now());
|
|
719
789
|
json(res, {
|
|
720
790
|
ok: true,
|
|
721
791
|
value: { plan: {
|
|
@@ -748,14 +818,21 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
748
818
|
let backupFile;
|
|
749
819
|
if (mode === "replace" && store.snapshot().tasks.length > 0) backupFile = await store.backup();
|
|
750
820
|
let replacedTotal;
|
|
751
|
-
await store.mutate("
|
|
821
|
+
await store.mutate("ledger-replaced", (ledger) => {
|
|
752
822
|
if (mode === "replace") {
|
|
823
|
+
if (ledger.tasks.some((t) => t.executions.some((e) => e.outcome === "running"))) throw new Error("Error: invalid_input: 有任务正在执行,不能整册替换(请先取消或等待结束)");
|
|
753
824
|
replacedTotal = ledger.tasks.length;
|
|
754
825
|
ledger.tasks = structuredClone(imported);
|
|
826
|
+
if (plan.settings !== void 0) ledger.settings = structuredClone(plan.settings);
|
|
827
|
+
else delete ledger.settings;
|
|
755
828
|
return ledger.tasks;
|
|
756
829
|
}
|
|
757
830
|
const byId = new Map(ledger.tasks.map((t) => [t.id, t]));
|
|
758
|
-
for (const task of imported)
|
|
831
|
+
for (const task of imported) {
|
|
832
|
+
const existing = byId.get(task.id);
|
|
833
|
+
if (existing !== void 0 && existing.executions.some((e) => e.outcome === "running")) throw new Error(`Error: invalid_input: 任务 ${task.id} 正在执行,不能被导入覆盖`);
|
|
834
|
+
byId.set(task.id, structuredClone(task));
|
|
835
|
+
}
|
|
759
836
|
ledger.tasks = [...byId.values()];
|
|
760
837
|
return structuredClone(imported);
|
|
761
838
|
});
|
|
@@ -797,7 +874,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
797
874
|
value: await options.templates.upsert({
|
|
798
875
|
id: str(body, "id") ?? void 0,
|
|
799
876
|
name,
|
|
800
|
-
task: normalizeTemplateSpec(body.task)
|
|
877
|
+
task: normalizeTemplateSpec(body.task, options.now())
|
|
801
878
|
})
|
|
802
879
|
}, 201);
|
|
803
880
|
} catch (error) {
|
|
@@ -806,6 +883,23 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
806
883
|
}
|
|
807
884
|
return;
|
|
808
885
|
}
|
|
886
|
+
if (pathname === `/dsh-taskboard/settings/update`) {
|
|
887
|
+
try {
|
|
888
|
+
const next = asBoardSettings(body);
|
|
889
|
+
await store.mutate("settings-updated", (ledger) => {
|
|
890
|
+
ledger.settings = next;
|
|
891
|
+
return [];
|
|
892
|
+
});
|
|
893
|
+
json(res, {
|
|
894
|
+
ok: true,
|
|
895
|
+
value: next
|
|
896
|
+
});
|
|
897
|
+
} catch (error) {
|
|
898
|
+
const f = toFail(error);
|
|
899
|
+
json(res, f.res, f.status);
|
|
900
|
+
}
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
809
903
|
res.writeHead(404);
|
|
810
904
|
res.end();
|
|
811
905
|
} catch (error) {
|
|
@@ -822,6 +916,9 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
822
916
|
res.write("retry: 2000\n\n");
|
|
823
917
|
res.write(`event: hello\ndata: ${JSON.stringify({ revision: store.snapshot().revision })}\n\n`);
|
|
824
918
|
subscribers.add(res);
|
|
919
|
+
res.on("error", () => {
|
|
920
|
+
subscribers.delete(res);
|
|
921
|
+
});
|
|
825
922
|
if (heartbeat === void 0) heartbeat = setInterval(() => {
|
|
826
923
|
for (const current of subscribers) current.write(": ping\n\n");
|
|
827
924
|
}, HEARTBEAT_MS);
|
|
@@ -843,6 +940,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
843
940
|
handler: sse
|
|
844
941
|
})];
|
|
845
942
|
return () => {
|
|
943
|
+
unsubscribeBroadcast();
|
|
846
944
|
for (const dispose of disposers) dispose();
|
|
847
945
|
if (heartbeat !== void 0) clearInterval(heartbeat);
|
|
848
946
|
for (const res of subscribers) res.end();
|