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/src/host/routes.ts
CHANGED
|
@@ -11,16 +11,18 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
13
13
|
import { readdir, rm } from 'node:fs/promises'
|
|
14
|
-
import { join } from 'node:path'
|
|
14
|
+
import { join, resolve, sep } from 'node:path'
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
16
|
// Type-only: pulls the webServer Context merge (ctx.webServer).
|
|
17
17
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
18
18
|
import {
|
|
19
|
+
asBoardSettings,
|
|
19
20
|
asIsolation,
|
|
20
21
|
asStatus,
|
|
21
22
|
asUrgency,
|
|
22
23
|
canTransition,
|
|
23
24
|
checklistFromTexts,
|
|
25
|
+
defaultIsolationOf,
|
|
24
26
|
newCommentId,
|
|
25
27
|
newTaskId,
|
|
26
28
|
normalizeBody,
|
|
@@ -32,6 +34,7 @@ import {
|
|
|
32
34
|
summarize,
|
|
33
35
|
syncClaim,
|
|
34
36
|
validateLedgerImport,
|
|
37
|
+
type TaskLedger,
|
|
35
38
|
type TaskModel,
|
|
36
39
|
type TaskRecord,
|
|
37
40
|
} from '../shared/protocol.ts'
|
|
@@ -40,11 +43,20 @@ import type { TaskTemplate } from '../shared/api.ts'
|
|
|
40
43
|
import type { TemplateStore } from './templates.ts'
|
|
41
44
|
import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
|
|
42
45
|
import type { TaskStore } from './store.ts'
|
|
46
|
+
import { ERR, ToolError } from './tools.ts'
|
|
43
47
|
import type { WorkspaceFace } from './tools.ts'
|
|
44
48
|
|
|
45
49
|
/** Heartbeat cadence for the SSE stream. */
|
|
46
50
|
const HEARTBEAT_MS = 20_000
|
|
47
51
|
|
|
52
|
+
/** Max accepted JSON body bytes (S8: unbounded buffering is a local OOM vector). */
|
|
53
|
+
const MAX_BODY_BYTES = 5 * 1024 * 1024
|
|
54
|
+
|
|
55
|
+
/** Route shapes (T2: compiled once at module load, not on every request). */
|
|
56
|
+
const TASK_DIFF_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`)
|
|
57
|
+
const TASK_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`)
|
|
58
|
+
const TASK_ACTION_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`)
|
|
59
|
+
|
|
48
60
|
/** How long a workspace git-detection result stays cached (fail-soft). */
|
|
49
61
|
const GIT_DETECT_TTL_MS = 60_000
|
|
50
62
|
|
|
@@ -72,7 +84,7 @@ export interface TaskboardRoutesOptions {
|
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
/** Validate a template's task spec (routes-side, unknown → invalid_input). */
|
|
75
|
-
function normalizeTemplateSpec(raw: unknown): TaskTemplate['task'] {
|
|
87
|
+
function normalizeTemplateSpec(raw: unknown, now: number): TaskTemplate['task'] {
|
|
76
88
|
if (typeof raw !== 'object' || raw === null) throw new Error('Error: invalid_input: task must be an object')
|
|
77
89
|
const e = raw as Record<string, unknown>
|
|
78
90
|
const spec: TaskTemplate['task'] = {}
|
|
@@ -95,7 +107,7 @@ function normalizeTemplateSpec(raw: unknown): TaskTemplate['task'] {
|
|
|
95
107
|
if (isolation !== undefined) spec.isolation = asIsolation(isolation)
|
|
96
108
|
if (presetId !== undefined && presetId.trim().length > 0) spec.presetId = presetId.trim()
|
|
97
109
|
if (e.execution !== undefined) {
|
|
98
|
-
spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string },
|
|
110
|
+
spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string }, now)
|
|
99
111
|
}
|
|
100
112
|
if (e.model !== undefined) spec.model = normalizeModel(e.model)
|
|
101
113
|
if (e.checklist !== undefined) {
|
|
@@ -135,10 +147,19 @@ function fail(code: ApiFail['error']['code'], message: string): { res: ApiFail;
|
|
|
135
147
|
return { res: { ok: false, error: { code, message } }, status }
|
|
136
148
|
}
|
|
137
149
|
|
|
138
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Read one JSON body (null on parse failure). S8: rejects bodies over
|
|
152
|
+
* MAX_BODY_BYTES by throwing — the local, unauthenticated HTTP surface must
|
|
153
|
+
* not be an unbounded memory sink.
|
|
154
|
+
*/
|
|
139
155
|
async function readBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {
|
|
140
156
|
const chunks: Buffer[] = []
|
|
141
|
-
|
|
157
|
+
let total = 0
|
|
158
|
+
for await (const chunk of req) {
|
|
159
|
+
total += (chunk as Buffer).length
|
|
160
|
+
if (total > MAX_BODY_BYTES) throw new Error('body too large')
|
|
161
|
+
chunks.push(chunk as Buffer)
|
|
162
|
+
}
|
|
142
163
|
if (chunks.length === 0) return {}
|
|
143
164
|
try {
|
|
144
165
|
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
@@ -161,6 +182,13 @@ function num(body: Record<string, unknown>, key: string): number | undefined | n
|
|
|
161
182
|
return typeof v === 'number' && Number.isFinite(v) ? v : null
|
|
162
183
|
}
|
|
163
184
|
|
|
185
|
+
/** Find a live task INSIDE a mutator (R1: guards run on the fresh draft). */
|
|
186
|
+
function liveTaskAt(ledger: TaskLedger, id: string): { index: number; task: TaskRecord } {
|
|
187
|
+
const index = ledger.tasks.findIndex(t => t.id === id)
|
|
188
|
+
if (index < 0 || ledger.tasks[index]!.trashedAt !== undefined) throw new Error('Error: not_found: no such task')
|
|
189
|
+
return { index, task: ledger.tasks[index]! }
|
|
190
|
+
}
|
|
191
|
+
|
|
164
192
|
/** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
|
|
165
193
|
function normalizePresetId(raw: string | null): string | undefined {
|
|
166
194
|
const t = (raw ?? '').trim()
|
|
@@ -170,9 +198,18 @@ function normalizePresetId(raw: string | null): string | undefined {
|
|
|
170
198
|
/** Map a thrown domain error to the envelope. */
|
|
171
199
|
function toFail(error: unknown): { res: ApiFail; status: number } {
|
|
172
200
|
const message = error instanceof Error ? error.message : String(error)
|
|
201
|
+
// Structured path first (review P2): ToolError carries its code — no need
|
|
202
|
+
// to parse the 'Error: <code>: …' prefix it also renders into the message.
|
|
203
|
+
if (error instanceof ToolError) {
|
|
204
|
+
const mapped = error.code === ERR.workspaceMismatch ? 'forbidden' : error.code
|
|
205
|
+
const known: ApiFail['error']['code'][] = ['invalid_input', 'not_found', 'version_conflict', 'invalid_transition', 'forbidden', 'internal']
|
|
206
|
+
if ((known as string[]).includes(mapped)) {
|
|
207
|
+
return fail(mapped as ApiFail['error']['code'], message.slice(7 + error.code.length + 2))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
173
210
|
const code = message.startsWith('Error: ') ? message.slice(7).split(':')[0] : undefined
|
|
174
|
-
const
|
|
175
|
-
if (code !== undefined && (
|
|
211
|
+
const known2: ApiFail['error']['code'][] = ['invalid_input', 'not_found', 'version_conflict', 'invalid_transition', 'forbidden', 'internal']
|
|
212
|
+
if (code !== undefined && (known2 as string[]).includes(code)) {
|
|
176
213
|
return fail(code as ApiFail['error']['code'], message.slice(7 + code.length + 2))
|
|
177
214
|
}
|
|
178
215
|
if (code === 'workspace_mismatch') return fail('forbidden', message.slice(7 + code.length + 2))
|
|
@@ -190,11 +227,18 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
190
227
|
const subscribers = new Set<ServerResponse>()
|
|
191
228
|
let heartbeat: NodeJS.Timeout | undefined
|
|
192
229
|
|
|
230
|
+
/** R4③: a cleanup/purge target must resolve INSIDE <ws>/.dsh-worktrees — string joining alone is never trusted with an rm. */
|
|
231
|
+
const insideWorktreeScope = (wsPath: string, target: string): boolean => {
|
|
232
|
+
const scope = resolve(wsPath, WORKTREE_DIR)
|
|
233
|
+
const resolved = resolve(target)
|
|
234
|
+
return resolved === scope || resolved.startsWith(scope + sep)
|
|
235
|
+
}
|
|
236
|
+
|
|
193
237
|
const broadcast = (change: { revision: number; kind: string; tasks: readonly TaskRecord[] }): void => {
|
|
194
238
|
const frame = `event: change\ndata: ${JSON.stringify({ revision: change.revision, kind: change.kind, tasks: change.tasks.map(summarize) })}\n\n`
|
|
195
239
|
for (const res of subscribers) res.write(frame)
|
|
196
240
|
}
|
|
197
|
-
store.subscribe(broadcast)
|
|
241
|
+
const unsubscribeBroadcast = store.subscribe(broadcast)
|
|
198
242
|
|
|
199
243
|
// Workspace git detection, TTL-cached and fail-soft (false on any error):
|
|
200
244
|
// feeds the create-form isolation toggle and the diagnostics panel.
|
|
@@ -304,7 +348,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
304
348
|
// Diff viewer (0.4.0): read-only git show/diff for one execution's
|
|
305
349
|
// commit or changed path. Prefers the live worktree (uncommitted
|
|
306
350
|
// view), falls back to the main repo.
|
|
307
|
-
const diffMatch = pathname.match(
|
|
351
|
+
const diffMatch = pathname.match(TASK_DIFF_RE)
|
|
308
352
|
if (diffMatch !== null) {
|
|
309
353
|
try {
|
|
310
354
|
if (options.git === undefined) {
|
|
@@ -355,7 +399,14 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
355
399
|
return
|
|
356
400
|
}
|
|
357
401
|
|
|
358
|
-
|
|
402
|
+
// Board settings (0.5.0): absent fields follow factory defaults.
|
|
403
|
+
if (pathname === `${ROUTE_PREFIX}/settings`) {
|
|
404
|
+
await store.load()
|
|
405
|
+
json(res, { ok: true, value: store.snapshot().settings ?? {} })
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const taskMatch = pathname.match(TASK_RE)
|
|
359
410
|
if (taskMatch !== null) {
|
|
360
411
|
const task = store.get(taskMatch[1]!)
|
|
361
412
|
if (task === undefined) { const f = fail('not_found', 'no such task'); json(res, f.res, f.status); return }
|
|
@@ -368,7 +419,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
368
419
|
}
|
|
369
420
|
|
|
370
421
|
if (req.method !== 'POST') {
|
|
371
|
-
res.writeHead(405)
|
|
422
|
+
res.writeHead(405, { allow: 'GET, POST' })
|
|
372
423
|
res.end()
|
|
373
424
|
return
|
|
374
425
|
}
|
|
@@ -379,7 +430,14 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
379
430
|
json(res, f.res, 415)
|
|
380
431
|
return
|
|
381
432
|
}
|
|
382
|
-
|
|
433
|
+
let body: Record<string, unknown> | null
|
|
434
|
+
try {
|
|
435
|
+
body = await readBody(req)
|
|
436
|
+
} catch {
|
|
437
|
+
const f = fail('invalid_input', `request body exceeds ${MAX_BODY_BYTES} bytes`)
|
|
438
|
+
json(res, f.res, 413)
|
|
439
|
+
return
|
|
440
|
+
}
|
|
383
441
|
if (body === null) {
|
|
384
442
|
const f = fail('invalid_input', 'body is not a JSON object')
|
|
385
443
|
json(res, f.res, 400)
|
|
@@ -394,10 +452,16 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
394
452
|
if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
395
453
|
const urgency = asUrgency(str(body, 'urgency') ?? '')
|
|
396
454
|
const status = str(body, 'status') === null ? 'todo' as const : asStatus(str(body, 'status')!)
|
|
455
|
+
if (status !== 'backlog' && status !== 'todo') {
|
|
456
|
+
throw new Error('Error: invalid_transition: a new task must start as backlog or todo')
|
|
457
|
+
}
|
|
397
458
|
const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())
|
|
398
459
|
const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)
|
|
399
460
|
const isolationRaw = str(body, 'isolation')
|
|
400
|
-
|
|
461
|
+
// 0.5.0: an omitted isolation is MATERIALIZED from the board
|
|
462
|
+
// setting (看板设置) at creation, so later setting changes never
|
|
463
|
+
// rewrite existing tasks.
|
|
464
|
+
const isolation = isolationRaw === null ? defaultIsolationOf(store.snapshot().settings) : asIsolation(isolationRaw)
|
|
401
465
|
const presetId = normalizePresetId(str(body, 'presetId'))
|
|
402
466
|
let checklist: TaskRecord['checklist'] = undefined
|
|
403
467
|
if (body.checklist !== undefined) {
|
|
@@ -419,7 +483,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
419
483
|
blocked: false,
|
|
420
484
|
execution,
|
|
421
485
|
model,
|
|
422
|
-
|
|
486
|
+
isolation,
|
|
423
487
|
...(presetId !== undefined ? { presetId } : {}),
|
|
424
488
|
...(checklist !== undefined ? { checklist } : {}),
|
|
425
489
|
version: 1,
|
|
@@ -444,8 +508,8 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
444
508
|
|
|
445
509
|
// ------------------------------------------- POST /tasks/:id/{action}
|
|
446
510
|
// (\w+ after the id would not match hyphenated actions like
|
|
447
|
-
// worktree-remove, hence the explicit class.)
|
|
448
|
-
const actionMatch = pathname.match(
|
|
511
|
+
// worktree-remove, hence the explicit class in the hoisted pattern.)
|
|
512
|
+
const actionMatch = pathname.match(TASK_ACTION_RE)
|
|
449
513
|
if (actionMatch !== null) {
|
|
450
514
|
const id = actionMatch[1]!
|
|
451
515
|
const action = actionMatch[2]!
|
|
@@ -455,78 +519,82 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
455
519
|
if (action === 'update') {
|
|
456
520
|
const ifVersion = num(body, 'ifVersion')
|
|
457
521
|
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const title = str(body, 'title')
|
|
461
|
-
if (title !== null) next.title = normalizeTitle(title)
|
|
462
|
-
const description = str(body, 'description')
|
|
463
|
-
if (description !== null) next.description = description.trim()
|
|
464
|
-
const prompt = str(body, 'prompt')
|
|
465
|
-
if (prompt !== null) next.prompt = normalizePrompt(prompt)
|
|
466
|
-
const urgency = str(body, 'urgency')
|
|
467
|
-
if (urgency !== null) next.urgency = asUrgency(urgency)
|
|
468
|
-
// GUI-only rebind to another project; validated against the workspace registry.
|
|
469
|
-
const workspaceId = str(body, 'workspaceId')
|
|
470
|
-
if (workspaceId !== null) {
|
|
471
|
-
if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
472
|
-
next.workspaceId = workspaceId
|
|
473
|
-
}
|
|
474
|
-
if (typeof body.blocked === 'boolean') next.blocked = body.blocked
|
|
475
|
-
// The GUI (task owner surface) may edit model/execution; null clears the model.
|
|
476
|
-
if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
|
|
477
|
-
if (body.model === null) next.model = undefined
|
|
478
|
-
else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)
|
|
479
|
-
// Isolation may change only before the first execution (分支与基线
|
|
480
|
-
// 取决于该选择 — plan §3.1: 执行开始后锁定).
|
|
481
|
-
const isolationRaw = str(body, 'isolation')
|
|
482
|
-
if (isolationRaw !== null) {
|
|
483
|
-
if (task.executions.length > 0 || task.status === 'in_progress') {
|
|
484
|
-
throw new Error('Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改')
|
|
485
|
-
}
|
|
486
|
-
next.isolation = asIsolation(isolationRaw)
|
|
487
|
-
}
|
|
488
|
-
// Preset may change any time: each run composes fresh.
|
|
489
|
-
if (body.presetId === null) delete next.presetId
|
|
490
|
-
else if (body.presetId !== undefined) next.presetId = normalizePresetId(str(body, 'presetId'))!
|
|
491
|
-
// Checklist (0.4.0): the GUI replaces the whole list; null clears.
|
|
492
|
-
if (body.checklist === null) delete next.checklist
|
|
493
|
-
else if (body.checklist !== undefined) {
|
|
494
|
-
const items = normalizeChecklist(body.checklist)
|
|
495
|
-
if (items.length > 0) next.checklist = items
|
|
496
|
-
else delete next.checklist
|
|
497
|
-
}
|
|
498
|
-
next.version = task.version + 1
|
|
499
|
-
next.updatedAt = options.now()
|
|
500
|
-
next.updatedBy = { kind: 'user' }
|
|
522
|
+
// R1: version guard + write inside the mutation, on the fresh draft.
|
|
523
|
+
let next: TaskRecord | undefined
|
|
501
524
|
await store.mutate('task-updated', ledger => {
|
|
502
|
-
const
|
|
503
|
-
|
|
525
|
+
const { index, task } = liveTaskAt(ledger, id)
|
|
526
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
527
|
+
if (task.status === 'archived') throw new Error('Error: invalid_transition: archived tasks are immutable')
|
|
528
|
+
next = structuredClone(task)
|
|
529
|
+
const title = str(body, 'title')
|
|
530
|
+
if (title !== null) next.title = normalizeTitle(title)
|
|
531
|
+
const description = str(body, 'description')
|
|
532
|
+
if (description !== null) next.description = description.trim()
|
|
533
|
+
const prompt = str(body, 'prompt')
|
|
534
|
+
if (prompt !== null) next.prompt = normalizePrompt(prompt)
|
|
535
|
+
const urgency = str(body, 'urgency')
|
|
536
|
+
if (urgency !== null) next.urgency = asUrgency(urgency)
|
|
537
|
+
// GUI-only rebind to another project; validated against the workspace registry.
|
|
538
|
+
const workspaceId = str(body, 'workspaceId')
|
|
539
|
+
if (workspaceId !== null) {
|
|
540
|
+
if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
541
|
+
next.workspaceId = workspaceId
|
|
542
|
+
}
|
|
543
|
+
if (typeof body.blocked === 'boolean') next.blocked = body.blocked
|
|
544
|
+
// The GUI (task owner surface) may edit model/execution; null clears the model.
|
|
545
|
+
if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
|
|
546
|
+
if (body.model === null) next.model = undefined
|
|
547
|
+
else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)
|
|
548
|
+
// Isolation may change only before the first execution (分支与基线
|
|
549
|
+
// 取决于该选择 — plan §3.1: 执行开始后锁定).
|
|
550
|
+
const isolationRaw = str(body, 'isolation')
|
|
551
|
+
if (isolationRaw !== null) {
|
|
552
|
+
if (task.executions.length > 0 || task.status === 'in_progress') {
|
|
553
|
+
throw new Error('Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改')
|
|
554
|
+
}
|
|
555
|
+
next.isolation = asIsolation(isolationRaw)
|
|
556
|
+
}
|
|
557
|
+
// Preset may change any time: each run composes fresh.
|
|
558
|
+
if (body.presetId === null) delete next.presetId
|
|
559
|
+
else if (body.presetId !== undefined) next.presetId = normalizePresetId(str(body, 'presetId'))!
|
|
560
|
+
// Checklist (0.4.0): the GUI replaces the whole list; null clears.
|
|
561
|
+
if (body.checklist === null) delete next.checklist
|
|
562
|
+
else if (body.checklist !== undefined) {
|
|
563
|
+
const items = normalizeChecklist(body.checklist)
|
|
564
|
+
if (items.length > 0) next.checklist = items
|
|
565
|
+
else delete next.checklist
|
|
566
|
+
}
|
|
567
|
+
next.version = task.version + 1
|
|
568
|
+
next.updatedAt = options.now()
|
|
569
|
+
next.updatedBy = { kind: 'user' }
|
|
570
|
+
ledger.tasks[index] = next
|
|
504
571
|
return [next]
|
|
505
572
|
})
|
|
506
|
-
json(res, { ok: true, value: summarize(next) })
|
|
573
|
+
json(res, { ok: true, value: summarize(next!) })
|
|
507
574
|
return
|
|
508
575
|
}
|
|
509
576
|
if (action === 'move') {
|
|
510
577
|
const ifVersion = num(body, 'ifVersion')
|
|
511
578
|
const status = str(body, 'status') ?? ''
|
|
512
579
|
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
513
|
-
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
514
580
|
const to = asStatus(status)
|
|
515
|
-
|
|
516
|
-
const next = structuredClone(task)
|
|
517
|
-
next.status = to
|
|
518
|
-
next.version = task.version + 1
|
|
519
|
-
next.updatedAt = options.now()
|
|
520
|
-
next.updatedBy = { kind: 'user' }
|
|
521
|
-
if (task.status === 'todo' && to === 'in_progress') next.blocked = false
|
|
522
|
-
// A user move records no holder; leaving in_progress releases any hold.
|
|
523
|
-
syncClaim(next, to, options.now())
|
|
581
|
+
let next: TaskRecord | undefined
|
|
524
582
|
await store.mutate('task-moved', ledger => {
|
|
525
|
-
const
|
|
526
|
-
|
|
583
|
+
const { index, task } = liveTaskAt(ledger, id)
|
|
584
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
585
|
+
if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)
|
|
586
|
+
next = structuredClone(task)
|
|
587
|
+
next.status = to
|
|
588
|
+
next.version = task.version + 1
|
|
589
|
+
next.updatedAt = options.now()
|
|
590
|
+
next.updatedBy = { kind: 'user' }
|
|
591
|
+
if (task.status === 'todo' && to === 'in_progress') next.blocked = false
|
|
592
|
+
// A user move records no holder; leaving in_progress releases any hold.
|
|
593
|
+
syncClaim(next, to, options.now())
|
|
594
|
+
ledger.tasks[index] = next
|
|
527
595
|
return [next]
|
|
528
596
|
})
|
|
529
|
-
json(res, { ok: true, value: summarize(next) })
|
|
597
|
+
json(res, { ok: true, value: summarize(next!) })
|
|
530
598
|
return
|
|
531
599
|
}
|
|
532
600
|
if (action === 'reject') {
|
|
@@ -534,36 +602,38 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
534
602
|
// atomic mutation (a failed move never strands an orphan comment).
|
|
535
603
|
const ifVersion = num(body, 'ifVersion')
|
|
536
604
|
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
537
|
-
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
538
|
-
if (!canTransition(task.status, 'todo')) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`)
|
|
539
|
-
const next = structuredClone(task)
|
|
540
|
-
next.status = 'todo'
|
|
541
|
-
next.version = task.version + 1
|
|
542
|
-
next.updatedAt = options.now()
|
|
543
|
-
next.updatedBy = { kind: 'user' }
|
|
544
|
-
syncClaim(next, 'todo', options.now())
|
|
545
605
|
const commentText = str(body, 'body') ?? ''
|
|
546
|
-
|
|
547
|
-
next.comments.push({ id: newCommentId(), body: normalizeBody(commentText), version: 1, createdAt: options.now() })
|
|
548
|
-
}
|
|
606
|
+
let next: TaskRecord | undefined
|
|
549
607
|
await store.mutate('task-moved', ledger => {
|
|
550
|
-
const
|
|
551
|
-
|
|
608
|
+
const { index, task } = liveTaskAt(ledger, id)
|
|
609
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
610
|
+
if (!canTransition(task.status, 'todo')) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`)
|
|
611
|
+
next = structuredClone(task)
|
|
612
|
+
next.status = 'todo'
|
|
613
|
+
next.version = task.version + 1
|
|
614
|
+
next.updatedAt = options.now()
|
|
615
|
+
next.updatedBy = { kind: 'user' }
|
|
616
|
+
syncClaim(next, 'todo', options.now())
|
|
617
|
+
if (commentText.trim().length > 0) {
|
|
618
|
+
next.comments.push({ id: newCommentId(), body: normalizeBody(commentText), version: 1, createdAt: options.now() })
|
|
619
|
+
}
|
|
620
|
+
ledger.tasks[index] = next
|
|
552
621
|
return [next]
|
|
553
622
|
})
|
|
554
|
-
json(res, { ok: true, value: summarize(next) })
|
|
623
|
+
json(res, { ok: true, value: summarize(next!) })
|
|
555
624
|
return
|
|
556
625
|
}
|
|
557
626
|
if (action === 'comment') {
|
|
558
627
|
const bodyText = str(body, 'body') ?? ''
|
|
559
628
|
const comment = { id: newCommentId(), body: normalizeBody(bodyText), version: 1, createdAt: options.now() }
|
|
560
|
-
const next = structuredClone(task)
|
|
561
|
-
next.comments.push(comment)
|
|
562
|
-
next.version = task.version + 1
|
|
563
|
-
next.updatedAt = options.now()
|
|
564
629
|
await store.mutate('comment-added', ledger => {
|
|
565
|
-
const
|
|
566
|
-
|
|
630
|
+
const { index, task } = liveTaskAt(ledger, id)
|
|
631
|
+
if (task.status === 'archived') throw new Error('Error: invalid_transition: archived tasks are immutable')
|
|
632
|
+
const next = structuredClone(task)
|
|
633
|
+
next.comments.push(comment)
|
|
634
|
+
next.version = task.version + 1
|
|
635
|
+
next.updatedAt = options.now()
|
|
636
|
+
ledger.tasks[index] = next
|
|
567
637
|
return [next]
|
|
568
638
|
})
|
|
569
639
|
json(res, { ok: true, value: comment }, 201)
|
|
@@ -580,19 +650,25 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
580
650
|
const ws = workspaces.get(task.workspaceId)
|
|
581
651
|
if (ws !== undefined) {
|
|
582
652
|
const path = worktreePathOf(ws.path, id)
|
|
653
|
+
if (!insideWorktreeScope(ws.path, path)) {
|
|
654
|
+
throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
|
|
655
|
+
}
|
|
583
656
|
try {
|
|
584
|
-
|
|
657
|
+
// S3: 'unregistered' (an orphaned dir git forgot) is a
|
|
658
|
+
// structured outcome, not a parsed stderr message.
|
|
659
|
+
if (await options.git.removeWorktree(ws.path, path) === 'unregistered') {
|
|
660
|
+
// An unregistered leftover dir: plain fs removal.
|
|
661
|
+
await rm(path, { recursive: true, force: true })
|
|
662
|
+
}
|
|
585
663
|
} catch (error) {
|
|
586
664
|
const message = error instanceof Error ? error.message : String(error)
|
|
587
|
-
|
|
665
|
+
// Structured classification (review P2): git tags its dirty
|
|
666
|
+
// rejections with a code; the keyword stays as a fallback.
|
|
667
|
+
const dirty = (error as { code?: string }).code === 'dirty-worktree' || message.includes('未提交修改')
|
|
668
|
+
if (dirty) {
|
|
588
669
|
throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`)
|
|
589
670
|
}
|
|
590
|
-
|
|
591
|
-
// An unregistered leftover dir: plain fs removal.
|
|
592
|
-
await rm(path, { recursive: true, force: true })
|
|
593
|
-
} else {
|
|
594
|
-
throw new Error(`Error: invalid_input: ${message}`)
|
|
595
|
-
}
|
|
671
|
+
throw new Error(`Error: invalid_input: ${message}`)
|
|
596
672
|
}
|
|
597
673
|
if (task.branch !== undefined) {
|
|
598
674
|
try {
|
|
@@ -610,13 +686,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
610
686
|
}
|
|
611
687
|
const ifVersion = num(body, 'ifVersion')
|
|
612
688
|
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
613
|
-
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
614
|
-
const next = structuredClone(task)
|
|
615
|
-
next.trashedAt = options.now()
|
|
616
|
-
next.version = task.version + 1
|
|
617
689
|
await store.mutate('task-deleted', ledger => {
|
|
618
|
-
const
|
|
619
|
-
|
|
690
|
+
const { index, task } = liveTaskAt(ledger, id)
|
|
691
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
692
|
+
// S5: a running execution keeps writing to the task — refuse the
|
|
693
|
+
// soft-delete until it is cancelled or settled. T8: clear residue.
|
|
694
|
+
if (task.executions.some(e => e.outcome === 'running')) {
|
|
695
|
+
throw new Error('Error: invalid_input: 任务有正在运行的执行,请先取消或等它结束再删除')
|
|
696
|
+
}
|
|
697
|
+
const next = structuredClone(task)
|
|
698
|
+
next.trashedAt = options.now()
|
|
699
|
+
next.version = task.version + 1
|
|
700
|
+
delete next.claimedBy
|
|
701
|
+
delete next.claimedAt
|
|
702
|
+
next.blocked = false
|
|
703
|
+
ledger.tasks[index] = next
|
|
620
704
|
return [next]
|
|
621
705
|
})
|
|
622
706
|
json(res, { ok: true, value: { trashed: true } })
|
|
@@ -683,13 +767,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
683
767
|
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
|
|
684
768
|
}
|
|
685
769
|
const mergedComment = { id: newCommentId(), body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`), version: 1, createdAt: options.now() }
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
next.version = task.version + 1
|
|
689
|
-
next.updatedAt = options.now()
|
|
770
|
+
// R1: the git merge above is slow — re-find the FRESH task inside
|
|
771
|
+
// the mutation so a concurrent comment is never overwritten.
|
|
690
772
|
await store.mutate('comment-added', ledger => {
|
|
691
|
-
const
|
|
692
|
-
|
|
773
|
+
const { index, task: fresh } = liveTaskAt(ledger, id)
|
|
774
|
+
const next = structuredClone(fresh)
|
|
775
|
+
next.comments.push(mergedComment)
|
|
776
|
+
next.version = fresh.version + 1
|
|
777
|
+
next.updatedAt = options.now()
|
|
778
|
+
ledger.tasks[index] = next
|
|
693
779
|
return [next]
|
|
694
780
|
})
|
|
695
781
|
json(res, { ok: true, value: { merged: true, branch: task.branch } })
|
|
@@ -707,8 +793,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
707
793
|
const ws = workspaces.get(task.workspaceId)
|
|
708
794
|
if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
709
795
|
const path = worktreePathOf(ws.path, id)
|
|
796
|
+
if (!insideWorktreeScope(ws.path, path)) {
|
|
797
|
+
throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
|
|
798
|
+
}
|
|
710
799
|
try {
|
|
711
|
-
|
|
800
|
+
// S3: an unregistered leftover at the task's own path is
|
|
801
|
+
// removed from the filesystem directly.
|
|
802
|
+
if (await options.git.removeWorktree(ws.path, path) === 'unregistered') {
|
|
803
|
+
await rm(path, { recursive: true, force: true })
|
|
804
|
+
}
|
|
712
805
|
} catch (error) {
|
|
713
806
|
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
|
|
714
807
|
}
|
|
@@ -749,19 +842,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
749
842
|
// Only dirs owned by NO ledger task may be cleaned here; live tasks
|
|
750
843
|
// remove their worktree from the detail page.
|
|
751
844
|
if (store.get(taskId) !== undefined) throw new Error('Error: invalid_input: 任务仍在看板中,请从任务详情页删除其 worktree')
|
|
845
|
+
// worktreePathOf throws on an illegal id charset (R4②); the resolved
|
|
846
|
+
// target must additionally stay inside the plugin's own worktree
|
|
847
|
+
// scope — the taskId is fully attacker-controlled body input (R4③).
|
|
752
848
|
const path = worktreePathOf(ws.path, taskId)
|
|
849
|
+
if (!insideWorktreeScope(ws.path, path)) {
|
|
850
|
+
throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
|
|
851
|
+
}
|
|
753
852
|
try {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
// An unregistered leftover (git no longer knows this worktree):
|
|
758
|
-
// fall back to direct fs removal — the dir lives inside the
|
|
759
|
-
// plugin's own .dsh-worktrees scope.
|
|
760
|
-
if (/not a working tree|not a working-tree/i.test(message)) {
|
|
853
|
+
// S3: 'unregistered' = git no longer knows this worktree; remove
|
|
854
|
+
// the leftover dir directly (scope-verified above).
|
|
855
|
+
if (await options.git.removeWorktree(ws.path, path) === 'unregistered') {
|
|
761
856
|
await rm(path, { recursive: true, force: true })
|
|
762
|
-
} else {
|
|
763
|
-
throw new Error(`Error: invalid_input: ${message}`)
|
|
764
857
|
}
|
|
858
|
+
} catch (error) {
|
|
859
|
+
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
|
|
765
860
|
}
|
|
766
861
|
json(res, { ok: true, value: { cleaned: true, path } })
|
|
767
862
|
} catch (error) {
|
|
@@ -814,14 +909,31 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
814
909
|
backupFile = await store.backup()
|
|
815
910
|
}
|
|
816
911
|
let replacedTotal: number | undefined
|
|
817
|
-
await store.mutate('
|
|
912
|
+
await store.mutate('ledger-replaced', ledger => {
|
|
818
913
|
if (mode === 'replace') {
|
|
914
|
+
// S6: a whole-ledger swap must not strand live executions — the
|
|
915
|
+
// running sessions keep working with no task to settle into.
|
|
916
|
+
if (ledger.tasks.some(t => t.executions.some(e => e.outcome === 'running'))) {
|
|
917
|
+
throw new Error('Error: invalid_input: 有任务正在执行,不能整册替换(请先取消或等待结束)')
|
|
918
|
+
}
|
|
819
919
|
replacedTotal = ledger.tasks.length
|
|
820
920
|
ledger.tasks = structuredClone(imported)
|
|
921
|
+
// Replace is a whole-ledger swap (0.5.0): board settings ride
|
|
922
|
+
// along when the file carries them; merge keeps the live ones.
|
|
923
|
+
if (plan.settings !== undefined) ledger.settings = structuredClone(plan.settings)
|
|
924
|
+
else delete ledger.settings
|
|
821
925
|
return ledger.tasks
|
|
822
926
|
}
|
|
823
927
|
const byId = new Map(ledger.tasks.map(t => [t.id, t]))
|
|
824
|
-
for (const task of imported)
|
|
928
|
+
for (const task of imported) {
|
|
929
|
+
// S6: merging over a task whose execution is live would orphan
|
|
930
|
+
// that run the same way — refuse the overwrite.
|
|
931
|
+
const existing = byId.get(task.id)
|
|
932
|
+
if (existing !== undefined && existing.executions.some(e => e.outcome === 'running')) {
|
|
933
|
+
throw new Error(`Error: invalid_input: 任务 ${task.id} 正在执行,不能被导入覆盖`)
|
|
934
|
+
}
|
|
935
|
+
byId.set(task.id, structuredClone(task))
|
|
936
|
+
}
|
|
825
937
|
ledger.tasks = [...byId.values()]
|
|
826
938
|
return structuredClone(imported)
|
|
827
939
|
})
|
|
@@ -862,7 +974,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
862
974
|
const template = await options.templates.upsert({
|
|
863
975
|
id: str(body, 'id') ?? undefined,
|
|
864
976
|
name,
|
|
865
|
-
task: normalizeTemplateSpec(body.task),
|
|
977
|
+
task: normalizeTemplateSpec(body.task, options.now()),
|
|
866
978
|
})
|
|
867
979
|
json(res, { ok: true, value: template }, 201)
|
|
868
980
|
} catch (error) {
|
|
@@ -872,6 +984,24 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
872
984
|
return
|
|
873
985
|
}
|
|
874
986
|
|
|
987
|
+
// ------------------------------------------ POST /settings/update
|
|
988
|
+
// (0.5.0) Whole-object replace semantics: omitted fields fall back to
|
|
989
|
+
// their factory defaults. Affects only tasks created AFTER the change.
|
|
990
|
+
if (pathname === `${ROUTE_PREFIX}/settings/update`) {
|
|
991
|
+
try {
|
|
992
|
+
const next = asBoardSettings(body)
|
|
993
|
+
await store.mutate('settings-updated', ledger => {
|
|
994
|
+
ledger.settings = next
|
|
995
|
+
return []
|
|
996
|
+
})
|
|
997
|
+
json(res, { ok: true, value: next })
|
|
998
|
+
} catch (error) {
|
|
999
|
+
const f = toFail(error)
|
|
1000
|
+
json(res, f.res, f.status)
|
|
1001
|
+
}
|
|
1002
|
+
return
|
|
1003
|
+
}
|
|
1004
|
+
|
|
875
1005
|
res.writeHead(404)
|
|
876
1006
|
res.end()
|
|
877
1007
|
} catch (error) {
|
|
@@ -890,6 +1020,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
890
1020
|
// Baseline frame: the client reconciles by revision and refetches state on gaps.
|
|
891
1021
|
res.write(`event: hello\ndata: ${JSON.stringify({ revision: store.snapshot().revision })}\n\n`)
|
|
892
1022
|
subscribers.add(res)
|
|
1023
|
+
// S2: a socket that dies between 'close' detection and the next write
|
|
1024
|
+
// would emit 'error' on the response — unhandled, that escalates to an
|
|
1025
|
+
// uncaughtException. Drop the subscriber instead.
|
|
1026
|
+
res.on('error', () => { subscribers.delete(res) })
|
|
893
1027
|
if (heartbeat === undefined) {
|
|
894
1028
|
heartbeat = setInterval(() => {
|
|
895
1029
|
for (const current of subscribers) current.write(': ping\n\n')
|
|
@@ -909,6 +1043,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
909
1043
|
ctx.webServer.register({ kind: 'exact', path: SSE_PATH, handler: sse }),
|
|
910
1044
|
]
|
|
911
1045
|
return () => {
|
|
1046
|
+
unsubscribeBroadcast()
|
|
912
1047
|
for (const dispose of disposers) dispose()
|
|
913
1048
|
if (heartbeat !== undefined) clearInterval(heartbeat)
|
|
914
1049
|
for (const res of subscribers) res.end()
|