@linxin666/dsh-client-ui-task-board 0.4.1 → 0.4.3

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.
Files changed (69) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +28 -3
  3. package/README.zh.md +28 -3
  4. package/lib/client.js +1142 -467
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2066 -1475
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
  8. package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
  9. package/lib/types/client/board/NewTaskModal.d.ts +7 -1
  10. package/lib/types/client/board/TaskCard.d.ts +11 -1
  11. package/lib/types/client/index.d.ts +15 -1
  12. package/lib/types/client/locales.d.ts +33 -0
  13. package/lib/types/client/native-panel.d.ts +62 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +71 -3
  16. package/lib/types/core/subtask.d.ts +122 -0
  17. package/lib/types/core/tasks.d.ts +46 -3
  18. package/lib/types/core/use-cases/task-archive.d.ts +10 -13
  19. package/lib/types/core/use-cases/task-create.d.ts +13 -4
  20. package/lib/types/core/use-cases/task-delete.d.ts +7 -1
  21. package/lib/types/core/use-cases/task-parent.d.ts +21 -0
  22. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  23. package/lib/types/host/agent-tools.d.ts +27 -0
  24. package/lib/types/host-ledger.d.ts +97 -4
  25. package/lib/types/host-runner.d.ts +39 -1
  26. package/lib/types/host-service.d.ts +98 -5
  27. package/lib/types/index.d.ts +93 -5
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +14 -0
  30. package/package.json +26 -21
  31. package/src/client/TaskBoardSettingsCard.tsx +48 -3
  32. package/src/client/board/LinkSubtaskModal.tsx +82 -0
  33. package/src/client/board/NewTaskModal.tsx +40 -6
  34. package/src/client/board/TaskBoard.tsx +88 -8
  35. package/src/client/board/TaskCard.tsx +52 -2
  36. package/src/client/board/TaskDetail.tsx +124 -1
  37. package/src/client/board.module.css +112 -131
  38. package/src/client/host-api.ts +14 -4
  39. package/src/client/index.ts +56 -18
  40. package/src/client/locales.ts +70 -2
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/settings-entry-form.ts +142 -0
  43. package/src/core/controller.ts +128 -10
  44. package/src/core/store.ts +15 -0
  45. package/src/core/subtask.ts +286 -0
  46. package/src/core/tasks.ts +50 -2
  47. package/src/core/use-cases/task-archive.ts +46 -12
  48. package/src/core/use-cases/task-create.ts +42 -7
  49. package/src/core/use-cases/task-delete.ts +11 -1
  50. package/src/core/use-cases/task-parent.ts +64 -0
  51. package/src/core/use-cases/task-update.ts +3 -1
  52. package/src/host/agent-tools.ts +643 -0
  53. package/src/host-ledger.ts +343 -45
  54. package/src/host-routes.ts +37 -1
  55. package/src/host-runner.ts +141 -12
  56. package/src/host-service.ts +265 -32
  57. package/src/index.ts +203 -9
  58. package/src/mount-once.ts +107 -13
  59. package/src/protocol.ts +26 -2
  60. package/lib/types/client/board-mount.d.ts +0 -23
  61. package/lib/types/client/body-mutations.d.ts +0 -45
  62. package/lib/types/client/panel-mount-core.d.ts +0 -56
  63. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  64. package/lib/types/client/sidebar-entry.d.ts +0 -29
  65. package/src/client/board-mount.tsx +0 -43
  66. package/src/client/body-mutations.ts +0 -133
  67. package/src/client/panel-mount-core.ts +0 -155
  68. package/src/client/sidebar-entry-core.ts +0 -237
  69. package/src/client/sidebar-entry.ts +0 -53
@@ -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,
@@ -423,6 +516,26 @@ export class HostTaskLedger {
423
516
  return count
424
517
  }
425
518
 
519
+ /**
520
+ * The earliest armed `nextRunAt` strictly after the supplied Host time, or
521
+ * undefined when no schedule is armed in the future. This is the instant the
522
+ * Host arms its native timer at, so the board wakes exactly when work is due
523
+ * instead of polling a fixed heartbeat.
524
+ * @param now - current Host time in ms epoch.
525
+ * @returns the nearest future trigger, or undefined when none is armed.
526
+ */
527
+ nextArmedRunAt(now: number): number | undefined {
528
+ let nearest: number | undefined
529
+ for (const task of this.document.tasks) {
530
+ if (task.archivedAt !== undefined) continue
531
+ const schedule = task.schedule
532
+ if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined) continue
533
+ if (schedule.nextRunAt <= now) continue
534
+ if (nearest === undefined || schedule.nextRunAt < nearest) nearest = schedule.nextRunAt
535
+ }
536
+ return nearest
537
+ }
538
+
426
539
  /** Return value-only references for schedules due at the supplied Host time. */
