dsh-taskboard 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/lib/client.js +242 -174
- 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 +180 -109
- 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 +124 -93
- 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 +23 -2
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +3 -2
- package/src/client/api.ts +19 -9
- package/src/client/board/ImportModal.tsx +1 -1
- package/src/client/board/TaskBoard.tsx +7 -38
- package/src/client/board/TaskCard.tsx +3 -5
- package/src/client/board/TaskDetail.tsx +30 -21
- package/src/client/board/TaskFormModal.tsx +30 -23
- package/src/client/board/format.ts +26 -0
- package/src/client/board/labels.ts +44 -0
- package/src/client/board-mount.tsx +9 -6
- package/src/client/controller.ts +60 -13
- package/src/client/index.ts +7 -5
- package/src/client/sidebar-entry.ts +16 -5
- package/src/client/styles.ts +5 -3
- package/src/host/execution.ts +90 -16
- package/src/host/git.ts +39 -10
- package/src/host/routes.ts +227 -126
- 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 +180 -123
- package/src/index.ts +10 -1
- package/src/shared/api.ts +1 -1
- package/src/shared/protocol.ts +35 -1
- package/src/shared/version.ts +1 -1
- package/src/client/board/NewTaskModal.tsx +0 -8
package/src/host/routes.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
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'
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
summarize,
|
|
35
35
|
syncClaim,
|
|
36
36
|
validateLedgerImport,
|
|
37
|
+
type TaskLedger,
|
|
37
38
|
type TaskModel,
|
|
38
39
|
type TaskRecord,
|
|
39
40
|
} from '../shared/protocol.ts'
|
|
@@ -42,11 +43,20 @@ import type { TaskTemplate } from '../shared/api.ts'
|
|
|
42
43
|
import type { TemplateStore } from './templates.ts'
|
|
43
44
|
import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
|
|
44
45
|
import type { TaskStore } from './store.ts'
|
|
46
|
+
import { ERR, ToolError } from './tools.ts'
|
|
45
47
|
import type { WorkspaceFace } from './tools.ts'
|
|
46
48
|
|
|
47
49
|
/** Heartbeat cadence for the SSE stream. */
|
|
48
50
|
const HEARTBEAT_MS = 20_000
|
|
49
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
|
+
|
|
50
60
|
/** How long a workspace git-detection result stays cached (fail-soft). */
|
|
51
61
|
const GIT_DETECT_TTL_MS = 60_000
|
|
52
62
|
|
|
@@ -74,7 +84,7 @@ export interface TaskboardRoutesOptions {
|
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
/** Validate a template's task spec (routes-side, unknown → invalid_input). */
|
|
77
|
-
function normalizeTemplateSpec(raw: unknown): TaskTemplate['task'] {
|
|
87
|
+
function normalizeTemplateSpec(raw: unknown, now: number): TaskTemplate['task'] {
|
|
78
88
|
if (typeof raw !== 'object' || raw === null) throw new Error('Error: invalid_input: task must be an object')
|
|
79
89
|
const e = raw as Record<string, unknown>
|
|
80
90
|
const spec: TaskTemplate['task'] = {}
|
|
@@ -97,7 +107,7 @@ function normalizeTemplateSpec(raw: unknown): TaskTemplate['task'] {
|
|
|
97
107
|
if (isolation !== undefined) spec.isolation = asIsolation(isolation)
|
|
98
108
|
if (presetId !== undefined && presetId.trim().length > 0) spec.presetId = presetId.trim()
|
|
99
109
|
if (e.execution !== undefined) {
|
|
100
|
-
spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string },
|
|
110
|
+
spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string }, now)
|
|
101
111
|
}
|
|
102
112
|
if (e.model !== undefined) spec.model = normalizeModel(e.model)
|
|
103
113
|
if (e.checklist !== undefined) {
|
|
@@ -137,10 +147,19 @@ function fail(code: ApiFail['error']['code'], message: string): { res: ApiFail;
|
|
|
137
147
|
return { res: { ok: false, error: { code, message } }, status }
|
|
138
148
|
}
|
|
139
149
|
|
|
140
|
-
/**
|
|
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
|
+
*/
|
|
141
155
|
async function readBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {
|
|
142
156
|
const chunks: Buffer[] = []
|
|
143
|
-
|
|
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
|
+
}
|
|
144
163
|
if (chunks.length === 0) return {}
|
|
145
164
|
try {
|
|
146
165
|
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
@@ -163,6 +182,13 @@ function num(body: Record<string, unknown>, key: string): number | undefined | n
|
|
|
163
182
|
return typeof v === 'number' && Number.isFinite(v) ? v : null
|
|
164
183
|
}
|
|
165
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
|
+
|
|
166
192
|
/** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
|
|
167
193
|
function normalizePresetId(raw: string | null): string | undefined {
|
|
168
194
|
const t = (raw ?? '').trim()
|
|
@@ -172,9 +198,18 @@ function normalizePresetId(raw: string | null): string | undefined {
|
|
|
172
198
|
/** Map a thrown domain error to the envelope. */
|
|
173
199
|
function toFail(error: unknown): { res: ApiFail; status: number } {
|
|
174
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
|
+
}
|
|
175
210
|
const code = message.startsWith('Error: ') ? message.slice(7).split(':')[0] : undefined
|
|
176
|
-
const
|
|
177
|
-
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)) {
|
|
178
213
|
return fail(code as ApiFail['error']['code'], message.slice(7 + code.length + 2))
|
|
179
214
|
}
|
|
180
215
|
if (code === 'workspace_mismatch') return fail('forbidden', message.slice(7 + code.length + 2))
|
|
@@ -192,11 +227,18 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
192
227
|
const subscribers = new Set<ServerResponse>()
|
|
193
228
|
let heartbeat: NodeJS.Timeout | undefined
|
|
194
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
|
+
|
|
195
237
|
const broadcast = (change: { revision: number; kind: string; tasks: readonly TaskRecord[] }): void => {
|
|
196
238
|
const frame = `event: change\ndata: ${JSON.stringify({ revision: change.revision, kind: change.kind, tasks: change.tasks.map(summarize) })}\n\n`
|
|
197
239
|
for (const res of subscribers) res.write(frame)
|
|
198
240
|
}
|
|
199
|
-
store.subscribe(broadcast)
|
|
241
|
+
const unsubscribeBroadcast = store.subscribe(broadcast)
|
|
200
242
|
|
|
201
243
|
// Workspace git detection, TTL-cached and fail-soft (false on any error):
|
|
202
244
|
// feeds the create-form isolation toggle and the diagnostics panel.
|
|
@@ -306,7 +348,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
306
348
|
// Diff viewer (0.4.0): read-only git show/diff for one execution's
|
|
307
349
|
// commit or changed path. Prefers the live worktree (uncommitted
|
|
308
350
|
// view), falls back to the main repo.
|
|
309
|
-
const diffMatch = pathname.match(
|
|
351
|
+
const diffMatch = pathname.match(TASK_DIFF_RE)
|
|
310
352
|
if (diffMatch !== null) {
|
|
311
353
|
try {
|
|
312
354
|
if (options.git === undefined) {
|
|
@@ -364,7 +406,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
364
406
|
return
|
|
365
407
|
}
|
|
366
408
|
|
|
367
|
-
const taskMatch = pathname.match(
|
|
409
|
+
const taskMatch = pathname.match(TASK_RE)
|
|
368
410
|
if (taskMatch !== null) {
|
|
369
411
|
const task = store.get(taskMatch[1]!)
|
|
370
412
|
if (task === undefined) { const f = fail('not_found', 'no such task'); json(res, f.res, f.status); return }
|
|
@@ -377,7 +419,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
377
419
|
}
|
|
378
420
|
|
|
379
421
|
if (req.method !== 'POST') {
|
|
380
|
-
res.writeHead(405)
|
|
422
|
+
res.writeHead(405, { allow: 'GET, POST' })
|
|
381
423
|
res.end()
|
|
382
424
|
return
|
|
383
425
|
}
|
|
@@ -388,7 +430,14 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
388
430
|
json(res, f.res, 415)
|
|
389
431
|
return
|
|
390
432
|
}
|
|
391
|
-
|
|
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
|
+
}
|
|
392
441
|
if (body === null) {
|
|
393
442
|
const f = fail('invalid_input', 'body is not a JSON object')
|
|
394
443
|
json(res, f.res, 400)
|
|
@@ -403,6 +452,9 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
403
452
|
if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
404
453
|
const urgency = asUrgency(str(body, 'urgency') ?? '')
|
|
405
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
|
+
}
|
|
406
458
|
const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())
|
|
407
459
|
const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)
|
|
408
460
|
const isolationRaw = str(body, 'isolation')
|
|
@@ -456,8 +508,8 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
456
508
|
|
|
457
509
|
// ------------------------------------------- POST /tasks/:id/{action}
|
|
458
510
|
// (\w+ after the id would not match hyphenated actions like
|
|
459
|
-
// worktree-remove, hence the explicit class.)
|
|
460
|
-
const actionMatch = pathname.match(
|
|
511
|
+
// worktree-remove, hence the explicit class in the hoisted pattern.)
|
|
512
|
+
const actionMatch = pathname.match(TASK_ACTION_RE)
|
|
461
513
|
if (actionMatch !== null) {
|
|
462
514
|
const id = actionMatch[1]!
|
|
463
515
|
const action = actionMatch[2]!
|
|
@@ -467,78 +519,82 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
467
519
|
if (action === 'update') {
|
|
468
520
|
const ifVersion = num(body, 'ifVersion')
|
|
469
521
|
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const title = str(body, 'title')
|
|
473
|
-
if (title !== null) next.title = normalizeTitle(title)
|
|
474
|
-
const description = str(body, 'description')
|
|
475
|
-
if (description !== null) next.description = description.trim()
|
|
476
|
-
const prompt = str(body, 'prompt')
|
|
477
|
-
if (prompt !== null) next.prompt = normalizePrompt(prompt)
|
|
478
|
-
const urgency = str(body, 'urgency')
|
|
479
|
-
if (urgency !== null) next.urgency = asUrgency(urgency)
|
|
480
|
-
// GUI-only rebind to another project; validated against the workspace registry.
|
|
481
|
-
const workspaceId = str(body, 'workspaceId')
|
|
482
|
-
if (workspaceId !== null) {
|
|
483
|
-
if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
484
|
-
next.workspaceId = workspaceId
|
|
485
|
-
}
|
|
486
|
-
if (typeof body.blocked === 'boolean') next.blocked = body.blocked
|
|
487
|
-
// The GUI (task owner surface) may edit model/execution; null clears the model.
|
|
488
|
-
if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
|
|
489
|
-
if (body.model === null) next.model = undefined
|
|
490
|
-
else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)
|
|
491
|
-
// Isolation may change only before the first execution (分支与基线
|
|
492
|
-
// 取决于该选择 — plan §3.1: 执行开始后锁定).
|
|
493
|
-
const isolationRaw = str(body, 'isolation')
|
|
494
|
-
if (isolationRaw !== null) {
|
|
495
|
-
if (task.executions.length > 0 || task.status === 'in_progress') {
|
|
496
|
-
throw new Error('Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改')
|
|
497
|
-
}
|
|
498
|
-
next.isolation = asIsolation(isolationRaw)
|
|
499
|
-
}
|
|
500
|
-
// Preset may change any time: each run composes fresh.
|
|
501
|
-
if (body.presetId === null) delete next.presetId
|
|
502
|
-
else if (body.presetId !== undefined) next.presetId = normalizePresetId(str(body, 'presetId'))!
|
|
503
|
-
// Checklist (0.4.0): the GUI replaces the whole list; null clears.
|
|
504
|
-
if (body.checklist === null) delete next.checklist
|
|
505
|
-
else if (body.checklist !== undefined) {
|
|
506
|
-
const items = normalizeChecklist(body.checklist)
|
|
507
|
-
if (items.length > 0) next.checklist = items
|
|
508
|
-
else delete next.checklist
|
|
509
|
-
}
|
|
510
|
-
next.version = task.version + 1
|
|
511
|
-
next.updatedAt = options.now()
|
|
512
|
-
next.updatedBy = { kind: 'user' }
|
|
522
|
+
// R1: version guard + write inside the mutation, on the fresh draft.
|
|
523
|
+
let next: TaskRecord | undefined
|
|
513
524
|
await store.mutate('task-updated', ledger => {
|
|
514
|
-
const
|
|
515
|
-
|
|
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
|
|
516
571
|
return [next]
|
|
517
572
|
})
|
|
518
|
-
json(res, { ok: true, value: summarize(next) })
|
|
573
|
+
json(res, { ok: true, value: summarize(next!) })
|
|
519
574
|
return
|
|
520
575
|
}
|
|
521
576
|
if (action === 'move') {
|
|
522
577
|
const ifVersion = num(body, 'ifVersion')
|
|
523
578
|
const status = str(body, 'status') ?? ''
|
|
524
579
|
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
525
|
-
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
526
580
|
const to = asStatus(status)
|
|
527
|
-
|
|
528
|
-
const next = structuredClone(task)
|
|
529
|
-
next.status = to
|
|
530
|
-
next.version = task.version + 1
|
|
531
|
-
next.updatedAt = options.now()
|
|
532
|
-
next.updatedBy = { kind: 'user' }
|
|
533
|
-
if (task.status === 'todo' && to === 'in_progress') next.blocked = false
|
|
534
|
-
// A user move records no holder; leaving in_progress releases any hold.
|
|
535
|
-
syncClaim(next, to, options.now())
|
|
581
|
+
let next: TaskRecord | undefined
|
|
536
582
|
await store.mutate('task-moved', ledger => {
|
|
537
|
-
const
|
|
538
|
-
|
|
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
|
|
539
595
|
return [next]
|
|
540
596
|
})
|
|
541
|
-
json(res, { ok: true, value: summarize(next) })
|
|
597
|
+
json(res, { ok: true, value: summarize(next!) })
|
|
542
598
|
return
|
|
543
599
|
}
|
|
544
600
|
if (action === 'reject') {
|
|
@@ -546,36 +602,38 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
546
602
|
// atomic mutation (a failed move never strands an orphan comment).
|
|
547
603
|
const ifVersion = num(body, 'ifVersion')
|
|
548
604
|
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
549
|
-
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
550
|
-
if (!canTransition(task.status, 'todo')) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`)
|
|
551
|
-
const next = structuredClone(task)
|
|
552
|
-
next.status = 'todo'
|
|
553
|
-
next.version = task.version + 1
|
|
554
|
-
next.updatedAt = options.now()
|
|
555
|
-
next.updatedBy = { kind: 'user' }
|
|
556
|
-
syncClaim(next, 'todo', options.now())
|
|
557
605
|
const commentText = str(body, 'body') ?? ''
|
|
558
|
-
|
|
559
|
-
next.comments.push({ id: newCommentId(), body: normalizeBody(commentText), version: 1, createdAt: options.now() })
|
|
560
|
-
}
|
|
606
|
+
let next: TaskRecord | undefined
|
|
561
607
|
await store.mutate('task-moved', ledger => {
|
|
562
|
-
const
|
|
563
|
-
|
|
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
|
|
564
621
|
return [next]
|
|
565
622
|
})
|
|
566
|
-
json(res, { ok: true, value: summarize(next) })
|
|
623
|
+
json(res, { ok: true, value: summarize(next!) })
|
|
567
624
|
return
|
|
568
625
|
}
|
|
569
626
|
if (action === 'comment') {
|
|
570
627
|
const bodyText = str(body, 'body') ?? ''
|
|
571
628
|
const comment = { id: newCommentId(), body: normalizeBody(bodyText), version: 1, createdAt: options.now() }
|
|
572
|
-
const next = structuredClone(task)
|
|
573
|
-
next.comments.push(comment)
|
|
574
|
-
next.version = task.version + 1
|
|
575
|
-
next.updatedAt = options.now()
|
|
576
629
|
await store.mutate('comment-added', ledger => {
|
|
577
|
-
const
|
|
578
|
-
|
|
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
|
|
579
637
|
return [next]
|
|
580
638
|
})
|
|
581
639
|
json(res, { ok: true, value: comment }, 201)
|
|
@@ -592,19 +650,25 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
592
650
|
const ws = workspaces.get(task.workspaceId)
|
|
593
651
|
if (ws !== undefined) {
|
|
594
652
|
const path = worktreePathOf(ws.path, id)
|
|
653
|
+
if (!insideWorktreeScope(ws.path, path)) {
|
|
654
|
+
throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
|
|
655
|
+
}
|
|
595
656
|
try {
|
|
596
|
-
|
|
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
|
+
}
|
|
597
663
|
} catch (error) {
|
|
598
664
|
const message = error instanceof Error ? error.message : String(error)
|
|
599
|
-
|
|
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) {
|
|
600
669
|
throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`)
|
|
601
670
|
}
|
|
602
|
-
|
|
603
|
-
// An unregistered leftover dir: plain fs removal.
|
|
604
|
-
await rm(path, { recursive: true, force: true })
|
|
605
|
-
} else {
|
|
606
|
-
throw new Error(`Error: invalid_input: ${message}`)
|
|
607
|
-
}
|
|
671
|
+
throw new Error(`Error: invalid_input: ${message}`)
|
|
608
672
|
}
|
|
609
673
|
if (task.branch !== undefined) {
|
|
610
674
|
try {
|
|
@@ -622,13 +686,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
622
686
|
}
|
|
623
687
|
const ifVersion = num(body, 'ifVersion')
|
|
624
688
|
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
625
|
-
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
626
|
-
const next = structuredClone(task)
|
|
627
|
-
next.trashedAt = options.now()
|
|
628
|
-
next.version = task.version + 1
|
|
629
689
|
await store.mutate('task-deleted', ledger => {
|
|
630
|
-
const
|
|
631
|
-
|
|
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
|
|
632
704
|
return [next]
|
|
633
705
|
})
|
|
634
706
|
json(res, { ok: true, value: { trashed: true } })
|
|
@@ -695,13 +767,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
695
767
|
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
|
|
696
768
|
}
|
|
697
769
|
const mergedComment = { id: newCommentId(), body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`), version: 1, createdAt: options.now() }
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
next.version = task.version + 1
|
|
701
|
-
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.
|
|
702
772
|
await store.mutate('comment-added', ledger => {
|
|
703
|
-
const
|
|
704
|
-
|
|
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
|
|
705
779
|
return [next]
|
|
706
780
|
})
|
|
707
781
|
json(res, { ok: true, value: { merged: true, branch: task.branch } })
|
|
@@ -719,8 +793,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
719
793
|
const ws = workspaces.get(task.workspaceId)
|
|
720
794
|
if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
721
795
|
const path = worktreePathOf(ws.path, id)
|
|
796
|
+
if (!insideWorktreeScope(ws.path, path)) {
|
|
797
|
+
throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
|
|
798
|
+
}
|
|
722
799
|
try {
|
|
723
|
-
|
|
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
|
+
}
|
|
724
805
|
} catch (error) {
|
|
725
806
|
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
|
|
726
807
|
}
|
|
@@ -761,19 +842,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
761
842
|
// Only dirs owned by NO ledger task may be cleaned here; live tasks
|
|
762
843
|
// remove their worktree from the detail page.
|
|
763
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③).
|
|
764
848
|
const path = worktreePathOf(ws.path, taskId)
|
|
849
|
+
if (!insideWorktreeScope(ws.path, path)) {
|
|
850
|
+
throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
|
|
851
|
+
}
|
|
765
852
|
try {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
// An unregistered leftover (git no longer knows this worktree):
|
|
770
|
-
// fall back to direct fs removal — the dir lives inside the
|
|
771
|
-
// plugin's own .dsh-worktrees scope.
|
|
772
|
-
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') {
|
|
773
856
|
await rm(path, { recursive: true, force: true })
|
|
774
|
-
} else {
|
|
775
|
-
throw new Error(`Error: invalid_input: ${message}`)
|
|
776
857
|
}
|
|
858
|
+
} catch (error) {
|
|
859
|
+
throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
|
|
777
860
|
}
|
|
778
861
|
json(res, { ok: true, value: { cleaned: true, path } })
|
|
779
862
|
} catch (error) {
|
|
@@ -826,8 +909,13 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
826
909
|
backupFile = await store.backup()
|
|
827
910
|
}
|
|
828
911
|
let replacedTotal: number | undefined
|
|
829
|
-
await store.mutate('
|
|
912
|
+
await store.mutate('ledger-replaced', ledger => {
|
|
830
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
|
+
}
|
|
831
919
|
replacedTotal = ledger.tasks.length
|
|
832
920
|
ledger.tasks = structuredClone(imported)
|
|
833
921
|
// Replace is a whole-ledger swap (0.5.0): board settings ride
|
|
@@ -837,7 +925,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
837
925
|
return ledger.tasks
|
|
838
926
|
}
|
|
839
927
|
const byId = new Map(ledger.tasks.map(t => [t.id, t]))
|
|
840
|
-
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
|
+
}
|
|
841
937
|
ledger.tasks = [...byId.values()]
|
|
842
938
|
return structuredClone(imported)
|
|
843
939
|
})
|
|
@@ -878,7 +974,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
878
974
|
const template = await options.templates.upsert({
|
|
879
975
|
id: str(body, 'id') ?? undefined,
|
|
880
976
|
name,
|
|
881
|
-
task: normalizeTemplateSpec(body.task),
|
|
977
|
+
task: normalizeTemplateSpec(body.task, options.now()),
|
|
882
978
|
})
|
|
883
979
|
json(res, { ok: true, value: template }, 201)
|
|
884
980
|
} catch (error) {
|
|
@@ -924,6 +1020,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
924
1020
|
// Baseline frame: the client reconciles by revision and refetches state on gaps.
|
|
925
1021
|
res.write(`event: hello\ndata: ${JSON.stringify({ revision: store.snapshot().revision })}\n\n`)
|
|
926
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) })
|
|
927
1027
|
if (heartbeat === undefined) {
|
|
928
1028
|
heartbeat = setInterval(() => {
|
|
929
1029
|
for (const current of subscribers) current.write(': ping\n\n')
|
|
@@ -943,6 +1043,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
|
|
|
943
1043
|
ctx.webServer.register({ kind: 'exact', path: SSE_PATH, handler: sse }),
|
|
944
1044
|
]
|
|
945
1045
|
return () => {
|
|
1046
|
+
unsubscribeBroadcast()
|
|
946
1047
|
for (const dispose of disposers) dispose()
|
|
947
1048
|
if (heartbeat !== undefined) clearInterval(heartbeat)
|
|
948
1049
|
for (const res of subscribers) res.end()
|