dsh-taskboard 0.2.2 → 0.4.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 +62 -6
- package/lib/client.js +1839 -74
- package/lib/host/execution.js +199 -54
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +327 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/protocol-text.js +5 -3
- package/lib/host/protocol-text.js.map +1 -1
- package/lib/host/routes.js +435 -5
- package/lib/host/routes.js.map +1 -1
- package/lib/host/store.js +12 -0
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +166 -0
- package/lib/host/templates.js.map +1 -0
- package/lib/host/tools.js +217 -3
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +23 -3
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +286 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +74 -74
- package/src/client/api.ts +47 -3
- package/src/client/board/ImportModal.tsx +182 -0
- package/src/client/board/TaskBoard.tsx +118 -3
- package/src/client/board/TaskCard.tsx +9 -0
- package/src/client/board/TaskDetail.tsx +360 -4
- package/src/client/board/TaskFormModal.tsx +193 -12
- package/src/client/board/TemplateManager.tsx +121 -0
- package/src/client/controller.ts +238 -11
- package/src/client/index.ts +18 -1
- package/src/client/styles.ts +198 -0
- package/src/host/execution.ts +301 -67
- package/src/host/git.ts +370 -0
- package/src/host/protocol-text.ts +5 -3
- package/src/host/routes.ts +483 -5
- package/src/host/store.ts +13 -0
- package/src/host/templates.ts +143 -0
- package/src/host/tools.ts +215 -2
- package/src/index.ts +30 -1
- package/src/shared/api.ts +89 -3
- package/src/shared/protocol.ts +408 -0
- package/src/shared/version.ts +1 -1
package/lib/host/routes.js
CHANGED
|
@@ -1,8 +1,45 @@
|
|
|
1
|
-
import { asStatus, asUrgency, canTransition, newCommentId, newTaskId, normalizeBody, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim } from "../shared/protocol.js";
|
|
1
|
+
import { asIsolation, asStatus, asUrgency, canTransition, checklistFromTexts, newCommentId, newTaskId, normalizeBody, normalizeChecklist, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim, validateLedgerImport } from "../shared/protocol.js";
|
|
2
|
+
import { WORKTREE_DIR, worktreePathOf } from "./git.js";
|
|
2
3
|
import { ROUTE_PREFIX, SSE_PATH } from "../shared/api.js";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readdir, rm } from "node:fs/promises";
|
|
3
6
|
//#region src/host/routes.ts
|
|
4
7
|
/** Heartbeat cadence for the SSE stream. */
|
|
5
8
|
const HEARTBEAT_MS = 2e4;
|
|
9
|
+
/** How long a workspace git-detection result stays cached (fail-soft). */
|
|
10
|
+
const GIT_DETECT_TTL_MS = 6e4;
|
|
11
|
+
/** Validate a template's task spec (routes-side, unknown → invalid_input). */
|
|
12
|
+
function normalizeTemplateSpec(raw) {
|
|
13
|
+
if (typeof raw !== "object" || raw === null) throw new Error("Error: invalid_input: task must be an object");
|
|
14
|
+
const e = raw;
|
|
15
|
+
const spec = {};
|
|
16
|
+
const str = (key) => {
|
|
17
|
+
const v = e[key];
|
|
18
|
+
if (v === void 0) return void 0;
|
|
19
|
+
if (typeof v !== "string") throw new Error(`Error: invalid_input: task.${key} must be a string`);
|
|
20
|
+
return v;
|
|
21
|
+
};
|
|
22
|
+
const title = str("title");
|
|
23
|
+
const description = str("description");
|
|
24
|
+
const prompt = str("prompt");
|
|
25
|
+
const urgency = str("urgency");
|
|
26
|
+
const isolation = str("isolation");
|
|
27
|
+
const presetId = str("presetId");
|
|
28
|
+
if (title !== void 0) spec.title = normalizeTitle(title);
|
|
29
|
+
if (description !== void 0) spec.description = description;
|
|
30
|
+
if (prompt !== void 0) spec.prompt = normalizePrompt(prompt);
|
|
31
|
+
if (urgency !== void 0) spec.urgency = asUrgency(urgency);
|
|
32
|
+
if (isolation !== void 0) spec.isolation = asIsolation(isolation);
|
|
33
|
+
if (presetId !== void 0 && presetId.trim().length > 0) spec.presetId = presetId.trim();
|
|
34
|
+
if (e.execution !== void 0) spec.execution = normalizeExecution(e.execution, Date.now());
|
|
35
|
+
if (e.model !== void 0) spec.model = normalizeModel(e.model);
|
|
36
|
+
if (e.checklist !== void 0) {
|
|
37
|
+
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");
|
|
38
|
+
checklistFromTexts(e.checklist);
|
|
39
|
+
spec.checklist = e.checklist;
|
|
40
|
+
}
|
|
41
|
+
return spec;
|
|
42
|
+
}
|
|
6
43
|
/** Validate a pinned model: structural check always, provider route when known. */
|
|
7
44
|
function checkModel(raw, modelProviders) {
|
|
8
45
|
const model = normalizeModel(raw);
|
|
@@ -55,6 +92,11 @@ function num(body, key) {
|
|
|
55
92
|
if (v === void 0) return void 0;
|
|
56
93
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
57
94
|
}
|
|
95
|
+
/** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
|
|
96
|
+
function normalizePresetId(raw) {
|
|
97
|
+
const t = (raw ?? "").trim();
|
|
98
|
+
return t.length === 0 ? void 0 : t;
|
|
99
|
+
}
|
|
58
100
|
/** Map a thrown domain error to the envelope. */
|
|
59
101
|
function toFail(error) {
|
|
60
102
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -89,9 +131,72 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
89
131
|
for (const res of subscribers) res.write(frame);
|
|
90
132
|
};
|
|
91
133
|
store.subscribe(broadcast);
|
|
134
|
+
const gitCache = /* @__PURE__ */ new Map();
|
|
135
|
+
const gitHinted = /* @__PURE__ */ new Set();
|
|
136
|
+
/** Whether <root>/.gitignore (missing file counts as missing) ignores our worktree dir. */
|
|
137
|
+
const gitignoreMissing = async (path) => {
|
|
138
|
+
try {
|
|
139
|
+
const { readFile } = await import("node:fs/promises");
|
|
140
|
+
return !(await readFile(join(path, ".gitignore"), "utf8")).split("\n").some((l) => {
|
|
141
|
+
const t = l.trim().replace(/\/+$/, "");
|
|
142
|
+
return t === ".dsh-worktrees" || t === `/.dsh-worktrees`;
|
|
143
|
+
});
|
|
144
|
+
} catch {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const gitAvailable = async (path) => {
|
|
149
|
+
if (options.git === void 0) return false;
|
|
150
|
+
const hit = gitCache.get(path);
|
|
151
|
+
if (hit !== void 0 && options.now() - hit.at < GIT_DETECT_TTL_MS) return hit.value;
|
|
152
|
+
let value = false;
|
|
153
|
+
try {
|
|
154
|
+
value = await options.git.detect(path);
|
|
155
|
+
} catch {}
|
|
156
|
+
gitCache.set(path, {
|
|
157
|
+
value,
|
|
158
|
+
at: options.now()
|
|
159
|
+
});
|
|
160
|
+
if (value && !gitHinted.has(path)) {
|
|
161
|
+
gitHinted.add(path);
|
|
162
|
+
if (await gitignoreMissing(path)) console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`);
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
};
|
|
166
|
+
/** List orphan worktree dirs: entries under <ws>/.dsh-worktrees owned by no ledger task. */
|
|
167
|
+
const listOrphanWorktrees = async () => {
|
|
168
|
+
const orphans = [];
|
|
169
|
+
const known = new Set(store.snapshot().tasks.map((t) => t.id));
|
|
170
|
+
for (const ws of workspaces.list()) {
|
|
171
|
+
let entries = [];
|
|
172
|
+
try {
|
|
173
|
+
entries = (await readdir(join(ws.path, WORKTREE_DIR), { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
174
|
+
} catch {}
|
|
175
|
+
for (const taskId of entries) if (!known.has(taskId)) orphans.push({
|
|
176
|
+
workspaceId: ws.id,
|
|
177
|
+
workspacePath: ws.path,
|
|
178
|
+
taskId,
|
|
179
|
+
path: worktreePathOf(ws.path, taskId)
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return orphans;
|
|
183
|
+
};
|
|
184
|
+
/** Git-enabled workspaces whose .gitignore does not cover the worktree dir. */
|
|
185
|
+
const listGitignoreSuggestions = async () => {
|
|
186
|
+
const suggestions = [];
|
|
187
|
+
for (const ws of workspaces.list()) {
|
|
188
|
+
if (!await gitAvailable(ws.path)) continue;
|
|
189
|
+
if (await gitignoreMissing(ws.path)) suggestions.push({
|
|
190
|
+
workspaceId: ws.id,
|
|
191
|
+
workspacePath: ws.path
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return suggestions;
|
|
195
|
+
};
|
|
92
196
|
const handler = async (req, res) => {
|
|
93
197
|
try {
|
|
94
|
-
const
|
|
198
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
199
|
+
const pathname = url.pathname;
|
|
95
200
|
if (req.method === "GET") {
|
|
96
201
|
if (pathname === `/dsh-taskboard/state`) {
|
|
97
202
|
await store.load();
|
|
@@ -102,9 +207,74 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
102
207
|
return;
|
|
103
208
|
}
|
|
104
209
|
if (pathname === `/dsh-taskboard/workspaces`) {
|
|
210
|
+
const list = workspaces.list();
|
|
211
|
+
const flags = await Promise.all(list.map((ws) => gitAvailable(ws.path)));
|
|
105
212
|
json(res, {
|
|
106
213
|
ok: true,
|
|
107
|
-
value:
|
|
214
|
+
value: list.map((ws, i) => ({
|
|
215
|
+
...ws,
|
|
216
|
+
sessionCount: 0,
|
|
217
|
+
gitAvailable: flags[i]
|
|
218
|
+
}))
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (pathname === `/dsh-taskboard/diagnostics`) {
|
|
223
|
+
const ledger = store.snapshot();
|
|
224
|
+
let staleRunning = 0;
|
|
225
|
+
for (const t of ledger.tasks) for (const e of t.executions) if (e.outcome === "running") staleRunning += 1;
|
|
226
|
+
json(res, {
|
|
227
|
+
ok: true,
|
|
228
|
+
value: {
|
|
229
|
+
revision: ledger.revision,
|
|
230
|
+
tasks: ledger.tasks.length,
|
|
231
|
+
staleRunning,
|
|
232
|
+
orphanWorktrees: await listOrphanWorktrees(),
|
|
233
|
+
gitIgnoreSuggestions: await listGitignoreSuggestions()
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const diffMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`));
|
|
239
|
+
if (diffMatch !== null) {
|
|
240
|
+
try {
|
|
241
|
+
if (options.git === void 0) {
|
|
242
|
+
json(res, fail("invalid_input", "git integration unavailable").res, 501);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const task = store.get(diffMatch[1]);
|
|
246
|
+
if (task === void 0) throw new Error("Error: not_found: no such task");
|
|
247
|
+
const execution = task.executions.find((e) => e.id === url.searchParams.get("execution"));
|
|
248
|
+
if (execution === void 0) throw new Error("Error: not_found: no such execution");
|
|
249
|
+
const commit = url.searchParams.get("commit");
|
|
250
|
+
const filePath = url.searchParams.get("path");
|
|
251
|
+
const ws = workspaces.get(task.workspaceId);
|
|
252
|
+
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
253
|
+
const cwd = execution.worktreePath ?? ws.path;
|
|
254
|
+
let result = commit !== null ? await options.git.showCommit(cwd, commit) : filePath !== null ? await options.git.showPathDiff(cwd, filePath, execution.baseCommit) : void 0;
|
|
255
|
+
if (result === void 0 && execution.worktreePath !== void 0 && cwd !== ws.path) result = commit !== null ? await options.git.showCommit(ws.path, commit) : filePath !== null && execution.baseCommit !== void 0 ? await options.git.showPathDiff(ws.path, filePath, execution.baseCommit) : void 0;
|
|
256
|
+
if (result === void 0) throw new Error("Error: invalid_input: 无法获取 diff(git 报错、对象不存在,或仅存于已删除的 worktree 且无基线)");
|
|
257
|
+
json(res, {
|
|
258
|
+
ok: true,
|
|
259
|
+
value: {
|
|
260
|
+
diff: result.text,
|
|
261
|
+
truncated: result.truncated
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
} catch (error) {
|
|
265
|
+
const f = toFail(error);
|
|
266
|
+
json(res, f.res, f.status);
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (pathname === `/dsh-taskboard/templates`) {
|
|
271
|
+
if (options.templates === void 0) {
|
|
272
|
+
json(res, fail("invalid_input", "template store unavailable").res, 501);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
json(res, {
|
|
276
|
+
ok: true,
|
|
277
|
+
value: { templates: await options.templates.list() }
|
|
108
278
|
});
|
|
109
279
|
return;
|
|
110
280
|
}
|
|
@@ -149,6 +319,15 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
149
319
|
const status = str(body, "status") === null ? "todo" : asStatus(str(body, "status"));
|
|
150
320
|
const execution = normalizeExecution(body.execution ?? {}, options.now());
|
|
151
321
|
const model = body.model === void 0 ? void 0 : checkModel(body.model, options.modelProviders);
|
|
322
|
+
const isolationRaw = str(body, "isolation");
|
|
323
|
+
const isolation = isolationRaw === null ? void 0 : asIsolation(isolationRaw);
|
|
324
|
+
const presetId = normalizePresetId(str(body, "presetId"));
|
|
325
|
+
let checklist = void 0;
|
|
326
|
+
if (body.checklist !== void 0) {
|
|
327
|
+
if (!Array.isArray(body.checklist) || body.checklist.some((c) => typeof c !== "string")) throw new Error("Error: invalid_input: checklist must be an array of strings");
|
|
328
|
+
const texts = body.checklist.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
329
|
+
if (texts.length > 0) checklist = checklistFromTexts(texts);
|
|
330
|
+
}
|
|
152
331
|
const now = options.now();
|
|
153
332
|
const task = {
|
|
154
333
|
id: newTaskId(),
|
|
@@ -161,6 +340,9 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
161
340
|
blocked: false,
|
|
162
341
|
execution,
|
|
163
342
|
model,
|
|
343
|
+
...isolation !== void 0 ? { isolation } : {},
|
|
344
|
+
...presetId !== void 0 ? { presetId } : {},
|
|
345
|
+
...checklist !== void 0 ? { checklist } : {},
|
|
164
346
|
version: 1,
|
|
165
347
|
createdAt: now,
|
|
166
348
|
updatedAt: now,
|
|
@@ -183,7 +365,7 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
183
365
|
}
|
|
184
366
|
return;
|
|
185
367
|
}
|
|
186
|
-
const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/(\\w+)$`));
|
|
368
|
+
const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`));
|
|
187
369
|
if (actionMatch !== null) {
|
|
188
370
|
const id = actionMatch[1];
|
|
189
371
|
const action = actionMatch[2];
|
|
@@ -212,6 +394,19 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
212
394
|
if (body.execution !== void 0) next.execution = normalizeExecution(body.execution, options.now());
|
|
213
395
|
if (body.model === null) next.model = void 0;
|
|
214
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
|
+
}
|
|
215
410
|
next.version = task.version + 1;
|
|
216
411
|
next.updatedAt = options.now();
|
|
217
412
|
next.updatedBy = { kind: "user" };
|
|
@@ -306,6 +501,26 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
306
501
|
if (action === "delete") {
|
|
307
502
|
if (body.purge === true) {
|
|
308
503
|
if (task.trashedAt === void 0) throw new Error("Error: invalid_input: purge requires a trashed task (soft-delete first)");
|
|
504
|
+
if (options.git !== void 0) {
|
|
505
|
+
const ws = workspaces.get(task.workspaceId);
|
|
506
|
+
if (ws !== void 0) {
|
|
507
|
+
const path = worktreePathOf(ws.path, id);
|
|
508
|
+
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, {
|
|
514
|
+
recursive: true,
|
|
515
|
+
force: true
|
|
516
|
+
});
|
|
517
|
+
else throw new Error(`Error: invalid_input: ${message}`);
|
|
518
|
+
}
|
|
519
|
+
if (task.branch !== void 0) try {
|
|
520
|
+
await options.git.deleteBranch(ws.path, task.branch);
|
|
521
|
+
} catch {}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
309
524
|
await store.mutate("task-deleted", (ledger) => {
|
|
310
525
|
ledger.tasks = ledger.tasks.filter((t) => t.id !== id);
|
|
311
526
|
return [];
|
|
@@ -338,7 +553,8 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
338
553
|
json(res, fail("invalid_input", "execution service unavailable").res, 501);
|
|
339
554
|
return;
|
|
340
555
|
}
|
|
341
|
-
const
|
|
556
|
+
const runOptions = body.reuse === true ? { reuseWorktree: true } : void 0;
|
|
557
|
+
const result = await options.run(id, runOptions);
|
|
342
558
|
if (result.ok) json(res, {
|
|
343
559
|
ok: true,
|
|
344
560
|
value: result
|
|
@@ -368,6 +584,92 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
368
584
|
}
|
|
369
585
|
return;
|
|
370
586
|
}
|
|
587
|
+
if (action === "merge") {
|
|
588
|
+
if (options.git === void 0) {
|
|
589
|
+
json(res, fail("invalid_input", "git integration unavailable").res, 501);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (task.branch === void 0) throw new Error("Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)");
|
|
593
|
+
if (task.status === "in_progress") throw new Error("Error: invalid_input: 任务执行中,不能合并");
|
|
594
|
+
if (task.executions.some((e) => e.outcome === "running")) throw new Error("Error: invalid_input: 任务执行中,不能合并");
|
|
595
|
+
const ws = workspaces.get(task.workspaceId);
|
|
596
|
+
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
597
|
+
let noop = false;
|
|
598
|
+
try {
|
|
599
|
+
noop = await options.git.isAncestor(ws.path, task.branch);
|
|
600
|
+
} catch {}
|
|
601
|
+
if (noop) {
|
|
602
|
+
json(res, {
|
|
603
|
+
ok: true,
|
|
604
|
+
value: {
|
|
605
|
+
merged: false,
|
|
606
|
+
noop: true,
|
|
607
|
+
branch: task.branch
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
await options.git.merge(ws.path, task.branch);
|
|
614
|
+
} catch (error) {
|
|
615
|
+
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
|
|
616
|
+
}
|
|
617
|
+
const mergedComment = {
|
|
618
|
+
id: newCommentId(),
|
|
619
|
+
body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`),
|
|
620
|
+
version: 1,
|
|
621
|
+
createdAt: options.now()
|
|
622
|
+
};
|
|
623
|
+
const next = structuredClone(task);
|
|
624
|
+
next.comments.push(mergedComment);
|
|
625
|
+
next.version = task.version + 1;
|
|
626
|
+
next.updatedAt = options.now();
|
|
627
|
+
await store.mutate("comment-added", (ledger) => {
|
|
628
|
+
const i = ledger.tasks.findIndex((t) => t.id === id);
|
|
629
|
+
ledger.tasks[i] = next;
|
|
630
|
+
return [next];
|
|
631
|
+
});
|
|
632
|
+
json(res, {
|
|
633
|
+
ok: true,
|
|
634
|
+
value: {
|
|
635
|
+
merged: true,
|
|
636
|
+
branch: task.branch
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (action === "worktree-remove") {
|
|
642
|
+
if (options.git === void 0) {
|
|
643
|
+
json(res, fail("invalid_input", "git integration unavailable").res, 501);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (task.executions.some((e) => e.outcome === "running")) throw new Error("Error: invalid_input: 任务执行中,不能删除 worktree");
|
|
647
|
+
const ws = workspaces.get(task.workspaceId);
|
|
648
|
+
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
649
|
+
const path = worktreePathOf(ws.path, id);
|
|
650
|
+
try {
|
|
651
|
+
await options.git.removeWorktree(ws.path, path);
|
|
652
|
+
} catch (error) {
|
|
653
|
+
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
|
|
654
|
+
}
|
|
655
|
+
let branchDeleted = false;
|
|
656
|
+
let branchError;
|
|
657
|
+
if (body.deleteBranch === true && task.branch !== void 0) try {
|
|
658
|
+
await options.git.deleteBranch(ws.path, task.branch);
|
|
659
|
+
branchDeleted = true;
|
|
660
|
+
} catch (error) {
|
|
661
|
+
branchError = error instanceof Error ? error.message : String(error);
|
|
662
|
+
}
|
|
663
|
+
json(res, {
|
|
664
|
+
ok: true,
|
|
665
|
+
value: {
|
|
666
|
+
removed: true,
|
|
667
|
+
branchDeleted,
|
|
668
|
+
...branchError !== void 0 ? { branchError } : {}
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
371
673
|
const f = fail("not_found", `unknown action ${action}`);
|
|
372
674
|
json(res, f.res, f.status);
|
|
373
675
|
} catch (error) {
|
|
@@ -376,6 +678,134 @@ function registerTaskboardRoutes(ctx, options) {
|
|
|
376
678
|
}
|
|
377
679
|
return;
|
|
378
680
|
}
|
|
681
|
+
if (pathname === `/dsh-taskboard/worktree-cleanup`) {
|
|
682
|
+
try {
|
|
683
|
+
if (options.git === void 0) {
|
|
684
|
+
json(res, fail("invalid_input", "git integration unavailable").res, 501);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const workspaceId = str(body, "workspaceId") ?? "";
|
|
688
|
+
const taskId = str(body, "taskId") ?? "";
|
|
689
|
+
const ws = workspaces.get(workspaceId);
|
|
690
|
+
if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
|
|
691
|
+
if (store.get(taskId) !== void 0) throw new Error("Error: invalid_input: 任务仍在看板中,请从任务详情页删除其 worktree");
|
|
692
|
+
const path = worktreePathOf(ws.path, taskId);
|
|
693
|
+
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, {
|
|
698
|
+
recursive: true,
|
|
699
|
+
force: true
|
|
700
|
+
});
|
|
701
|
+
else throw new Error(`Error: invalid_input: ${message}`);
|
|
702
|
+
}
|
|
703
|
+
json(res, {
|
|
704
|
+
ok: true,
|
|
705
|
+
value: {
|
|
706
|
+
cleaned: true,
|
|
707
|
+
path
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
} catch (error) {
|
|
711
|
+
const f = toFail(error);
|
|
712
|
+
json(res, f.res, f.status);
|
|
713
|
+
}
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
if (pathname === `/dsh-taskboard/import/preview`) {
|
|
717
|
+
try {
|
|
718
|
+
const plan = validateLedgerImport(body, new Set(store.snapshot().tasks.map((t) => t.id)), options.now());
|
|
719
|
+
json(res, {
|
|
720
|
+
ok: true,
|
|
721
|
+
value: { plan: {
|
|
722
|
+
create: plan.create.map((t) => ({
|
|
723
|
+
id: t.id,
|
|
724
|
+
title: t.title,
|
|
725
|
+
status: t.status
|
|
726
|
+
})),
|
|
727
|
+
overwrite: plan.overwrite.map((t) => ({
|
|
728
|
+
id: t.id,
|
|
729
|
+
title: t.title,
|
|
730
|
+
status: t.status
|
|
731
|
+
})),
|
|
732
|
+
invalid: plan.invalid
|
|
733
|
+
} }
|
|
734
|
+
});
|
|
735
|
+
} catch (error) {
|
|
736
|
+
const f = toFail(error);
|
|
737
|
+
json(res, f.res, f.status);
|
|
738
|
+
}
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (pathname === `/dsh-taskboard/import`) {
|
|
742
|
+
try {
|
|
743
|
+
const mode = str(body, "mode") === "replace" ? "replace" : "merge";
|
|
744
|
+
const raw = body.ledger;
|
|
745
|
+
const plan = validateLedgerImport(raw, new Set(store.snapshot().tasks.map((t) => t.id)), options.now());
|
|
746
|
+
const imported = [...plan.create, ...plan.overwrite];
|
|
747
|
+
if (mode === "replace" && imported.length === 0) throw new Error("Error: invalid_input: 导入文件没有可导入的任务,已拒绝整册替换");
|
|
748
|
+
let backupFile;
|
|
749
|
+
if (mode === "replace" && store.snapshot().tasks.length > 0) backupFile = await store.backup();
|
|
750
|
+
let replacedTotal;
|
|
751
|
+
await store.mutate("task-created", (ledger) => {
|
|
752
|
+
if (mode === "replace") {
|
|
753
|
+
replacedTotal = ledger.tasks.length;
|
|
754
|
+
ledger.tasks = structuredClone(imported);
|
|
755
|
+
return ledger.tasks;
|
|
756
|
+
}
|
|
757
|
+
const byId = new Map(ledger.tasks.map((t) => [t.id, t]));
|
|
758
|
+
for (const task of imported) byId.set(task.id, structuredClone(task));
|
|
759
|
+
ledger.tasks = [...byId.values()];
|
|
760
|
+
return structuredClone(imported);
|
|
761
|
+
});
|
|
762
|
+
json(res, {
|
|
763
|
+
ok: true,
|
|
764
|
+
value: {
|
|
765
|
+
mode,
|
|
766
|
+
created: plan.create.length,
|
|
767
|
+
overwritten: plan.overwrite.length,
|
|
768
|
+
...mode === "replace" ? { replacedTotal } : {},
|
|
769
|
+
...backupFile !== void 0 ? { backupFile } : {}
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
} catch (error) {
|
|
773
|
+
const f = toFail(error);
|
|
774
|
+
json(res, f.res, f.status);
|
|
775
|
+
}
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (pathname === `/dsh-taskboard/templates` || pathname === `/dsh-taskboard/templates/delete`) {
|
|
779
|
+
try {
|
|
780
|
+
if (options.templates === void 0) {
|
|
781
|
+
json(res, fail("invalid_input", "template store unavailable").res, 501);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (pathname.endsWith("/delete")) {
|
|
785
|
+
const id = str(body, "id") ?? "";
|
|
786
|
+
if (id.length === 0) throw new Error("Error: invalid_input: id required");
|
|
787
|
+
json(res, {
|
|
788
|
+
ok: true,
|
|
789
|
+
value: { deleted: await options.templates.remove(id) }
|
|
790
|
+
});
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
const name = str(body, "name") ?? "";
|
|
794
|
+
if (name.trim().length === 0) throw new Error("Error: invalid_input: name required");
|
|
795
|
+
json(res, {
|
|
796
|
+
ok: true,
|
|
797
|
+
value: await options.templates.upsert({
|
|
798
|
+
id: str(body, "id") ?? void 0,
|
|
799
|
+
name,
|
|
800
|
+
task: normalizeTemplateSpec(body.task)
|
|
801
|
+
})
|
|
802
|
+
}, 201);
|
|
803
|
+
} catch (error) {
|
|
804
|
+
const f = toFail(error);
|
|
805
|
+
json(res, f.res, f.status);
|
|
806
|
+
}
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
379
809
|
res.writeHead(404);
|
|
380
810
|
res.end();
|
|
381
811
|
} catch (error) {
|