427
540
  dueSchedules(now: number): DueScheduleReference[] {
428
541
  const due: DueScheduleReference[] = []
@@ -457,7 +570,7 @@ export class HostTaskLedger {
457
570
  requestId: string,
458
571
  action: TaskBoardAction,
459
572
  initiator?: string,
460
- ): { state: LedgerState; run?: OpenedRun } {
573
+ ): { state: LedgerState; runs?: OpenedRun[] } {
461
574
  const fingerprint = createHash('sha256').update(JSON.stringify(action)).digest('hex')
462
575
  const cached = this.requestCache.get(requestId)
463
576
  if (cached !== undefined) {
@@ -479,27 +592,32 @@ export class HostTaskLedger {
479
592
  }
480
593
  }
481
594
 
482
- openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun | undefined {
595
+ /**
596
+ * Open the cascade one due schedule triggers: an empty array means nothing
597
+ * ran (already running, or a participant whose elevated permission is still
598
+ * unconfirmed), and the rule rolls to its next occurrence either way.
599
+ */
600
+ openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun[] {
483
601
  const task = this.document.tasks.find(item => item.id === taskId)
484
- if (task === undefined || task.archivedAt !== undefined) return undefined
485
- if (requiresPermissionConfirmation(task, this.sessionDefaultPermission)) {
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.
602
+ if (task === undefined || task.archivedAt !== undefined) return []
603
+ const rollForward = (): void => {
489
604
  this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
490
605
  this.commit()
491
- return undefined
606
+ }
607
+ const refusal = this.bindingRefusal(task)
608
+ if (refusal !== undefined) {
609
+ // An unconfirmed above-default permission must never run unattended: the
610
+ // whole tree is refused and the schedule rolls to the next occurrence,
611
+ // exactly like the already-running refusal.
612
+ this.document.scheduler.error = `scheduled run refused for task ${taskId}: ${bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'schedule')}`
613
+ rollForward()
614
+ return []
492
615
  }
493
616
  if (task.status === 'running' || hasOpenExecution(task)) {
494
- this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
495
- this.commit()
496
- return undefined
617
+ rollForward()
618
+ return []
497
619
  }
498
- const opened = startExecution(task, triggeredAt, crypto.randomUUID())
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
620
+ return this.startCascade(task, triggeredAt, undefined, false, nextRunAt)
503
621
  }
504
622
 
505
623
  skipMissed(now: number): void {
@@ -535,6 +653,38 @@ export class HostTaskLedger {
535
653
  this.commit(false)
536
654
  }
537
655
 
656
+ /**
657
+ * Settle one execution. A cascade participant first records its OWN outcome
658
+ * and only finalizes once every child execution in its run group has
659
+ * settled, so a parent card leaves 'running' with the whole tree's verdict
660
+ * rather than its own turn alone.
661
+ */
662
+ settle(taskId: string, executionId: string, outcome: ExecutionOutcome, error?: string): void {
663
+ const now = this.now()
664
+ const task = this.document.tasks.find(item => item.id === taskId)
665
+ const execution = task?.executions.find(entry => entry.id === executionId)
666
+ if (task === undefined || execution === undefined || execution.endedAt !== undefined) return
667
+ const groupId = execution.runGroupId
668
+ if (groupId === undefined) {
669
+ this.document.tasks = this.document.tasks.map(item => item.id === taskId
670
+ ? settleExecution(item, executionId, outcome, now, error)
671
+ : item)
672
+ this.commit()
673
+ return
674
+ }
675
+ let changed = false
676
+ if (execution.ownResult === undefined) {
677
+ this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
678
+ ...item,
679
+ updatedAt: now,
680
+ executions: item.executions.map(entry => entry.id === executionId ? { ...entry, ownResult: outcome, ownError: error } : entry),
681
+ })
682
+ changed = true
683
+ }
684
+ if (this.settleCascade(taskId, groupId, now)) changed = true
685
+ if (changed) this.commit()
686
+ }
687
+
538
688
  attachSession(taskId: string, executionId: string, sessionId: string): void {
539
689
  const now = this.now()
540
690
  this.document.tasks = this.document.tasks.map(task => task.id !== taskId ? task : {
@@ -545,16 +695,9 @@ export class HostTaskLedger {
545
695
  this.commit()
546
696
  }
547
697
 
548
- settle(taskId: string, executionId: string, outcome: 'succeeded' | 'failed' | 'cancelled', error?: string): void {
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 } {
698
+ private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; runs?: OpenedRun[] } {
556
699
  const now = this.now()
557
- let run: OpenedRun | undefined
700
+ let runs: OpenedRun[] | undefined
558
701
  switch (action.kind) {
559
702
  case 'import': {
560
703
  const sources = new Set(this.document.scheduler.importedSources ?? [])
@@ -565,7 +708,9 @@ export class HostTaskLedger {
565
708
  const incoming = parseHostTasks(action.tasks)
566
709
  const merged = new Map(this.document.tasks.map(task => [task.id, task]))
567
710
  for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id)!, task) : task)
568
- this.document.tasks = [...merged.values()]
711
+ // An imported child can name a parent this ledger has never seen (a
712
+ // partial export): the dangling link is dropped, the task survives.
713
+ this.document.tasks = repairParentLinks([...merged.values()])
569
714
  this.document.scheduler.importedSources = [...sources, action.sourceId]
570
715
  this.document.scheduler.error = invalidScheduleIds.length === 0
571
716
  ? undefined
@@ -582,8 +727,8 @@ export class HostTaskLedger {
582
727
  const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
583
728
  ? action.input
584
729
  : { ...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')
730
+ const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth)
731
+ if (result.task === undefined) throw new Error(result.error ?? 'invalid task')
587
732
  this.document.tasks = [...result.tasks]
588
733
  break
589
734
  }
@@ -607,14 +752,24 @@ export class HostTaskLedger {
607
752
  this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, patch, now)]
608
753
  break
609
754
  }
610
- case 'delete':
611
- {
612
- const task = this.document.tasks.find(task => task.id === action.taskId)
613
- if (task === undefined) throw new Error('task not found')
614
- if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be deleted')
755
+ case 'delete': {
756
+ const task = this.document.tasks.find(task => task.id === action.taskId)
757
+ if (task === undefined) throw new Error('task not found')
758
+ if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be deleted')
759
+ // Subtasks keep their link: deleting the parent would leave dangling
760
+ // children, so the user detaches or deletes them explicitly first.
761
+ if (this.document.tasks.some(item => item.parentId === action.taskId)) {
762
+ throw new Error('task has subtasks; detach or delete them first')
615
763
  }
616
764
  this.document.tasks = [...applyDeleteTask(this.document.tasks, undefined, action.taskId).tasks]
617
765
  break
766
+ }
767
+ case 'set-parent': {
768
+ const result = applySetParent(this.document.tasks, action.taskId, action.parentId, now, this.maxSubtaskDepth)
769
+ if (!result.applied) throw new Error(result.error ?? 'parent link refused')
770
+ this.document.tasks = [...result.tasks]
771
+ break
772
+ }
618
773
  case 'move': {
619
774
  const task = this.document.tasks.find(item => item.id === action.taskId)
620
775
  if (task === undefined) throw new Error('task not found')
@@ -625,13 +780,15 @@ export class HostTaskLedger {
625
780
  break
626
781
  }
627
782
  case 'archive': {
628
- const result = applyArchiveTask(this.document.tasks, action.taskId, now)
783
+ if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be archived')
784
+ const result = applyArchiveTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
629
785
  if (!result.archived) throw new Error('task cannot be archived')
630
786
  this.document.tasks = [...result.tasks]
631
787
  break
632
788
  }
633
789
  case 'restore': {
634
- const result = applyRestoreTask(this.document.tasks, action.taskId, now)
790
+ if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
791
+ const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
635
792
  if (!result.archived) throw new Error('task is not archived')
636
793
  this.document.tasks = [...result.tasks]
637
794
  break
@@ -658,17 +815,148 @@ export class HostTaskLedger {
658
815
  const task = this.document.tasks.find(item => item.id === action.taskId)
659
816
  if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
660
817
  if (task === undefined || task.status === 'running' || hasOpenExecution(task)) throw new Error('task is already running or missing')
661
- if (requiresPermissionConfirmation(task, this.sessionDefaultPermission)) {
662
- throw new Error(`confirmation-required: the effective permission is above the session default (${this.sessionDefaultPermission}); confirm the card's permission binding first`)
663
- }
664
- const base = action.kind === 'rerun' ? withStatus(task, 'todo', now) : task
665
- run = startExecution(base, now, crypto.randomUUID(), initiator)
666
- this.document.tasks = this.document.tasks.map(item => item.id === task.id ? run!.task : item)
818
+ // The confirmation gate judges the RESOLVED binding: a subtask that
819
+ // inherits an elevated permission from its parent is exactly as
820
+ // unconfirmed as the parent would be without its own stamp.
821
+ const refusal = this.bindingRefusal(task)
822
+ if (refusal !== undefined) throw new Error(bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'run'))
823
+ runs = this.startCascade(task, now, initiator, action.kind === 'rerun')
667
824
  break
668
825
  }
669
826
  }
827
+ // startCascade committed the opened participants itself; committing again
828
+ // here would bump the revision twice for one action.
829
+ if (runs === undefined || runs.length === 0) this.commit()
830
+ return { state: this.state(), ...(runs === undefined ? {} : { runs }) }
831
+ }
832
+
833
+ /**
834
+ * The binding that makes a run illegal, if any.
835
+ *
836
+ * A plain cascade launches one session per participant, so every participant
837
+ * carries its own resolved binding and each one gates the run. A team run
838
+ * launches only the Lead session: the Lead's binding gates it, while a
839
+ * subtask's OWN above-default pin cannot be applied to a teammate and is
840
+ * refused instead of being silently dropped (an inherited binding is the
841
+ * Lead's own and stays allowed once the Lead is confirmed).
842
+ * @param root - the task being run.
843
+ * @returns the first refusal, or undefined when the run may start.
844
+ */
845
+ private bindingRefusal(root: TaskRecord): { kind: 'root' | 'inherited' | 'subtask-pin'; title: string } | undefined {
846
+ const participants = this.cascadeParticipants(root.id)
847
+ if (root.teamRun === true) {
848
+ const lead = participants.find(participant => participant.id === root.id)
849
+ if (lead !== undefined && requiresPermissionConfirmation(lead, this.sessionDefaultPermission)) {
850
+ return { kind: 'root', title: lead.title }
851
+ }
852
+ // The raw record carries the subtask's OWN binding: the resolved
853
+ // participant above already folded the inherited one into its permission.
854
+ const pinned = participants.find(participant => {
855
+ if (participant.id === root.id) return false
856
+ const raw = this.document.tasks.find(task => task.id === participant.id)
857
+ return raw !== undefined && requiresPermissionConfirmation(raw, this.sessionDefaultPermission)
858
+ })
859
+ return pinned === undefined ? undefined : { kind: 'subtask-pin', title: pinned.title }
860
+ }
861
+ const unconfirmed = participants.find(participant => requiresPermissionConfirmation(participant, this.sessionDefaultPermission))
862
+ if (unconfirmed === undefined) return undefined
863
+ return { kind: unconfirmed.id === root.id ? 'root' : 'inherited', title: unconfirmed.title }
864
+ }
865
+
866
+ /**
867
+ * The tasks a cascade from `rootId` would actually open executions for, with
868
+ * their effective execution targets resolved. Archived members never run, and
869
+ * a member that already has an open execution is skipped (a task cannot run
870
+ * twice), so the result is the participant set the launch will use.
871
+ */
872
+ private cascadeParticipants(rootId: string): TaskRecord[] {
873
+ return cascadeTargets(this.document.tasks, rootId, this.maxSubtaskDepth)
874
+ .map(participant => resolveExecutionTargets(participant, this.document.tasks, this.maxSubtaskDepth))
875
+ .filter(participant => participant.archivedAt === undefined
876
+ && participant.status !== 'running'
877
+ && !hasOpenExecution(participant))
878
+ }
879
+
880
+ /** Whether a task or any of its subtasks still has an open execution. */
881
+ private subtreeHasOpenExecution(id: string): boolean {
882
+ return cascadeTargets(this.document.tasks, id, this.maxSubtaskDepth)
883
+ .some(task => task.status === 'running' || hasOpenExecution(task))
884
+ }
885
+
886
+ /**
887
+ * Open one execution per cascade participant (the requested task and its
888
+ * on-board descendants, within the depth limit) under a single run group, and
889
+ * commit the ledger once. The returned runs are ordered root-first, so the
890
+ * Host launches the parent before its subtasks.
891
+ * @param root - the task the user ran.
892
+ * @param now - clock instant (ms epoch).
893
+ * @param initiator - the DSH session that asked for the run (audit only).
894
+ * @param rerun - true to reset the root to 'todo' before starting it.
895
+ * @param nextRunAt - when set, the root's schedule also rolls forward.
896
+ */
897
+ private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false, nextRunAt?: number): OpenedRun[] {
898
+ const before = this.document.tasks
899
+ const groupId = crypto.randomUUID()
900
+ // A team-mode root runs as the Team Lead: every other member of the same
901
+ // cascade is handed to that Lead as a teammate (the tree is flattened into
902
+ // one Team, because only the Lead may spawn).
903
+ const team = root.teamRun === true
904
+ const started = new Map<string, TaskRecord>()
905
+ const runs: OpenedRun[] = []
906
+ for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
907
+ if (task.archivedAt !== undefined) continue
908
+ if (task.status === 'running' || hasOpenExecution(task)) continue
909
+ const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
910
+ const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
911
+ started.set(task.id, opened.task)
912
+ runs.push({
913
+ task: resolveExecutionTargets(opened.task, before, this.maxSubtaskDepth),
914
+ execution: opened.execution,
915
+ ...(team && task.id !== root.id ? { dispatch: 'teammate' as const } : {}),
916
+ })
917
+ }
918
+ if (runs.length === 0) return []
919
+ let tasks: readonly TaskRecord[] = before.map(task => started.get(task.id) ?? task)
920
+ if (nextRunAt !== undefined) tasks = applyScheduleNextRun(tasks, root.id, nextRunAt, now, now)
921
+ this.document.tasks = [...tasks]
670
922
  this.commit()
671
- return { state: this.state(), ...(run === undefined ? {} : { run }) }
923
+ return runs
924
+ }
925
+
926
+ /**
927
+ * Finalize every cascade parent that is ready, walking from `taskId` up the
928
+ * lineage: a member settles once its own outcome is recorded and none of its
929
+ * group children is still open, and its verdict folds those children in
930
+ * (failure dominates, then cancellation).
931
+ */
932
+ private settleCascade(taskId: string, groupId: string, now: number): boolean {
933
+ let changed = false
934
+ const visited = new Set<string>()
935
+ let current: string | undefined = taskId
936
+ while (current !== undefined && !visited.has(current)) {
937
+ visited.add(current)
938
+ const task: TaskRecord | undefined = this.document.tasks.find(item => item.id === current)
939
+ if (task === undefined) break
940
+ const execution = openGroupExecution(task, groupId)
941
+ if (execution === undefined || execution.ownResult === undefined) break
942
+ if (pendingCascadeChildren(this.document.tasks, current, groupId).length > 0) break
943
+ const entries: Array<{ result: ExecutionOutcome; error?: string }> = [
944
+ { result: execution.ownResult, ...(execution.ownError === undefined ? {} : { error: execution.ownError }) },
945
+ ]
946
+ for (const child of this.document.tasks) {
947
+ if (child.parentId !== current) continue
948
+ const childExecution = child.executions.find(entry => entry.runGroupId === groupId && entry.result !== undefined)
949
+ if (childExecution?.result === undefined) continue
950
+ entries.push({ result: childExecution.result, ...(childExecution.error === undefined ? {} : { error: childExecution.error }) })
951
+ }
952
+ const combined = combineCascadeOutcome(entries)
953
+ this.document.tasks = this.document.tasks.map(item => item.id === current
954
+ ? settleExecution(item, execution.id, combined.result, now, combined.error)
955
+ : item)
956
+ changed = true
957
+ current = task.parentId
958
+ }
959
+ return changed
672
960
  }
673
961
 
674
962
  private repairSchedules(skipPast: boolean, persist = true): void {
@@ -694,13 +982,23 @@ export class HostTaskLedger {
694
982
  private reconcileInterruptedStarts(persist = true): void {
695
983
  const now = this.now()
696
984
  let changed = false
985
+ const interrupted: Array<{ taskId: string; runGroupId: string | undefined }> = []
697
986
  this.document.tasks = this.document.tasks.map(task => {
698
987
  if (task.status !== 'running') return task
699
988
  const execution = task.executions.at(-1)
700
989
  if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task
701
990
  changed = true
991
+ interrupted.push({ taskId: task.id, runGroupId: execution.runGroupId })
702
992
  return settleExecution(task, execution.id, 'cancelled', now, 'host restarted before the execution session was recorded')
703
993
  })
994
+ // A cancelled participant may have been the last pending child of a
995
+ // deferred cascade parent; walking up from its parent is what lets that
996
+ // parent finalize instead of staying in the running column forever.
997
+ for (const entry of interrupted) {
998
+ if (entry.runGroupId === undefined) continue
999
+ const parentId = this.document.tasks.find(task => task.id === entry.taskId)?.parentId
1000
+ if (parentId !== undefined && this.settleCascade(parentId, entry.runGroupId, now)) changed = true
1001
+ }
704
1002
  if (changed && persist) this.commit()
705
1003
  }
706
1004
 
@@ -73,15 +73,51 @@ function resolveAccess(access: TaskBoardRouteAccess): ResolvedRouteAccess {
73
73
  return { trustedProxyHosts, ...(access.proxyToken === undefined ? {} : { proxyToken: access.proxyToken }) }
74
74
  }
75
75
 
76
+ /**
77
+ * Harness browser-auth cookie prefix (dsh-client-connection): the
78
+ * authority-bound signed cookie the Host mints in exchange for its launch
79
+ * token. dsh-web's remote channel redeems that token before re-issuing paired
80
+ * traffic to 127.0.0.1 (packages/dsh-remote-web-ui/src/inner-auth.ts), and the
81
+ * official DSH Desktop shell keeps its own copy from the same exchange and
82
+ * attaches it to every request it forwards for the dsh-app://app/ page. The
83
+ * two constants describe one fact and move together.
84
+ */
85
+ const BROWSER_AUTH_COOKIE_PREFIX = 'dsh-auth-'
86
+
87
+ /**
88
+ * Whether a Cookie header carries the Host's browser-auth credential. Only an
89
+ * application on this machine can hold it: the desktop shell never lets it
90
+ * reach the page's cookie jar, and SameSite=Strict keeps a cross-site page
91
+ * from attaching it.
92
+ * @param header - the raw Cookie header value.
93
+ */
94
+ function carriesBrowserAuthCookie(header: string | undefined): boolean {
95
+ if (header === undefined) return false
96
+ return header.split(';').some(segment => segment.trim().startsWith(BROWSER_AUTH_COOKIE_PREFIX))
97
+ }
98
+
76
99
  /**
77
100
  * Browser-signal tripwire, NOT an authority check: a bare curl sends neither
78
101
  * header and is refused, but a curl with a forged Origin passes this too.
79
102
  * The real boundary is the loopback socket + Host + origin-equality checks
80
103
  * in isTrustedTaskBoardRequest below; do not rely on this marker alone.
104
+ *
105
+ * A first-party client that presents NEITHER header must still pass. The DSH
106
+ * Desktop shell serves the Web GUI from dsh-app://app/ and forwards that
107
+ * page's Host requests itself, deleting origin and sec-fetch-site on the
108
+ * way (dsh-desktop-host's forwardWebRequest), so the panel's own fetch
109
+ * arrives marker-less and every route behind this guard answered 403 - which
110
+ * the panel renders as its board.hostError.unauthorized message. The shell
111
+ * does attach the Host's browser-auth cookie, redeemed from the Host's launch
112
+ * URL at startup and deliberately withheld from the page. That credential,
113
+ * not a header the shell strips, is the browser signal of an application on
114
+ * this machine; a marker-less, credential-less request stays refused.
81
115
  */
82
116
  function browserSameOriginMarker(req: IncomingMessage): boolean {
83
117
  const site = req.headers['sec-fetch-site']
84
- return site === 'same-origin' || typeof req.headers.origin === 'string'
118
+ if (site === 'same-origin') return true
119
+ if (typeof req.headers.origin === 'string') return true
120
+ return carriesBrowserAuthCookie(req.headers.cookie)
85
121
  }
86
122
 
87
123
  function sameAuthority(req: IncomingMessage, host: URL): boolean {