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/tools.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
syncClaim,
|
|
47
47
|
type Actor,
|
|
48
48
|
type ChecklistItem,
|
|
49
|
+
type TaskLedger,
|
|
49
50
|
type TaskModel,
|
|
50
51
|
type TaskRecord,
|
|
51
52
|
} from '../shared/protocol.ts'
|
|
@@ -124,7 +125,7 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
|
|
|
124
125
|
} else {
|
|
125
126
|
lines.push('执行记录: 无')
|
|
126
127
|
}
|
|
127
|
-
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'
|
|
128
129
|
lines.push(`更新: ${new Date(t.updatedAt).toISOString()} 由 ${updatedBy}`)
|
|
129
130
|
return lines.join('\n')
|
|
130
131
|
}
|
|
@@ -140,8 +141,10 @@ export const ERR = {
|
|
|
140
141
|
invalidInput: 'invalid_input',
|
|
141
142
|
} as const
|
|
142
143
|
|
|
143
|
-
/** Tool failure: an Error whose message starts with a stable code.
|
|
144
|
-
|
|
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 {
|
|
145
148
|
constructor(readonly code: string, detail: string) {
|
|
146
149
|
super(`Error: ${code}: ${detail}`)
|
|
147
150
|
}
|
|
@@ -223,6 +226,20 @@ function versionGuard(task: TaskRecord, ifVersion: number | undefined): void {
|
|
|
223
226
|
}
|
|
224
227
|
}
|
|
225
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
|
+
|
|
226
243
|
/** Re-throw with a stable code; non-ToolErrors become invalid_input. */
|
|
227
244
|
function fail(error: unknown): never {
|
|
228
245
|
if (error instanceof ToolError) throw error
|
|
@@ -249,7 +266,7 @@ export interface ToolContextFace {
|
|
|
249
266
|
}
|
|
250
267
|
|
|
251
268
|
/**
|
|
252
|
-
* Register all
|
|
269
|
+
* Register all ten tools.
|
|
253
270
|
* @param ctx - a context exposing `tools.register`.
|
|
254
271
|
* @param deps - store + workspaces + clock.
|
|
255
272
|
* @returns dispose functions, one per tool.
|
|
@@ -415,8 +432,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
415
432
|
}
|
|
416
433
|
const urgency = asUrgency(args.urgency)
|
|
417
434
|
const status = args.status === undefined ? 'todo' as const : asStatus(args.status)
|
|
418
|
-
if (status
|
|
419
|
-
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)')
|
|
420
437
|
}
|
|
421
438
|
const execution = normalizeExecution(args.execution ?? {}, deps.now())
|
|
422
439
|
const model = args.model !== undefined ? checkModel(deps, args.model) : undefined
|
|
@@ -425,7 +442,10 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
425
442
|
// existing tasks.
|
|
426
443
|
const isolation = args.isolation === undefined ? defaultIsolationOf(store.snapshot().settings) : asIsolation(args.isolation)
|
|
427
444
|
const presetId = args.presetId?.trim() || undefined
|
|
428
|
-
|
|
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
|
|
429
449
|
const now = deps.now()
|
|
430
450
|
const task: TaskRecord = {
|
|
431
451
|
id: newTaskId(),
|
|
@@ -492,25 +512,27 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
492
512
|
}, exec: unknown) {
|
|
493
513
|
try {
|
|
494
514
|
const { actor } = caller(exec as ToolRunContext)
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const next: TaskRecord = structuredClone(task)
|
|
500
|
-
if (args.title !== undefined) next.title = normalizeTitle(args.title)
|
|
501
|
-
if (args.description !== undefined) next.description = args.description.trim()
|
|
502
|
-
if (args.prompt !== undefined) next.prompt = normalizePrompt(args.prompt)
|
|
503
|
-
if (args.urgency !== undefined) next.urgency = asUrgency(args.urgency)
|
|
504
|
-
if (args.blocked !== undefined) next.blocked = args.blocked
|
|
505
|
-
next.version = task.version + 1
|
|
506
|
-
next.updatedAt = deps.now()
|
|
507
|
-
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
|
|
508
519
|
await store.mutate('task-updated', ledger => {
|
|
509
|
-
const
|
|
510
|
-
|
|
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
|
|
511
533
|
return [next]
|
|
512
534
|
})
|
|
513
|
-
return json({ task: summarize(next) })
|
|
535
|
+
return json({ task: summarize(next!) })
|
|
514
536
|
} catch (error) { fail(error) }
|
|
515
537
|
},
|
|
516
538
|
})) as () => void)
|
|
@@ -540,44 +562,47 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
540
562
|
try {
|
|
541
563
|
const { actor } = caller(exec as ToolRunContext)
|
|
542
564
|
const to = asStatus(args.status)
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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)
|
|
546
576
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const wsId = await callerWorkspace(deps, exec as ToolRunContext)
|
|
563
|
-
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) {
|
|
564
592
|
throw new ToolError(ERR.workspaceMismatch, 'only a session inside this task\'s project may claim it')
|
|
565
593
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
await store.mutate('task-moved', ledger => {
|
|
576
|
-
const i = ledger.tasks.findIndex(t => t.id === args.id)
|
|
577
|
-
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
|
|
578
603
|
return [next]
|
|
579
604
|
})
|
|
580
|
-
return json({ task: summarize(next) })
|
|
605
|
+
return json({ task: summarize(next!) })
|
|
581
606
|
} catch (error) { fail(error) }
|
|
582
607
|
},
|
|
583
608
|
})) as () => void)
|
|
@@ -610,8 +635,6 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
610
635
|
async execute(args: { id: string; body: string }, exec: unknown) {
|
|
611
636
|
try {
|
|
612
637
|
const { sessionId } = caller(exec as ToolRunContext)
|
|
613
|
-
const task = store.get(args.id)
|
|
614
|
-
if (task === undefined || task.trashedAt !== undefined) throw new ToolError(ERR.notFound, `no task ${args.id}`)
|
|
615
638
|
const comment = {
|
|
616
639
|
id: newCommentId(),
|
|
617
640
|
body: normalizeBody(args.body),
|
|
@@ -619,16 +642,21 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
619
642
|
createdAt: deps.now(),
|
|
620
643
|
threadId: sessionId,
|
|
621
644
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
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
|
|
626
649
|
await store.mutate('comment-added', ledger => {
|
|
627
|
-
const
|
|
628
|
-
|
|
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
|
|
629
657
|
return [next]
|
|
630
658
|
})
|
|
631
|
-
return json({ comment, task: { id: next
|
|
659
|
+
return json({ comment, task: { id: next!.id, version: next!.version, status: next!.status } })
|
|
632
660
|
} catch (error) { fail(error) }
|
|
633
661
|
},
|
|
634
662
|
})) as () => void)
|
|
@@ -682,15 +710,23 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
682
710
|
async execute(args: { id: string; ifVersion: number }, exec: unknown) {
|
|
683
711
|
try {
|
|
684
712
|
caller(exec as ToolRunContext)
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
next.trashedAt = deps.now()
|
|
690
|
-
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
|
|
691
717
|
await store.mutate('task-deleted', ledger => {
|
|
692
|
-
const
|
|
693
|
-
|
|
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
|
|
694
730
|
return [next]
|
|
695
731
|
})
|
|
696
732
|
return { trashed: true }
|
|
@@ -739,57 +775,57 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
739
775
|
}, exec: unknown) {
|
|
740
776
|
try {
|
|
741
777
|
const { actor } = caller(exec as ToolRunContext)
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
if (
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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}")`)
|
|
756
815
|
}
|
|
757
|
-
checklist.push(...checklistFromTexts(texts))
|
|
758
|
-
} else if (args.action === 'check') {
|
|
759
|
-
if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for check')
|
|
760
|
-
const item = checklist.find(i => i.id === args.itemId)
|
|
761
|
-
if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
|
|
762
|
-
const note = args.note !== undefined && args.note.trim().length > 0 ? args.note.trim().slice(0, 400) : undefined
|
|
763
|
-
item.checked = true
|
|
764
|
-
item.checkedBy = actor.sessionId
|
|
765
|
-
item.checkedAt = deps.now()
|
|
766
|
-
if (note !== undefined) item.note = note
|
|
767
|
-
} else if (args.action === 'uncheck') {
|
|
768
|
-
if (args.itemId === undefined) throw new ToolError(ERR.invalidInput, 'itemId is required for uncheck')
|
|
769
|
-
const item = checklist.find(i => i.id === args.itemId)
|
|
770
|
-
if (item === undefined) throw new ToolError(ERR.notFound, `no checklist item ${args.itemId} (task ${args.id})`)
|
|
771
|
-
item.checked = false
|
|
772
|
-
delete item.checkedBy
|
|
773
|
-
delete item.checkedAt
|
|
774
|
-
delete item.note
|
|
775
|
-
} else {
|
|
776
|
-
throw new ToolError(ERR.invalidInput, `action must be add | check | uncheck (got "${args.action}")`)
|
|
777
|
-
}
|
|
778
816
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
const i = ledger.tasks.findIndex(t => t.id === args.id)
|
|
786
|
-
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
|
|
787
823
|
return [next]
|
|
788
824
|
})
|
|
789
|
-
const progress = next
|
|
790
|
-
? { done: next
|
|
825
|
+
const progress = next!.checklist !== undefined
|
|
826
|
+
? { done: next!.checklist.filter(i => i.checked).length, total: next!.checklist.length }
|
|
791
827
|
: { done: 0, total: 0 }
|
|
792
|
-
return json({ task: { id: next
|
|
828
|
+
return json({ task: { id: next!.id, version: next!.version }, checklist: next!.checklist ?? [], ...progress })
|
|
793
829
|
} catch (error) { fail(error) }
|
|
794
830
|
},
|
|
795
831
|
})) as () => void)
|
|
@@ -800,7 +836,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
800
836
|
description:
|
|
801
837
|
'Submit the structured execution report for the task you are currently executing (summary / changed '
|
|
802
838
|
+ 'files / how you verified / artifacts / remaining risk). Submit BEFORE moving the task to in_review; '
|
|
803
|
-
+ '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.',
|
|
804
841
|
parameters: {
|
|
805
842
|
summary: { type: 'string', required: true, description: 'What was done (1..2000 chars).' },
|
|
806
843
|
changedFiles: {
|
|
@@ -842,8 +879,8 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
842
879
|
try {
|
|
843
880
|
const { sessionId } = caller(exec as ToolRunContext)
|
|
844
881
|
const report = normalizeExecutionReport(args)
|
|
845
|
-
//
|
|
846
|
-
// 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.
|
|
847
884
|
let taskId: string | undefined
|
|
848
885
|
let executionId: string | undefined
|
|
849
886
|
await store.mutate('execution-recorded', ledger => {
|
|
@@ -858,8 +895,28 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
|
|
|
858
895
|
}
|
|
859
896
|
return undefined
|
|
860
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
|
+
}
|
|
861
918
|
if (taskId === undefined || executionId === undefined) {
|
|
862
|
-
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')
|
|
863
920
|
}
|
|
864
921
|
return json({ taskId, executionId, report })
|
|
865
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
|
@@ -195,6 +195,6 @@ export type SummaryResponse = { tasks: TaskSummary[] }
|
|
|
195
195
|
/** Change frame pushed on every committed ledger mutation. */
|
|
196
196
|
export type ChangeEvent = {
|
|
197
197
|
revision: number
|
|
198
|
-
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'
|
|
199
199
|
tasks: TaskSummary[]
|
|
200
200
|
}
|
package/src/shared/protocol.ts
CHANGED
|
@@ -286,6 +286,7 @@ export function nextCronTime(match: CronMatch, from: number): number | null {
|
|
|
286
286
|
export type Actor =
|
|
287
287
|
| { kind: 'user' }
|
|
288
288
|
| { kind: 'agent'; sessionId: string }
|
|
289
|
+
| { kind: 'system' }
|
|
289
290
|
|
|
290
291
|
/** A progress/report comment on a task. */
|
|
291
292
|
export type CommentRecord = {
|
|
@@ -479,6 +480,16 @@ function suffix(): string {
|
|
|
479
480
|
return Math.random().toString(36).slice(2, 8)
|
|
480
481
|
}
|
|
481
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Legal task id charset (R4): `t-<base36>-<base36>` from {@link newTaskId},
|
|
485
|
+
* and the ONLY shape accepted from the outside (import) or used to build
|
|
486
|
+
* filesystem paths (worktree dirs). Ids ride into `join(ws, '.dsh-worktrees',
|
|
487
|
+
* id)` — a lax charset here is an arbitrary-directory delete primitive.
|
|
488
|
+
*/
|
|
489
|
+
export function isValidTaskId(id: string): boolean {
|
|
490
|
+
return /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(id)
|
|
491
|
+
}
|
|
492
|
+
|
|
482
493
|
/** Mint a task id. */
|
|
483
494
|
export function newTaskId(): string {
|
|
484
495
|
return `t-${Date.now().toString(36)}-${suffix()}`
|
|
@@ -811,7 +822,10 @@ export function validateImportedTask(raw: unknown, now: number): { ok: true; tas
|
|
|
811
822
|
const e = raw as Record<string, unknown>
|
|
812
823
|
const id = typeof e.id === 'string' ? e.id.trim() : ''
|
|
813
824
|
const fail = (reason: string): { ok: false; reason: string } => ({ ok: false, reason })
|
|
814
|
-
|
|
825
|
+
// R4①: length alone let traversal-shaped ids (`../../x`, `..\..\x`) into
|
|
826
|
+
// the ledger; the charset gate is the primary defense for every downstream
|
|
827
|
+
// filesystem use of a task id.
|
|
828
|
+
if (!isValidTaskId(id)) return fail('missing/invalid id (must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$)')
|
|
815
829
|
try {
|
|
816
830
|
const execution = normalizeExecution(
|
|
817
831
|
typeof e.execution === 'object' && e.execution !== null ? e.execution as { mode?: string; cron?: string } : {},
|
|
@@ -909,6 +923,26 @@ export function validateImportedTask(raw: unknown, now: number): { ok: true; tas
|
|
|
909
923
|
}
|
|
910
924
|
}
|
|
911
925
|
|
|
926
|
+
/**
|
|
927
|
+
* Minimal structural check for ONE ledger record at load time (S11): unlike
|
|
928
|
+
* {@link validateImportedTask} this REBUILDS NOTHING (cron state, ids and
|
|
929
|
+
* timestamps must survive a load untouched) — it only rejects entries whose
|
|
930
|
+
* shape would break downstream consumers, including the R4 id charset.
|
|
931
|
+
* @param raw - the untyped record.
|
|
932
|
+
*/
|
|
933
|
+
export function isPlausibleTaskRecord(raw: unknown): boolean {
|
|
934
|
+
if (typeof raw !== 'object' || raw === null) return false
|
|
935
|
+
const t = raw as Record<string, unknown>
|
|
936
|
+
return typeof t.id === 'string' && isValidTaskId(t.id)
|
|
937
|
+
&& typeof t.title === 'string' && t.title.length > 0
|
|
938
|
+
&& typeof t.workspaceId === 'string' && t.workspaceId.length > 0
|
|
939
|
+
&& ALL_STATUSES.includes(t.status as TaskStatus)
|
|
940
|
+
&& typeof t.version === 'number' && Number.isFinite(t.version) && t.version >= 1
|
|
941
|
+
&& Array.isArray(t.comments) && Array.isArray(t.executions)
|
|
942
|
+
&& typeof t.execution === 'object' && t.execution !== null
|
|
943
|
+
&& (t.execution as { mode?: unknown }).mode !== undefined
|
|
944
|
+
}
|
|
945
|
+
|
|
912
946
|
/**
|
|
913
947
|
* Validate a whole imported ledger and classify its tasks against the live
|
|
914
948
|
* one (pure). Duplicate ids INSIDE the file are invalid (first wins, later
|
package/src/shared/version.ts
CHANGED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Compatibility shim: the composer moved into TaskFormModal (create + edit
|
|
3
|
-
* in one dialog). Kept so existing imports keep working.
|
|
4
|
-
*
|
|
5
|
-
* @module dsh-taskboard/client/board/NewTaskModal
|
|
6
|
-
*/
|
|
7
|
-
export { TaskFormModal as NewTaskModal } from './TaskFormModal.tsx'
|
|
8
|
-
export type { CatalogModel } from './TaskFormModal.tsx'
|