@linxin666/dsh-client-ui-task-board 0.4.0 → 0.4.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.i18n.yaml +2 -2
- package/README.md +24 -0
- package/README.zh.md +24 -0
- package/lib/client.js +917 -81
- package/lib/client.js.map +1 -1
- package/lib/index.js +1774 -1420
- package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
- package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
- package/lib/types/client/board/NewTaskModal.d.ts +7 -1
- package/lib/types/client/board/TaskCard.d.ts +11 -1
- package/lib/types/client/locales.d.ts +33 -0
- package/lib/types/core/controller.d.ts +30 -1
- package/lib/types/core/subtask.d.ts +122 -0
- package/lib/types/core/tasks.d.ts +46 -3
- package/lib/types/core/use-cases/task-archive.d.ts +10 -13
- package/lib/types/core/use-cases/task-create.d.ts +13 -4
- package/lib/types/core/use-cases/task-delete.d.ts +7 -1
- package/lib/types/core/use-cases/task-parent.d.ts +21 -0
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host/agent-tools.d.ts +27 -0
- package/lib/types/host-ledger.d.ts +88 -4
- package/lib/types/host-runner.d.ts +39 -1
- package/lib/types/host-service.d.ts +47 -0
- package/lib/types/index.d.ts +83 -5
- package/lib/types/protocol.d.ts +14 -0
- package/package.json +23 -21
- package/src/client/TaskBoardSettingsCard.tsx +48 -3
- package/src/client/board/LinkSubtaskModal.tsx +82 -0
- package/src/client/board/NewTaskModal.tsx +40 -6
- package/src/client/board/TaskBoard.tsx +88 -8
- package/src/client/board/TaskCard.tsx +52 -2
- package/src/client/board/TaskDetail.tsx +124 -1
- package/src/client/board.module.css +96 -0
- package/src/client/host-api.ts +14 -4
- package/src/client/index.ts +11 -1
- package/src/client/locales.ts +70 -2
- package/src/core/controller.ts +69 -8
- package/src/core/store.ts +15 -0
- package/src/core/subtask.ts +286 -0
- package/src/core/tasks.ts +50 -2
- package/src/core/use-cases/task-archive.ts +46 -12
- package/src/core/use-cases/task-create.ts +42 -7
- package/src/core/use-cases/task-delete.ts +11 -1
- package/src/core/use-cases/task-parent.ts +64 -0
- package/src/core/use-cases/task-update.ts +3 -1
- package/src/host/agent-tools.ts +643 -0
- package/src/host-ledger.ts +323 -45
- package/src/host-runner.ts +141 -12
- package/src/host-service.ts +127 -10
- package/src/index.ts +181 -9
- package/src/protocol.ts +26 -2
package/src/host-ledger.ts
CHANGED
|
@@ -5,11 +5,21 @@ import { dirname, join } from 'node:path'
|
|
|
5
5
|
import { dshHome } from './dsh-home.ts'
|
|
6
6
|
import { isValidCron, nextRunAtMs } from './core/schedule.ts'
|
|
7
7
|
import { isTaskRecord, parseLedger } from './core/store.ts'
|
|
8
|
-
import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
8
|
+
import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_SUBTASK_DEPTH,
|
|
11
|
+
cascadeTargets,
|
|
12
|
+
combineCascadeOutcome,
|
|
13
|
+
normalizeSubtaskDepth,
|
|
14
|
+
openGroupExecution,
|
|
15
|
+
pendingCascadeChildren,
|
|
16
|
+
resolveExecutionTargets,
|
|
17
|
+
} from './core/subtask.ts'
|
|
9
18
|
import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
|
|
10
19
|
import { applyCreateTask } from './core/use-cases/task-create.ts'
|
|
11
20
|
import { applyDeleteTask } from './core/use-cases/task-delete.ts'
|
|
12
21
|
import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
|
|
22
|
+
import { applySetParent } from './core/use-cases/task-parent.ts'
|
|
13
23
|
import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
|
|
14
24
|
import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
15
25
|
import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
|
|
@@ -40,9 +50,32 @@ export interface LedgerState {
|
|
|
40
50
|
scheduler: TaskBoardSchedulerSnapshot
|
|
41
51
|
}
|
|
42
52
|
|
|
53
|
+
/** Human-facing text for a refused binding, per surface. */
|
|
54
|
+
function bindingRefusalMessage(
|
|
55
|
+
refusal: { kind: 'root' | 'inherited' | 'subtask-pin'; title: string },
|
|
56
|
+
sessionDefault: TaskPermission,
|
|
57
|
+
surface: 'run' | 'schedule',
|
|
58
|
+
): string {
|
|
59
|
+
if (refusal.kind === 'subtask-pin') {
|
|
60
|
+
return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session; clear that card's permission or run the tree without Agent Team`
|
|
61
|
+
}
|
|
62
|
+
if (surface === 'schedule') return `task "${refusal.title}" has an unconfirmed above-default permission`
|
|
63
|
+
if (refusal.kind === 'root') {
|
|
64
|
+
return `confirmation-required: the effective permission is above the session default (${sessionDefault}); confirm the card's permission binding first`
|
|
65
|
+
}
|
|
66
|
+
return `confirmation-required: subtask "${refusal.title}" inherits an above-default permission; confirm that card's permission binding first`
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
export interface OpenedRun {
|
|
44
70
|
task: TaskRecord
|
|
45
71
|
execution: ExecutionRecord
|
|
72
|
+
/**
|
|
73
|
+
* How the Host obtains this run's session. Absent or `session` launches a
|
|
74
|
+
* fresh (or reused) execution session; `teammate` means the run belongs to a
|
|
75
|
+
* team-mode cascade, where the Host asks the root's Lead session to spawn a
|
|
76
|
+
* teammate instead of launching a session of its own.
|
|
77
|
+
*/
|
|
78
|
+
dispatch?: 'session' | 'teammate'
|
|
46
79
|
}
|
|
47
80
|
|
|
48
81
|
/** Minimal value copy used by the Host session monitor. */
|
|
@@ -298,6 +331,39 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
|
|
|
298
331
|
return { ...newer, executions: retainRecentExecutions(executions) }
|
|
299
332
|
}
|
|
300
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Repair parent links after a load or an import: a link whose parent row is
|
|
336
|
+
* missing, cyclic, or names an archived parent while the child is on board is
|
|
337
|
+
* dropped (the task becomes a root) instead of dropping the task. A chain
|
|
338
|
+
* deeper than the current `maxSubtaskDepth` is deliberately KEPT: the limit
|
|
339
|
+
* gates new links, it never deletes a stored one, and every cascade walk is
|
|
340
|
+
* bounded by the limit anyway. The Host is the only writer of these links, so
|
|
341
|
+
* this is a last-resort guard against a hand-edited or imported document.
|
|
342
|
+
*/
|
|
343
|
+
export function repairParentLinks(tasks: readonly TaskRecord[]): TaskRecord[] {
|
|
344
|
+
const byId = new Map(tasks.map(task => [task.id, task]))
|
|
345
|
+
return tasks.map(task => {
|
|
346
|
+
if (task.parentId === undefined) return task
|
|
347
|
+
const seen = new Set<string>([task.id])
|
|
348
|
+
let current: TaskRecord | undefined = byId.get(task.parentId)
|
|
349
|
+
let depth = 0
|
|
350
|
+
while (current !== undefined) {
|
|
351
|
+
// The first hop is the parent itself: an archived parent holding an
|
|
352
|
+
// on-board child is outside the archive cascade invariant, and only an
|
|
353
|
+
// import can produce it.
|
|
354
|
+
if (seen.has(current.id) || (depth === 0 && current.archivedAt !== undefined && task.archivedAt === undefined)) {
|
|
355
|
+
return { ...task, parentId: undefined }
|
|
356
|
+
}
|
|
357
|
+
seen.add(current.id)
|
|
358
|
+
depth += 1
|
|
359
|
+
current = current.parentId === undefined ? undefined : byId.get(current.parentId)
|
|
360
|
+
}
|
|
361
|
+
// A walk that never started (depth 0) means the parent row did not survive
|
|
362
|
+
// the parse: the link is dangling and the task goes back to the root.
|
|
363
|
+
return depth === 0 ? { ...task, parentId: undefined } : task
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
301
367
|
function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
302
368
|
const rawById = new Map<string, Record<string, unknown>>()
|
|
303
369
|
for (const value of values) {
|
|
@@ -305,7 +371,7 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
|
305
371
|
const raw = value as Record<string, unknown>
|
|
306
372
|
if (typeof raw.id === 'string') rawById.set(raw.id, raw)
|
|
307
373
|
}
|
|
308
|
-
return parseLedger(JSON.stringify(values)).map(task => {
|
|
374
|
+
return repairParentLinks(parseLedger(JSON.stringify(values))).map(task => {
|
|
309
375
|
const rawSchedule = rawById.get(task.id)?.schedule
|
|
310
376
|
if (typeof rawSchedule !== 'object' || rawSchedule === null) return task
|
|
311
377
|
const schedule = rawSchedule as Record<string, unknown>
|
|
@@ -337,9 +403,16 @@ export class HostTaskLedger {
|
|
|
337
403
|
|
|
338
404
|
/** Session-default permission the confirmation gate compares against. */
|
|
339
405
|
readonly sessionDefaultPermission: TaskPermission
|
|
406
|
+
/**
|
|
407
|
+
* Deployment subtask depth limit (1..3): the lineage gate every write obeys.
|
|
408
|
+
* The settings card edits it live, so {@link setMaxSubtaskDepth} mutates it
|
|
409
|
+
* instead of remounting the row.
|
|
410
|
+
*/
|
|
411
|
+
private depthLimit: number
|
|
340
412
|
|
|
341
|
-
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission } = {}) {
|
|
413
|
+
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission; maxSubtaskDepth?: number } = {}) {
|
|
342
414
|
this.sessionDefaultPermission = options.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION
|
|
415
|
+
this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH)
|
|
343
416
|
mkdirSync(dir, { recursive: true })
|
|
344
417
|
this.file = join(dir, 'ledger-v2.json')
|
|
345
418
|
this.lockFile = join(dir, 'ledger-v2.lock')
|
|
@@ -362,6 +435,23 @@ export class HostTaskLedger {
|
|
|
362
435
|
}
|
|
363
436
|
}
|
|
364
437
|
|
|
438
|
+
/** Current subtask depth limit (1..3). */
|
|
439
|
+
get maxSubtaskDepth(): number {
|
|
440
|
+
return this.depthLimit
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Apply a live settings edit of the subtask depth limit. A no-op when the
|
|
445
|
+
* normalized limit is unchanged, so a coarse volatile invalidation that
|
|
446
|
+
* changed nothing emits nothing.
|
|
447
|
+
*/
|
|
448
|
+
setMaxSubtaskDepth(depth: number): void {
|
|
449
|
+
const next = normalizeSubtaskDepth(depth)
|
|
450
|
+
if (next === this.depthLimit) return
|
|
451
|
+
this.depthLimit = next
|
|
452
|
+
this.notify()
|
|
453
|
+
}
|
|
454
|
+
|
|
365
455
|
/** Remove leftover *.tmp-* files from previous crashes or interrupted writes. */
|
|
366
456
|
private cleanStaleTemporaryFiles(dir: string): void {
|
|
367
457
|
try {
|
|
@@ -403,6 +493,9 @@ export class HostTaskLedger {
|
|
|
403
493
|
if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
|
|
404
494
|
for (const execution of task.executions) {
|
|
405
495
|
if (execution.endedAt !== undefined) continue
|
|
496
|
+
// A deferred cascade parent already knows its own outcome; the monitor
|
|
497
|
+
// has nothing left to inspect, and its children's settles finalize it.
|
|
498
|
+
if (execution.ownResult !== undefined) continue
|
|
406
499
|
openExecutions.push({
|
|
407
500
|
taskId: task.id,
|
|
408
501
|
executionId: execution.id,
|
|
@@ -457,7 +550,7 @@ export class HostTaskLedger {
|
|
|
457
550
|
requestId: string,
|
|
458
551
|
action: TaskBoardAction,
|
|
459
552
|
initiator?: string,
|
|
460
|
-
): { state: LedgerState;
|
|
553
|
+
): { state: LedgerState; runs?: OpenedRun[] } {
|
|
461
554
|
const fingerprint = createHash('sha256').update(JSON.stringify(action)).digest('hex')
|
|
462
555
|
const cached = this.requestCache.get(requestId)
|
|
463
556
|
if (cached !== undefined) {
|
|
@@ -479,27 +572,32 @@ export class HostTaskLedger {
|
|
|
479
572
|
}
|
|
480
573
|
}
|
|
481
574
|
|
|
482
|
-
|
|
575
|
+
/**
|
|
576
|
+
* Open the cascade one due schedule triggers: an empty array means nothing
|
|
577
|
+
* ran (already running, or a participant whose elevated permission is still
|
|
578
|
+
* unconfirmed), and the rule rolls to its next occurrence either way.
|
|
579
|
+
*/
|
|
580
|
+
openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun[] {
|
|
483
581
|
const task = this.document.tasks.find(item => item.id === taskId)
|
|
484
|
-
if (task === undefined || task.archivedAt !== undefined) return
|
|
485
|
-
|
|
486
|
-
// An unconfirmed above-default permission must never run unattended:
|
|
487
|
-
// cron refuses the card and rolls to the next occurrence, exactly
|
|
488
|
-
// like the already-running refusal.
|
|
582
|
+
if (task === undefined || task.archivedAt !== undefined) return []
|
|
583
|
+
const rollForward = (): void => {
|
|
489
584
|
this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
|
|
490
585
|
this.commit()
|
|
491
|
-
|
|
586
|
+
}
|
|
587
|
+
const refusal = this.bindingRefusal(task)
|
|
588
|
+
if (refusal !== undefined) {
|
|
589
|
+
// An unconfirmed above-default permission must never run unattended: the
|
|
590
|
+
// whole tree is refused and the schedule rolls to the next occurrence,
|
|
591
|
+
// exactly like the already-running refusal.
|
|
592
|
+
this.document.scheduler.error = `scheduled run refused for task ${taskId}: ${bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'schedule')}`
|
|
593
|
+
rollForward()
|
|
594
|
+
return []
|
|
492
595
|
}
|
|
493
596
|
if (task.status === 'running' || hasOpenExecution(task)) {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
return undefined
|
|
597
|
+
rollForward()
|
|
598
|
+
return []
|
|
497
599
|
}
|
|
498
|
-
|
|
499
|
-
this.document.tasks = this.document.tasks.map(item => item.id === taskId ? opened.task : item)
|
|
500
|
-
this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, triggeredAt, triggeredAt)]
|
|
501
|
-
this.commit()
|
|
502
|
-
return opened
|
|
600
|
+
return this.startCascade(task, triggeredAt, undefined, false, nextRunAt)
|
|
503
601
|
}
|
|
504
602
|
|
|
505
603
|
skipMissed(now: number): void {
|
|
@@ -535,6 +633,38 @@ export class HostTaskLedger {
|
|
|
535
633
|
this.commit(false)
|
|
536
634
|
}
|
|
537
635
|
|
|
636
|
+
/**
|
|
637
|
+
* Settle one execution. A cascade participant first records its OWN outcome
|
|
638
|
+
* and only finalizes once every child execution in its run group has
|
|
639
|
+
* settled, so a parent card leaves 'running' with the whole tree's verdict
|
|
640
|
+
* rather than its own turn alone.
|
|
641
|
+
*/
|
|
642
|
+
settle(taskId: string, executionId: string, outcome: ExecutionOutcome, error?: string): void {
|
|
643
|
+
const now = this.now()
|
|
644
|
+
const task = this.document.tasks.find(item => item.id === taskId)
|
|
645
|
+
const execution = task?.executions.find(entry => entry.id === executionId)
|
|
646
|
+
if (task === undefined || execution === undefined || execution.endedAt !== undefined) return
|
|
647
|
+
const groupId = execution.runGroupId
|
|
648
|
+
if (groupId === undefined) {
|
|
649
|
+
this.document.tasks = this.document.tasks.map(item => item.id === taskId
|
|
650
|
+
? settleExecution(item, executionId, outcome, now, error)
|
|
651
|
+
: item)
|
|
652
|
+
this.commit()
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
let changed = false
|
|
656
|
+
if (execution.ownResult === undefined) {
|
|
657
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
|
|
658
|
+
...item,
|
|
659
|
+
updatedAt: now,
|
|
660
|
+
executions: item.executions.map(entry => entry.id === executionId ? { ...entry, ownResult: outcome, ownError: error } : entry),
|
|
661
|
+
})
|
|
662
|
+
changed = true
|
|
663
|
+
}
|
|
664
|
+
if (this.settleCascade(taskId, groupId, now)) changed = true
|
|
665
|
+
if (changed) this.commit()
|
|
666
|
+
}
|
|
667
|
+
|
|
538
668
|
attachSession(taskId: string, executionId: string, sessionId: string): void {
|
|
539
669
|
const now = this.now()
|
|
540
670
|
this.document.tasks = this.document.tasks.map(task => task.id !== taskId ? task : {
|
|
@@ -545,16 +675,9 @@ export class HostTaskLedger {
|
|
|
545
675
|
this.commit()
|
|
546
676
|
}
|
|
547
677
|
|
|
548
|
-
|
|
549
|
-
this.document.tasks = this.document.tasks.map(task => task.id === taskId
|
|
550
|
-
? settleExecution(task, executionId, outcome, this.now(), error)
|
|
551
|
-
: task)
|
|
552
|
-
this.commit()
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; run?: OpenedRun } {
|
|
678
|
+
private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; runs?: OpenedRun[] } {
|
|
556
679
|
const now = this.now()
|
|
557
|
-
let
|
|
680
|
+
let runs: OpenedRun[] | undefined
|
|
558
681
|
switch (action.kind) {
|
|
559
682
|
case 'import': {
|
|
560
683
|
const sources = new Set(this.document.scheduler.importedSources ?? [])
|
|
@@ -565,7 +688,9 @@ export class HostTaskLedger {
|
|
|
565
688
|
const incoming = parseHostTasks(action.tasks)
|
|
566
689
|
const merged = new Map(this.document.tasks.map(task => [task.id, task]))
|
|
567
690
|
for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id)!, task) : task)
|
|
568
|
-
this
|
|
691
|
+
// An imported child can name a parent this ledger has never seen (a
|
|
692
|
+
// partial export): the dangling link is dropped, the task survives.
|
|
693
|
+
this.document.tasks = repairParentLinks([...merged.values()])
|
|
569
694
|
this.document.scheduler.importedSources = [...sources, action.sourceId]
|
|
570
695
|
this.document.scheduler.error = invalidScheduleIds.length === 0
|
|
571
696
|
? undefined
|
|
@@ -582,8 +707,8 @@ export class HostTaskLedger {
|
|
|
582
707
|
const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
|
|
583
708
|
? action.input
|
|
584
709
|
: { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
|
|
585
|
-
const result = applyCreateTask(this.document.tasks, input, now, action.id)
|
|
586
|
-
if (result.task === undefined) throw new Error('invalid task')
|
|
710
|
+
const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth)
|
|
711
|
+
if (result.task === undefined) throw new Error(result.error ?? 'invalid task')
|
|
587
712
|
this.document.tasks = [...result.tasks]
|
|
588
713
|
break
|
|
589
714
|
}
|
|
@@ -607,14 +732,24 @@ export class HostTaskLedger {
|
|
|
607
732
|
this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, patch, now)]
|
|
608
733
|
break
|
|
609
734
|
}
|
|
610
|
-
case 'delete':
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
735
|
+
case 'delete': {
|
|
736
|
+
const task = this.document.tasks.find(task => task.id === action.taskId)
|
|
737
|
+
if (task === undefined) throw new Error('task not found')
|
|
738
|
+
if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be deleted')
|
|
739
|
+
// Subtasks keep their link: deleting the parent would leave dangling
|
|
740
|
+
// children, so the user detaches or deletes them explicitly first.
|
|
741
|
+
if (this.document.tasks.some(item => item.parentId === action.taskId)) {
|
|
742
|
+
throw new Error('task has subtasks; detach or delete them first')
|
|
615
743
|
}
|
|
616
744
|
this.document.tasks = [...applyDeleteTask(this.document.tasks, undefined, action.taskId).tasks]
|
|
617
745
|
break
|
|
746
|
+
}
|
|
747
|
+
case 'set-parent': {
|
|
748
|
+
const result = applySetParent(this.document.tasks, action.taskId, action.parentId, now, this.maxSubtaskDepth)
|
|
749
|
+
if (!result.applied) throw new Error(result.error ?? 'parent link refused')
|
|
750
|
+
this.document.tasks = [...result.tasks]
|
|
751
|
+
break
|
|
752
|
+
}
|
|
618
753
|
case 'move': {
|
|
619
754
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
620
755
|
if (task === undefined) throw new Error('task not found')
|
|
@@ -625,13 +760,15 @@ export class HostTaskLedger {
|
|
|
625
760
|
break
|
|
626
761
|
}
|
|
627
762
|
case 'archive': {
|
|
628
|
-
|
|
763
|
+
if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be archived')
|
|
764
|
+
const result = applyArchiveTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
|
|
629
765
|
if (!result.archived) throw new Error('task cannot be archived')
|
|
630
766
|
this.document.tasks = [...result.tasks]
|
|
631
767
|
break
|
|
632
768
|
}
|
|
633
769
|
case 'restore': {
|
|
634
|
-
|
|
770
|
+
if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
|
|
771
|
+
const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
|
|
635
772
|
if (!result.archived) throw new Error('task is not archived')
|
|
636
773
|
this.document.tasks = [...result.tasks]
|
|
637
774
|
break
|
|
@@ -658,17 +795,148 @@ export class HostTaskLedger {
|
|
|
658
795
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
659
796
|
if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
660
797
|
if (task === undefined || task.status === 'running' || hasOpenExecution(task)) throw new Error('task is already running or missing')
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
798
|
+
// The confirmation gate judges the RESOLVED binding: a subtask that
|
|
799
|
+
// inherits an elevated permission from its parent is exactly as
|
|
800
|
+
// unconfirmed as the parent would be without its own stamp.
|
|
801
|
+
const refusal = this.bindingRefusal(task)
|
|
802
|
+
if (refusal !== undefined) throw new Error(bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'run'))
|
|
803
|
+
runs = this.startCascade(task, now, initiator, action.kind === 'rerun')
|
|
667
804
|
break
|
|
668
805
|
}
|
|
669
806
|
}
|
|
807
|
+
// startCascade committed the opened participants itself; committing again
|
|
808
|
+
// here would bump the revision twice for one action.
|
|
809
|
+
if (runs === undefined || runs.length === 0) this.commit()
|
|
810
|
+
return { state: this.state(), ...(runs === undefined ? {} : { runs }) }
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* The binding that makes a run illegal, if any.
|
|
815
|
+
*
|
|
816
|
+
* A plain cascade launches one session per participant, so every participant
|
|
817
|
+
* carries its own resolved binding and each one gates the run. A team run
|
|
818
|
+
* launches only the Lead session: the Lead's binding gates it, while a
|
|
819
|
+
* subtask's OWN above-default pin cannot be applied to a teammate and is
|
|
820
|
+
* refused instead of being silently dropped (an inherited binding is the
|
|
821
|
+
* Lead's own and stays allowed once the Lead is confirmed).
|
|
822
|
+
* @param root - the task being run.
|
|
823
|
+
* @returns the first refusal, or undefined when the run may start.
|
|
824
|
+
*/
|
|
825
|
+
private bindingRefusal(root: TaskRecord): { kind: 'root' | 'inherited' | 'subtask-pin'; title: string } | undefined {
|
|
826
|
+
const participants = this.cascadeParticipants(root.id)
|
|
827
|
+
if (root.teamRun === true) {
|
|
828
|
+
const lead = participants.find(participant => participant.id === root.id)
|
|
829
|
+
if (lead !== undefined && requiresPermissionConfirmation(lead, this.sessionDefaultPermission)) {
|
|
830
|
+
return { kind: 'root', title: lead.title }
|
|
831
|
+
}
|
|
832
|
+
// The raw record carries the subtask's OWN binding: the resolved
|
|
833
|
+
// participant above already folded the inherited one into its permission.
|
|
834
|
+
const pinned = participants.find(participant => {
|
|
835
|
+
if (participant.id === root.id) return false
|
|
836
|
+
const raw = this.document.tasks.find(task => task.id === participant.id)
|
|
837
|
+
return raw !== undefined && requiresPermissionConfirmation(raw, this.sessionDefaultPermission)
|
|
838
|
+
})
|
|
839
|
+
return pinned === undefined ? undefined : { kind: 'subtask-pin', title: pinned.title }
|
|
840
|
+
}
|
|
841
|
+
const unconfirmed = participants.find(participant => requiresPermissionConfirmation(participant, this.sessionDefaultPermission))
|
|
842
|
+
if (unconfirmed === undefined) return undefined
|
|
843
|
+
return { kind: unconfirmed.id === root.id ? 'root' : 'inherited', title: unconfirmed.title }
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* The tasks a cascade from `rootId` would actually open executions for, with
|
|
848
|
+
* their effective execution targets resolved. Archived members never run, and
|
|
849
|
+
* a member that already has an open execution is skipped (a task cannot run
|
|
850
|
+
* twice), so the result is the participant set the launch will use.
|
|
851
|
+
*/
|
|
852
|
+
private cascadeParticipants(rootId: string): TaskRecord[] {
|
|
853
|
+
return cascadeTargets(this.document.tasks, rootId, this.maxSubtaskDepth)
|
|
854
|
+
.map(participant => resolveExecutionTargets(participant, this.document.tasks, this.maxSubtaskDepth))
|
|
855
|
+
.filter(participant => participant.archivedAt === undefined
|
|
856
|
+
&& participant.status !== 'running'
|
|
857
|
+
&& !hasOpenExecution(participant))
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Whether a task or any of its subtasks still has an open execution. */
|
|
861
|
+
private subtreeHasOpenExecution(id: string): boolean {
|
|
862
|
+
return cascadeTargets(this.document.tasks, id, this.maxSubtaskDepth)
|
|
863
|
+
.some(task => task.status === 'running' || hasOpenExecution(task))
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Open one execution per cascade participant (the requested task and its
|
|
868
|
+
* on-board descendants, within the depth limit) under a single run group, and
|
|
869
|
+
* commit the ledger once. The returned runs are ordered root-first, so the
|
|
870
|
+
* Host launches the parent before its subtasks.
|
|
871
|
+
* @param root - the task the user ran.
|
|
872
|
+
* @param now - clock instant (ms epoch).
|
|
873
|
+
* @param initiator - the DSH session that asked for the run (audit only).
|
|
874
|
+
* @param rerun - true to reset the root to 'todo' before starting it.
|
|
875
|
+
* @param nextRunAt - when set, the root's schedule also rolls forward.
|
|
876
|
+
*/
|
|
877
|
+
private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false, nextRunAt?: number): OpenedRun[] {
|
|
878
|
+
const before = this.document.tasks
|
|
879
|
+
const groupId = crypto.randomUUID()
|
|
880
|
+
// A team-mode root runs as the Team Lead: every other member of the same
|
|
881
|
+
// cascade is handed to that Lead as a teammate (the tree is flattened into
|
|
882
|
+
// one Team, because only the Lead may spawn).
|
|
883
|
+
const team = root.teamRun === true
|
|
884
|
+
const started = new Map<string, TaskRecord>()
|
|
885
|
+
const runs: OpenedRun[] = []
|
|
886
|
+
for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
|
|
887
|
+
if (task.archivedAt !== undefined) continue
|
|
888
|
+
if (task.status === 'running' || hasOpenExecution(task)) continue
|
|
889
|
+
const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
|
|
890
|
+
const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
|
|
891
|
+
started.set(task.id, opened.task)
|
|
892
|
+
runs.push({
|
|
893
|
+
task: resolveExecutionTargets(opened.task, before, this.maxSubtaskDepth),
|
|
894
|
+
execution: opened.execution,
|
|
895
|
+
...(team && task.id !== root.id ? { dispatch: 'teammate' as const } : {}),
|
|
896
|
+
})
|
|
897
|
+
}
|
|
898
|
+
if (runs.length === 0) return []
|
|
899
|
+
let tasks: readonly TaskRecord[] = before.map(task => started.get(task.id) ?? task)
|
|
900
|
+
if (nextRunAt !== undefined) tasks = applyScheduleNextRun(tasks, root.id, nextRunAt, now, now)
|
|
901
|
+
this.document.tasks = [...tasks]
|
|
670
902
|
this.commit()
|
|
671
|
-
return
|
|
903
|
+
return runs
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Finalize every cascade parent that is ready, walking from `taskId` up the
|
|
908
|
+
* lineage: a member settles once its own outcome is recorded and none of its
|
|
909
|
+
* group children is still open, and its verdict folds those children in
|
|
910
|
+
* (failure dominates, then cancellation).
|
|
911
|
+
*/
|
|
912
|
+
private settleCascade(taskId: string, groupId: string, now: number): boolean {
|
|
913
|
+
let changed = false
|
|
914
|
+
const visited = new Set<string>()
|
|
915
|
+
let current: string | undefined = taskId
|
|
916
|
+
while (current !== undefined && !visited.has(current)) {
|
|
917
|
+
visited.add(current)
|
|
918
|
+
const task: TaskRecord | undefined = this.document.tasks.find(item => item.id === current)
|
|
919
|
+
if (task === undefined) break
|
|
920
|
+
const execution = openGroupExecution(task, groupId)
|
|
921
|
+
if (execution === undefined || execution.ownResult === undefined) break
|
|
922
|
+
if (pendingCascadeChildren(this.document.tasks, current, groupId).length > 0) break
|
|
923
|
+
const entries: Array<{ result: ExecutionOutcome; error?: string }> = [
|
|
924
|
+
{ result: execution.ownResult, ...(execution.ownError === undefined ? {} : { error: execution.ownError }) },
|
|
925
|
+
]
|
|
926
|
+
for (const child of this.document.tasks) {
|
|
927
|
+
if (child.parentId !== current) continue
|
|
928
|
+
const childExecution = child.executions.find(entry => entry.runGroupId === groupId && entry.result !== undefined)
|
|
929
|
+
if (childExecution?.result === undefined) continue
|
|
930
|
+
entries.push({ result: childExecution.result, ...(childExecution.error === undefined ? {} : { error: childExecution.error }) })
|
|
931
|
+
}
|
|
932
|
+
const combined = combineCascadeOutcome(entries)
|
|
933
|
+
this.document.tasks = this.document.tasks.map(item => item.id === current
|
|
934
|
+
? settleExecution(item, execution.id, combined.result, now, combined.error)
|
|
935
|
+
: item)
|
|
936
|
+
changed = true
|
|
937
|
+
current = task.parentId
|
|
938
|
+
}
|
|
939
|
+
return changed
|
|
672
940
|
}
|
|
673
941
|
|
|
674
942
|
private repairSchedules(skipPast: boolean, persist = true): void {
|
|
@@ -694,13 +962,23 @@ export class HostTaskLedger {
|
|
|
694
962
|
private reconcileInterruptedStarts(persist = true): void {
|
|
695
963
|
const now = this.now()
|
|
696
964
|
let changed = false
|
|
965
|
+
const interrupted: Array<{ taskId: string; runGroupId: string | undefined }> = []
|
|
697
966
|
this.document.tasks = this.document.tasks.map(task => {
|
|
698
967
|
if (task.status !== 'running') return task
|
|
699
968
|
const execution = task.executions.at(-1)
|
|
700
969
|
if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task
|
|
701
970
|
changed = true
|
|
971
|
+
interrupted.push({ taskId: task.id, runGroupId: execution.runGroupId })
|
|
702
972
|
return settleExecution(task, execution.id, 'cancelled', now, 'host restarted before the execution session was recorded')
|
|
703
973
|
})
|
|
974
|
+
// A cancelled participant may have been the last pending child of a
|
|
975
|
+
// deferred cascade parent; walking up from its parent is what lets that
|
|
976
|
+
// parent finalize instead of staying in the running column forever.
|
|
977
|
+
for (const entry of interrupted) {
|
|
978
|
+
if (entry.runGroupId === undefined) continue
|
|
979
|
+
const parentId = this.document.tasks.find(task => task.id === entry.taskId)?.parentId
|
|
980
|
+
if (parentId !== undefined && this.settleCascade(parentId, entry.runGroupId, now)) changed = true
|
|
981
|
+
}
|
|
704
982
|
if (changed && persist) this.commit()
|
|
705
983
|
}
|
|
706
984
|
|