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/tools.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
asUrgency,
|
|
30
30
|
canTransition,
|
|
31
31
|
checklistFromTexts,
|
|
32
|
+
defaultIsolationOf,
|
|
32
33
|
effectivePrompt,
|
|
33
34
|
isClaim,
|
|
34
35
|
isClaimedBy,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
syncClaim,
|
|
46
47
|
type Actor,
|
|
47
48
|
type ChecklistItem,
|
|
49
|
+
type TaskLedger,
|
|
48
50
|
type TaskModel,
|
|
49
51
|
type TaskRecord,
|
|
50
52
|
} from '../shared/protocol.ts'
|
|
@@ -123,7 +125,7 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
|
|
|
123
125
|
} else {
|
|
124
126
|
lines.push('执行记录: 无')
|
|
125
127
|
}
|
|
126
|
-
const updatedBy = t.updatedBy.kind === 'agent' ? `agent ${String(t.updatedBy.sessionId).slice(0, 24)}` : 'user'
|
|
128
|
+
const updatedBy = t.updatedBy.kind === 'agent' ? `agent ${String(t.updatedBy.sessionId).slice(0, 24)}` : t.updatedBy.kind === 'system' ? 'system' : 'user'
|
|
127
129
|
lines.push(`更新: ${new Date(t.updatedAt).toISOString()} 由 ${updatedBy}`)
|
|
128
130
|
return lines.join('\n')
|
|
129
131
|
}
|
|
@@ -139,8 +141,10 @@ export const ERR = {
|
|
|
139
141
|
invalidInput: 'invalid_input',
|
|
140
142
|
} as const
|
|
141
143
|
|
|
142
|
-
/** Tool failure: an Error whose message starts with a stable code.
|
|
143
|
-
|
|
144
|
+
/** Tool failure: an Error whose message starts with a stable code. The code
|
|
145
|
+
* is also carried structurally so the routes layer can map failures without
|
|
146
|
+
* re-parsing messages (review P2). */
|
|
147
|
+
export class ToolError extends Error {
|
|
144
148
|
constructor(readonly code: string, detail: string) {
|
|
145
149
|
super(`Error: ${code}: ${detail}`)
|
|
146
150
|
}
|
|
@@ -222,6 +226,20 @@ function versionGuard(task: TaskRecord, ifVersion: number | undefined): void {
|
|
|
222
226
|
}
|
|
223
227
|
}
|
|
224
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Find a live (non-trashed) task INSIDE a mutator (R1: every guard must run
|
|
231
|
+
* on the fresh draft the serial queue hands us, never on a pre-read clone —
|
|
232
|
+
* a pre-read can pass its version check and then blind-overwrite a task that
|
|
233
|
+
* changed while the caller awaited). Throws not_found for missing/trashed.
|
|
234
|
+
*/
|
|
235
|
+
function liveTaskAt(ledger: TaskLedger, id: string): { index: number; task: TaskRecord } {
|
|
236
|
+
const index = ledger.tasks.findIndex(t => t.id === id)
|
|
237
|
+
if (index < 0) throw new ToolError(ERR.notFound, `no task ${id}`)
|
|
238
|
+
const task = ledger.tasks[index]!
|
|
239
|
+
if (task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${id}`)
|
|
240
|
+
return { index, task }
|
|
241
|
+
}
|
|
242
|
+
|
|
225
243
|
/** Re-throw with a stable code; non-ToolErrors become invalid_input. */
|
|
226
244
|
function fail(error: unknown): never {
|
|
227
245
|
if (error instanceof ToolError) throw error
|
|
@@ -248,7 +266,7 @@ export interface ToolContextFace {
|
|
|
248
266
|
}
|
|
249
267
|
|
|
250
268
|
/**
|
|
251
|
-
* Register all
|
|
269
|
+
* Register all ten tools.
|
|
252
270
|
* @param ctx - a context exposing `tools.register`.
|
|
253
271
|
* @param deps - store + workspaces + clock.
|
|
254
272
|
* @returns dispose functions, one per tool.
|
|
@@ -373,7 +391,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
373
391
|
},
|
|
374
392
|
isolation: {
|
|
375
393
|
type: 'string',
|
|
376
|
-
description: 'Code isolation for executions: "worktree" (
|
|
394
|
+
description: 'Code isolation for executions: "worktree" (each run gets a fresh git worktree on branch task/<标题>+<taskId>) or "none" (run in the project directory, zero git interaction). Omitted → the board default (看板设置 → 默认执行隔离; factory default "none").',
|
|
377
395
|
},
|
|
378
396
|
presetId: {
|
|
379
397
|
type: 'string',
|
|
@@ -414,14 +432,20 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
414
432
|
}
|
|
415
433
|
const urgency = asUrgency(args.urgency)
|
|
416
434
|
const status = args.status === undefined ? 'todo' as const : asStatus(args.status)
|
|
417
|
-
if (status
|
|
418
|
-
throw new ToolError(ERR.invalidTransition, 'a new task
|
|
435
|
+
if (status !== 'backlog' && status !== 'todo') {
|
|
436
|
+
throw new ToolError(ERR.invalidTransition, 'a new task must start as backlog or todo (in_progress requires claiming the task)')
|
|
419
437
|
}
|
|
420
438
|
const execution = normalizeExecution(args.execution ?? {}, deps.now())
|
|
421
439
|
const model = args.model !== undefined ? checkModel(deps, args.model) : undefined
|
|
422
|
-
|
|
440
|
+
// 0.5.0: an omitted isolation is MATERIALIZED from the board setting
|
|
441
|
+
// (看板设置) at creation, so later setting changes never rewrite
|
|
442
|
+
// existing tasks.
|
|
443
|
+
const isolation = args.isolation === undefined ? defaultIsolationOf(store.snapshot().settings) : asIsolation(args.isolation)
|
|
423
444
|
const presetId = args.presetId?.trim() || undefined
|
|
424
|
-
|
|
445
|
+
// T9: match the GUI create route — trim and drop blank lines instead
|
|
446
|
+
// of failing the whole call over one empty string.
|
|
447
|
+
const checklistTexts = args.checklist?.map(c => c.trim()).filter(c => c.length > 0)
|
|
448
|
+
const checklist = checklistTexts !== undefined && checklistTexts.length > 0 ? checklistFromTexts(checklistTexts) : undefined
|
|
425
449
|
const now = deps.now()
|
|
426
450
|
const task: TaskRecord = {
|
|
427
451
|
id: newTaskId(),
|
|
@@ -434,7 +458,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
434
458
|
blocked: false,
|
|
435
459
|
execution,
|
|
436
460
|
model,
|
|
437
|
-
|
|
461
|
+
isolation,
|
|
438
462
|
...(presetId !== undefined ? { presetId } : {}),
|
|
439
463
|
...(checklist !== undefined ? { checklist } : {}),
|
|
440
464
|
version: 1,
|
|
@@ -488,25 +512,27 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
488
512
|
}, exec: unknown) {
|
|
489
513
|
try {
|
|
490
514
|
const { actor } = caller(exec as ToolRunContext)
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const next: TaskRecord = structuredClone(task)
|
|
496
|
-
if (args.title !== undefined) next.title = normalizeTitle(args.title)
|
|
497
|
-
if (args.description !== undefined) next.description = args.description.trim()
|
|
498
|
-
if (args.prompt !== undefined) next.prompt = normalizePrompt(args.prompt)
|
|
499
|
-
if (args.urgency !== undefined) next.urgency = asUrgency(args.urgency)
|
|
500
|
-
if (args.blocked !== undefined) next.blocked = args.blocked
|
|
501
|
-
next.version = task.version + 1
|
|
502
|
-
next.updatedAt = deps.now()
|
|
503
|
-
next.updatedBy = actor
|
|
515
|
+
// R1: lookup + version guard + write run inside the serial-queue
|
|
516
|
+
// mutation, on the fresh draft — a pre-read clone could pass its
|
|
517
|
+
// version check and then blind-overwrite a concurrent writer.
|
|
518
|
+
let next: TaskRecord | undefined
|
|
504
519
|
await store.mutate('task-updated', ledger => {
|
|
505
|
-
const
|
|
506
|
-
|
|
520
|
+
const { index, task } = liveTaskAt(ledger, args.id)
|
|
521
|
+
versionGuard(task, args.ifVersion)
|
|
522
|
+
if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
|
|
523
|
+
next = structuredClone(task)
|
|
524
|
+
if (args.title !== undefined) next.title = normalizeTitle(args.title)
|
|
525
|
+
if (args.description !== undefined) next.description = args.description.trim()
|
|
526
|
+
if (args.prompt !== undefined) next.prompt = normalizePrompt(args.prompt)
|
|
527
|
+
if (args.urgency !== undefined) next.urgency = asUrgency(args.urgency)
|
|
528
|
+
if (args.blocked !== undefined) next.blocked = args.blocked
|
|
529
|
+
next.version = task.version + 1
|
|
530
|
+
next.updatedAt = deps.now()
|
|
531
|
+
next.updatedBy = actor
|
|
532
|
+
ledger.tasks[index] = next
|
|
507
533
|
return [next]
|
|
508
534
|
})
|
|
509
|
-
return json({ task: summarize(next) })
|
|
535
|
+
return json({ task: summarize(next!) })
|
|
510
536
|
} catch (error) { fail(error) }
|
|
511
537
|
},
|
|
512
538
|
})) as () => void)
|
|
@@ -536,44 +562,47 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
536
562
|
try {
|
|
537
563
|
const { actor } = caller(exec as ToolRunContext)
|
|
538
564
|
const to = asStatus(args.status)
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
565
|
+
// Claim boundary (policy gate): resolving the caller's workspace is
|
|
566
|
+
// async, so it happens BEFORE the mutation — the comparison runs INSIDE
|
|
567
|
+
// against the FRESH task (stronger than the old stale-clone compare).
|
|
568
|
+
const callerWsId = to === 'in_progress'
|
|
569
|
+
? await callerWorkspace(deps, exec as ToolRunContext)
|
|
570
|
+
: undefined
|
|
571
|
+
// R1: every state guard + the write itself run inside the mutation.
|
|
572
|
+
let next: TaskRecord | undefined
|
|
573
|
+
await store.mutate('task-moved', ledger => {
|
|
574
|
+
const { index, task } = liveTaskAt(ledger, args.id)
|
|
575
|
+
versionGuard(task, args.ifVersion)
|
|
542
576
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const wsId = await callerWorkspace(deps, exec as ToolRunContext)
|
|
559
|
-
if (wsId !== task.workspaceId) {
|
|
577
|
+
// Code-level gate: agents never complete a task.
|
|
578
|
+
if (to === 'done') {
|
|
579
|
+
throw new ToolError(ERR.forbidden, 'moving a task to done requires explicit user confirmation (GUI); agents cannot do it')
|
|
580
|
+
}
|
|
581
|
+
if (!canTransition(task.status, to)) {
|
|
582
|
+
throw new ToolError(ERR.invalidTransition, `illegal transition ${task.status} → ${to}`)
|
|
583
|
+
}
|
|
584
|
+
// Exclusive hold: while a task is in_progress under a session
|
|
585
|
+
// (explicit claimedBy — an agent claim or a live execution), no other
|
|
586
|
+
// session may move it (that would be a takeover).
|
|
587
|
+
if (task.status === 'in_progress' && task.claimedBy !== undefined && task.claimedBy !== actor.sessionId) {
|
|
588
|
+
throw new ToolError(ERR.forbidden, `task is held by session ${task.claimedBy}; never take over another session's claim`)
|
|
589
|
+
}
|
|
590
|
+
// Claim boundary: the calling session must belong to the task's project.
|
|
591
|
+
if (isClaim(task.status, to) && callerWsId !== task.workspaceId) {
|
|
560
592
|
throw new ToolError(ERR.workspaceMismatch, 'only a session inside this task\'s project may claim it')
|
|
561
593
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
await store.mutate('task-moved', ledger => {
|
|
572
|
-
const i = ledger.tasks.findIndex(t => t.id === args.id)
|
|
573
|
-
ledger.tasks[i] = next
|
|
594
|
+
next = structuredClone(task)
|
|
595
|
+
next.status = to
|
|
596
|
+
next.version = task.version + 1
|
|
597
|
+
next.updatedAt = deps.now()
|
|
598
|
+
next.updatedBy = actor
|
|
599
|
+
if (isClaim(task.status, to)) next.blocked = false
|
|
600
|
+
// Record the holder on a claim; every move out of in_progress releases it.
|
|
601
|
+
syncClaim(next, to, deps.now(), isClaim(task.status, to) ? actor.sessionId : undefined)
|
|
602
|
+
ledger.tasks[index] = next
|
|
574
603
|
return [next]
|
|
575
604
|
})
|
|
576
|
-
return json({ task: summarize(next) })
|
|
605
|
+
return json({ task: summarize(next!) })
|
|
577
606
|
} catch (error) { fail(error) }
|
|
578
607
|
},
|
|
579
608
|
})) as () => void)
|
|
@@ -606,8 +635,6 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
606
635
|
async execute(args: { id: string; body: string }, exec: unknown) {
|
|
607
636
|
try {
|
|
608
637
|
const { sessionId } = caller(exec as ToolRunContext)
|
|
609
|
-
const task = store.get(args.id)
|
|
610
|
-
if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
|
|
611
638
|
const comment = {
|
|
612
639
|
id: newCommentId(),
|
|
613
640
|
body: normalizeBody(args.body),
|
|
@@ -615,16 +642,21 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
615
642
|
createdAt: deps.now(),
|
|
616
643
|
threadId: sessionId,
|
|
617
644
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
next
|
|
645
|
+
// R1: find + append inside the mutation (no ifVersion by design —
|
|
646
|
+
// comments are append-only — but the write must not clobber a task
|
|
647
|
+
// that changed while we were queued).
|
|
648
|
+
let next: TaskRecord | undefined
|
|
622
649
|
await store.mutate('comment-added', ledger => {
|
|
623
|
-
const
|
|
624
|
-
|
|
650
|
+
const { index, task } = liveTaskAt(ledger, args.id)
|
|
651
|
+
if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
|
|
652
|
+
next = structuredClone(task)
|
|
653
|
+
next.comments.push(comment)
|
|
654
|
+
next.version = task.version + 1
|
|
655
|
+
next.updatedAt = deps.now()
|
|
656
|
+
ledger.tasks[index] = next
|
|
625
657
|
return [next]
|
|
626
658
|
})
|
|
627
|
-
return json({ comment, task: { id: next
|
|
659
|
+
return json({ comment, task: { id: next!.id, version: next!.version, status: next!.status } })
|
|
628
660
|
} catch (error) { fail(error) }
|
|
629
661
|
},
|
|
630
662
|
})) as () => void)
|
|
@@ -678,15 +710,23 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
678
710
|
async execute(args: { id: string; ifVersion: number }, exec: unknown) {
|
|
679
711
|
try {
|
|
680
712
|
caller(exec as ToolRunContext)
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
next.trashedAt = deps.now()
|
|
686
|
-
next.version = task.version + 1
|
|
713
|
+
// R1: guards inside the mutation. S5: a running execution keeps
|
|
714
|
+
// writing to the task (report, settlement) — refuse the soft-delete
|
|
715
|
+
// until it is cancelled or settled. T8: clear claim residue.
|
|
716
|
+
let next: TaskRecord | undefined
|
|
687
717
|
await store.mutate('task-deleted', ledger => {
|
|
688
|
-
const
|
|
689
|
-
|
|
718
|
+
const { index, task } = liveTaskAt(ledger, args.id)
|
|
719
|
+
versionGuard(task, args.ifVersion)
|
|
720
|
+
if (task.executions.some(e => e.outcome === 'running')) {
|
|
721
|
+
throw new ToolError(ERR.invalidInput, '任务有正在运行的执行(先在 GUI 取消或等它结束再删除)')
|
|
722
|
+
}
|
|
723
|
+
next = structuredClone(task)
|
|
724
|
+
next.trashedAt = deps.now()
|
|
725
|
+
next.version = task.version + 1
|
|
726
|
+
delete next.claimedBy
|
|
727
|
+
delete next.claimedAt
|
|
728
|
+
next.blocked = false
|
|
729
|
+
ledger.tasks[index] = next
|
|
690
730
|
return [next]
|
|
691
731
|
})
|
|
692
732
|
return { trashed: true }
|
|
@@ -735,57 +775,57 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
735
775
|
}, exec: unknown) {
|
|
736
776
|
try {
|
|
737
777
|
const { actor } = caller(exec as ToolRunContext)
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
if (
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
778
|
+
// R1: guards + the checklist edit itself run inside the mutation.
|
|
779
|
+
let next: TaskRecord | undefined
|
|
780
|
+
await store.mutate('task-updated', ledger => {
|
|
781
|
+
const { index, task } = liveTaskAt(ledger, args.id)
|
|
782
|
+
versionGuard(task, args.ifVersion)
|
|
783
|
+
if (task.status === 'archived') throw new ToolError(ERR.invalidTransition, 'archived tasks are immutable')
|
|
784
|
+
next = structuredClone(task)
|
|
785
|
+
const checklist: ChecklistItem[] = next.checklist === undefined ? [] : [...next.checklist]
|
|
786
|
+
|
|
787
|
+
if (args.action === 'add') {
|
|
788
|
+
const texts = args.items ?? []
|
|
789
|
+
if (texts.length === 0 || texts.length > 10) {
|
|
790
|
+
throw new ToolError(ERR.invalidInput, 'items must carry 1..10 texts per add call')
|
|
791
|
+
}
|
|
792
|
+
if (checklist.length + texts.length > MAX_CHECKLIST_ITEMS) {
|
|
793
|
+
throw new ToolError(ERR.invalidInput, `checklist may hold at most ${MAX_CHECKLIST_ITEMS} items (currently ${checklist.length})`)
|
|
794
|
+
}
|
|
795
|
+
checklist.push(...checklistFromTexts(texts))
|
|
796
|
+
} else if (args.action === 'check') {
|
|
797
|
+
if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for check')
|
|
798
|
+
const item = checklist.find(i => i.id === args.itemId)
|
|
799
|
+
if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
|
|
800
|
+
const note = args.note !== undefined && args.note.trim().length > 0 ? args.note.trim().slice(0, 400) : undefined
|
|
801
|
+
item.checked = true
|
|
802
|
+
item.checkedBy = actor.sessionId
|
|
803
|
+
item.checkedAt = deps.now()
|
|
804
|
+
if (note !== undefined) item.note = note
|
|
805
|
+
} else if (args.action === 'uncheck') {
|
|
806
|
+
if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for uncheck')
|
|
807
|
+
const item = checklist.find(i => i.id === args.itemId)
|
|
808
|
+
if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
|
|
809
|
+
item.checked = false
|
|
810
|
+
delete item.checkedBy
|
|
811
|
+
delete item.checkedAt
|
|
812
|
+
delete item.note
|
|
813
|
+
} else {
|
|
814
|
+
throw new ToolError(ERR.invalidInput, `action must be add | check | uncheck (got "${args.action}")`)
|
|
752
815
|
}
|
|
753
|
-
checklist.push(...checklistFromTexts(texts))
|
|
754
|
-
} else if (args.action === 'check') {
|
|
755
|
-
if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for check')
|
|
756
|
-
const item = checklist.find(i => i.id === args.itemId)
|
|
757
|
-
if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
|
|
758
|
-
const note = args.note !== undefined && args.note.trim().length > 0 ? args.note.trim().slice(0, 400) : undefined
|
|
759
|
-
item.checked = true
|
|
760
|
-
item.checkedBy = actor.sessionId
|
|
761
|
-
item.checkedAt = deps.now()
|
|
762
|
-
if (note !== undefined) item.note = note
|
|
763
|
-
} else if (args.action === 'uncheck') {
|
|
764
|
-
if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for uncheck')
|
|
765
|
-
const item = checklist.find(i => i.id === args.itemId)
|
|
766
|
-
if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
|
|
767
|
-
item.checked = false
|
|
768
|
-
delete item.checkedBy
|
|
769
|
-
delete item.checkedAt
|
|
770
|
-
delete item.note
|
|
771
|
-
} else {
|
|
772
|
-
throw new ToolError(ERR.invalidInput, `action must be add | check | uncheck (got "${args.action}")`)
|
|
773
|
-
}
|
|
774
816
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const i = ledger.tasks.findIndex(t => t.id === args.id)
|
|
782
|
-
ledger.tasks[i] = next
|
|
817
|
+
if (checklist.length > 0) next.checklist = checklist
|
|
818
|
+
else delete next.checklist
|
|
819
|
+
next.version = task.version + 1
|
|
820
|
+
next.updatedAt = deps.now()
|
|
821
|
+
next.updatedBy = actor
|
|
822
|
+
ledger.tasks[index] = next
|
|
783
823
|
return [next]
|
|
784
824
|
})
|
|
785
|
-
const progress = next
|
|
786
|
-
? { done: next
|
|
825
|
+
const progress = next!.checklist !== undefined
|
|
826
|
+
? { done: next!.checklist.filter(i => i.checked).length, total: next!.checklist.length }
|
|
787
827
|
: { done: 0, total: 0 }
|
|
788
|
-
return json({ task: { id: next
|
|
828
|
+
return json({ task: { id: next!.id, version: next!.version }, checklist: next!.checklist ?? [], ...progress })
|
|
789
829
|
} catch (error) { fail(error) }
|
|
790
830
|
},
|
|
791
831
|
})) as () => void)
|
|
@@ -796,7 +836,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
796
836
|
description:
|
|
797
837
|
'Submit the structured execution report for the task you are currently executing (summary / changed '
|
|
798
838
|
+ 'files / how you verified / artifacts / remaining risk). Submit BEFORE moving the task to in_review; '
|
|
799
|
-
+ 'a later submission overwrites the previous report.
|
|
839
|
+
+ 'a later submission overwrites the previous report. If your run already settled, you may back-submit '
|
|
840
|
+
+ 'onto your latest succeeded execution while you still hold the task. Commits and diffs are host-collected.',
|
|
800
841
|
parameters: {
|
|
801
842
|
summary: { type: 'string', required: true, description: 'What was done (1..2000 chars).' },
|
|
802
843
|
changedFiles: {
|
|
@@ -838,8 +879,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
838
879
|
try {
|
|
839
880
|
const { sessionId } = caller(exec as ToolRunContext)
|
|
840
881
|
const report = normalizeExecutionReport(args)
|
|
841
|
-
//
|
|
842
|
-
// the live run, so the agent never needs
|
|
882
|
+
// Path 1 (unchanged): attach to the RUNNING execution this session
|
|
883
|
+
// owns — reports ride the live run, so the agent never needs ids.
|
|
843
884
|
let taskId: string | undefined
|
|
844
885
|
let executionId: string | undefined
|
|
845
886
|
await store.mutate('execution-recorded', ledger => {
|
|
@@ -854,8 +895,28 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
854
895
|
}
|
|
855
896
|
return undefined
|
|
856
897
|
})
|
|
898
|
+
// Path 2 (review follow-up, P2): back-submit onto a session-owned
|
|
899
|
+
// SETTLED execution — the main conversation claims tasks directly and
|
|
900
|
+
// has no live run. Allowed when the session holds the task or owns
|
|
901
|
+
// its latest successful execution; never touches anyone else's runs.
|
|
902
|
+
if (taskId === undefined || executionId === undefined) {
|
|
903
|
+
await store.mutate('execution-recorded', ledger => {
|
|
904
|
+
for (const task of ledger.tasks) {
|
|
905
|
+
if (task.trashedAt !== undefined || task.status === 'archived') continue
|
|
906
|
+
const last = task.executions[task.executions.length - 1]
|
|
907
|
+
const owned = last !== undefined && last.sessionId === sessionId && last.outcome === 'succeeded'
|
|
908
|
+
const holds = task.claimedBy === sessionId && last !== undefined && last.sessionId === sessionId
|
|
909
|
+
if (!owned && !holds) continue
|
|
910
|
+
last!.report = report
|
|
911
|
+
taskId = task.id
|
|
912
|
+
executionId = last!.id
|
|
913
|
+
return [task]
|
|
914
|
+
}
|
|
915
|
+
return undefined
|
|
916
|
+
})
|
|
917
|
+
}
|
|
857
918
|
if (taskId === undefined || executionId === undefined) {
|
|
858
|
-
throw new ToolError(ERR.forbidden, 'no running execution
|
|
919
|
+
throw new ToolError(ERR.forbidden, 'no running execution and no settled execution of yours to report on — reports attach to your running execution, or back-submit onto your latest succeeded one while you hold the task')
|
|
859
920
|
}
|
|
860
921
|
return json({ taskId, executionId, report })
|
|
861
922
|
} catch (error) { fail(error) }
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Host loader entry for dsh-taskboard.
|
|
3
3
|
*
|
|
4
|
-
* Wiring: the ledger store (one JSON file under the DSH home), the
|
|
4
|
+
* Wiring: the ledger store (one JSON file under the DSH home), the ten
|
|
5
5
|
* `taskboard_*` agent tools, the agent workflow-protocol system-prompt
|
|
6
6
|
* section, the /taskboard JSON+SSE routes (when a webServer is served),
|
|
7
7
|
* the host execution service (fresh in-project sessions, pinned models), and
|
|
@@ -48,6 +48,12 @@ export const inject = ['tools', 'systemPrompt']
|
|
|
48
48
|
export function apply(ctx: Context): void {
|
|
49
49
|
const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })
|
|
50
50
|
const templates = new TemplateStore(dshHomePath(TEMPLATES_FILE))
|
|
51
|
+
// Eager first load: the tools and most routes read snapshot()/get() without
|
|
52
|
+
// triggering the lazy load, so a fresh boot used to serve an EMPTY board to
|
|
53
|
+
// taskboard_list/get until the scheduler catchup tick or the first
|
|
54
|
+
// GET /state happened to load the file (review P0). load() never throws —
|
|
55
|
+
// a corrupt ledger is quarantined instead.
|
|
56
|
+
void store.load()
|
|
51
57
|
const now = () => Date.now()
|
|
52
58
|
// Global execution concurrency cap (DSH_TASKBOARD_MAX_CONCURRENT overrides).
|
|
53
59
|
const maxConcurrent = Math.max(1, Number.parseInt(process.env.DSH_TASKBOARD_MAX_CONCURRENT ?? '', 10) || DEFAULT_MAX_CONCURRENT)
|
|
@@ -173,6 +179,9 @@ export function apply(ctx: Context): void {
|
|
|
173
179
|
const scheduler = new SchedulerService({ store, execution, now, maxConcurrent })
|
|
174
180
|
scheduler.start()
|
|
175
181
|
disposers.push(() => scheduler.dispose())
|
|
182
|
+
// Detach the settlement listener with the plugin — a hot reload must
|
|
183
|
+
// not leave stale services reacting to turn/end errors (review P1).
|
|
184
|
+
disposers.push(() => execution.dispose())
|
|
176
185
|
|
|
177
186
|
return () => {
|
|
178
187
|
disposeRoutes?.()
|
package/src/shared/api.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @module dsh-taskboard/shared/api
|
|
7
7
|
*/
|
|
8
|
-
import type { TaskLedger, TaskRecord, TaskSummary } from './protocol.ts'
|
|
8
|
+
import type { BoardSettings, TaskLedger, TaskRecord, TaskSummary } from './protocol.ts'
|
|
9
9
|
|
|
10
10
|
export type { TaskRecord }
|
|
11
11
|
|
|
@@ -151,6 +151,15 @@ export type TaskTemplate = {
|
|
|
151
151
|
/** Templates listing response. */
|
|
152
152
|
export type TemplatesResponse = { templates: TaskTemplate[] }
|
|
153
153
|
|
|
154
|
+
/** Board-settings response (0.5.0; absent fields follow factory defaults). */
|
|
155
|
+
export type SettingsResponse = BoardSettings
|
|
156
|
+
|
|
157
|
+
/** Update-board-settings request body (0.5.0; whole-object replace semantics). */
|
|
158
|
+
export type UpdateSettingsBody = {
|
|
159
|
+
/** Default code isolation for NEW tasks ('worktree' | 'none'). */
|
|
160
|
+
defaultIsolation?: string
|
|
161
|
+
}
|
|
162
|
+
|
|
154
163
|
/** Import dry-run response (0.4.0): every task classified, nothing written. */
|
|
155
164
|
export type ImportPreviewResponse = {
|
|
156
165
|
plan: {
|
|
@@ -186,6 +195,6 @@ export type SummaryResponse = { tasks: TaskSummary[] }
|
|
|
186
195
|
/** Change frame pushed on every committed ledger mutation. */
|
|
187
196
|
export type ChangeEvent = {
|
|
188
197
|
revision: number
|
|
189
|
-
kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded'
|
|
198
|
+
kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'
|
|
190
199
|
tasks: TaskSummary[]
|
|
191
200
|
}
|