@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
@@ -17,7 +17,9 @@ import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
17
17
  import { applyCreateTask } from './use-cases/task-create.ts'
18
18
  import { applyDeleteTask } from './use-cases/task-delete.ts'
19
19
  import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
20
+ import { applySetParent } from './use-cases/task-parent.ts'
20
21
  import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
22
+ import { DEFAULT_SUBTASK_DEPTH } from './subtask.ts'
21
23
  import type {
22
24
  TaskBoardAction,
23
25
  TaskBoardEventPayload,
@@ -60,6 +62,21 @@ function currentOf(sessions: SessionsControllerFace | undefined): string | undef
60
62
  return sessions?.current()
61
63
  }
62
64
 
65
+ /** Stable id shared by the board's sidebar panel row and its main-slot page. */
66
+ export const TASK_BOARD_PANEL_ID = 'task-board'
67
+
68
+ /**
69
+ * The layout's panel-navigation face. The board no longer owns the center
70
+ * column at the DOM level: selecting the panel is the layout's business, and
71
+ * this is the one call that asks for it (null returns to the conversation).
72
+ * Optional, so a composition without the layout service keeps the board's
73
+ * pure state transitions testable.
74
+ */
75
+ export interface PanelNavigationFace {
76
+ /** Select the board panel, or null to hand the column back to the conversation. */
77
+ select(panelId: string | null): void
78
+ }
79
+
63
80
  /** Controller dependencies (all swappable in tests). */
64
81
  export interface ControllerDeps {
65
82
  store: TaskStore
@@ -70,6 +87,8 @@ export interface ControllerDeps {
70
87
  uuid?: () => string
71
88
  /** Host-authoritative transport; absent keeps the legacy in-memory test path. */
72
89
  transport?: TaskBoardTransport
90
+ /** Layout panel selection; absent keeps the board state-only (tests, shell-less hosts). */
91
+ panel?: PanelNavigationFace
73
92
  }
74
93
 
75
94
  /**
@@ -111,6 +130,13 @@ export interface ExecutionOptionsSnapshot {
111
130
  models?: readonly ExecutionModelOption[]
112
131
  }
113
132
 
133
+ /**
134
+ * Host-owned state the browser mirrors. SSE frames carry the volatile subset
135
+ * (revision/scheduler/power); a full snapshot also carries the deployment
136
+ * constants the UI reads (session default permission, subtask depth).
137
+ */
138
+ export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable'>
139
+
114
140
  /** Immutable controller snapshot for UI subscriptions. */
115
141
  export interface ControllerSnapshot {
116
142
  tasks: readonly TaskRecord[]
@@ -126,7 +152,7 @@ export interface ControllerSnapshot {
126
152
  /** Whether this deployment can parse pasted text into task fields (issue #1540). */
127
153
  canParseTask?: boolean
128
154
  transportError?: string
129
- host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'>
155
+ host?: HostMirror
130
156
  }
131
157
 
132
158
  /** The selected task (resolved from the ledger), or undefined. */
@@ -173,7 +199,7 @@ export class BoardController {
173
199
  private readonly pendingTaskIds = new Set<string>()
174
200
  private readonly taskQueues = new Map<string, Promise<void>>()
175
201
  private transportError: string | undefined
176
- private hostState: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'> | undefined
202
+ private hostState: HostMirror | undefined
177
203
  private remoteSubscribed = false
178
204
  private remoteInitialization: Promise<boolean> | undefined
179
205
 
@@ -244,15 +270,31 @@ export class BoardController {
244
270
 
245
271
  // --- view state -------------------------------------------------------------
246
272
 
273
+ /**
274
+ * Show the board. The layout owns which panel the center column renders, so
275
+ * the state flip and the panel selection travel together here; a composition
276
+ * with no layout face (tests, a shell-less host) still flips the state.
277
+ *
278
+ * The selection is requested AFTER the snapshot flips so a subscriber
279
+ * rendering against `boardOpen` never observes "open" while the shell still
280
+ * shows the conversation.
281
+ */
247
282
  openBoard(): void {
248
283
  if (this.boardOpen) return
249
284
  this.boardOpen = true
250
285
  this.notify()
286
+ this.selectPanel(TASK_BOARD_PANEL_ID)
251
287
  }
252
288
 
289
+ /**
290
+ * Return the center column to the conversation. The layout's own selection is
291
+ * the source of truth for what the column renders, so this asks for the
292
+ * conversation explicitly rather than only clearing local state.
293
+ */
253
294
  closeBoard(): void {
254
295
  this.boardOpen = false
255
296
  this.notify()
297
+ this.selectPanel(null)
256
298
  }
257
299
 
258
300
  toggleBoard(): void {
@@ -260,6 +302,30 @@ export class BoardController {
260
302
  else this.openBoard()
261
303
  }
262
304
 
305
+ /**
306
+ * Reflect a panel selection that came from OUTSIDE this controller (the user
307
+ * clicked another sidebar row, or the layout dropped the panel id). Keeps
308
+ * `boardOpen` aligned with what the column actually shows without asking the
309
+ * layout to select anything back.
310
+ * @param panelId - the layout's current panel id, or null for the conversation.
311
+ */
312
+ syncPanelSelection(panelId: string | null): void {
313
+ const open = panelId === TASK_BOARD_PANEL_ID
314
+ if (open === this.boardOpen) return
315
+ this.boardOpen = open
316
+ this.notify()
317
+ }
318
+
319
+ /** Ask the layout to select a panel; a shell that serves no layout face is a no-op. */
320
+ private selectPanel(panelId: string | null): void {
321
+ try {
322
+ this.deps.panel?.select(panelId)
323
+ } catch {
324
+ // The layout service throws by contract before its root entry mounts;
325
+ // the state flip above already stands, and the next selection retries.
326
+ }
327
+ }
328
+
263
329
  /**
264
330
  * Switch between the kanban columns and the archive view. Leaving the
265
331
  * archive view with an archived task still selected closes the selection —
@@ -291,7 +357,7 @@ export class BoardController {
291
357
 
292
358
  createTask(input: NewTaskInput): TaskRecord | undefined {
293
359
  const id = this.uuid()
294
- const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id)
360
+ const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth())
295
361
  if (task === undefined) return undefined
296
362
  this.tasks = [...tasks]
297
363
  this.persistAndNotify()
@@ -302,7 +368,10 @@ export class BoardController {
302
368
  async createTaskConfirmed(input: NewTaskInput): Promise<TaskRecord | undefined> {
303
369
  if (this.deps.transport === undefined) return this.createTask(input)
304
370
  const id = this.uuid()
305
- const preview = applyCreateTask(this.tasks, input, this.now(), id).task
371
+ // The preview must obey the SAME lineage gate the Host will: a browser-side
372
+ // default of one level would reject a legal subtask-of-subtask before the
373
+ // request is ever sent.
374
+ const preview = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth()).task
306
375
  if (preview === undefined) return undefined
307
376
  return await this.commitRemote({ kind: 'create', id, input }, id)
308
377
  ? this.tasks.find(task => task.id === id)
@@ -392,7 +461,7 @@ export class BoardController {
392
461
  * @returns true when applied.
393
462
  */
394
463
  archiveTask(id: string): boolean {
395
- const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now())
464
+ const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now(), this.mirrorDepth())
396
465
  if (!archived) return false
397
466
  if (this.deps.transport !== undefined) {
398
467
  void this.commitRemote({ kind: 'archive', taskId: id }, id)
@@ -403,9 +472,27 @@ export class BoardController {
403
472
  return true
404
473
  }
405
474
 
475
+ /**
476
+ * Attach an existing task under a parent (or detach it with a null parent)
477
+ * through the Host. The lineage gate — parent exists and is on-board, no
478
+ * cycle, depth within the deployment limit — belongs to the Host; a refusal
479
+ * surfaces through the transport error like every other rejected action.
480
+ * @returns true when the link was accepted by the authority.
481
+ */
482
+ async setParent(id: string, parentId: string | null): Promise<boolean> {
483
+ if (this.deps.transport === undefined) {
484
+ const result = applySetParent(this.tasks, id, parentId, this.now(), this.mirrorDepth())
485
+ if (!result.applied) return false
486
+ this.tasks = [...result.tasks]
487
+ this.persistAndNotify()
488
+ return true
489
+ }
490
+ return await this.commitRemote({ kind: 'set-parent', taskId: id, parentId }, id)
491
+ }
492
+
406
493
  /** Restore an archived task back onto the board (same status column). */
407
494
  restoreTask(id: string): boolean {
408
- const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now())
495
+ const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now(), this.mirrorDepth())
409
496
  if (!archived) return false
410
497
  if (this.deps.transport !== undefined) {
411
498
  void this.commitRemote({ kind: 'restore', taskId: id }, id).then(restored => {
@@ -517,8 +604,8 @@ export class BoardController {
517
604
  * (background navigation, the Host runner creating and selecting a fresh
518
605
  * execution session, settlement, other plugins), so closing on `current`
519
606
  * changes would evict the board without the user asking. The board closes
520
- * only on explicit user navigation: a sidebar session/workspace row click
521
- * (board-mount onClickSidebarRow) or the board's own actions
607
+ * only on explicit user navigation: selecting another panel (the layout owns
608
+ * selection, and `syncPanelSelection` follows it) or the board's own actions
522
609
  * (openSession / close). Keeping the hook preserves the subscription
523
610
  * contract for future listeners.
524
611
  */
@@ -604,13 +691,44 @@ export class BoardController {
604
691
  if (event !== undefined && this.hostState !== undefined && event.revision === this.hostState.revision
605
692
  && typeof event.scheduler === 'object' && event.scheduler !== null
606
693
  && typeof event.power === 'object' && event.power !== null) {
607
- this.hostState = { revision: event.revision, scheduler: event.scheduler, power: event.power }
694
+ this.hostState = { ...this.hostState, revision: event.revision, scheduler: event.scheduler, power: event.power }
608
695
  this.notify()
609
696
  return
610
697
  }
611
698
  void this.refreshRemote()
612
699
  }
613
700
 
701
+ /**
702
+ * Deployment subtask depth limit the browser mirrors from the last Host
703
+ * snapshot; the fallback is the deployment default for the legacy path (no
704
+ * transport) and for the window before the first snapshot arrives.
705
+ */
706
+ private mirrorDepth(): number {
707
+ return this.hostState?.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH
708
+ }
709
+
710
+ /**
711
+ * Project a Host snapshot onto the browser's mirror. SSE frames and partial
712
+ * snapshots carry only the volatile subset (revision/scheduler/power), so the
713
+ * deployment constants the UI reads — the session-default permission the
714
+ * confirmation banner compares against and the subtask depth limit — are
715
+ * carried over from the last full snapshot instead of being dropped by a
716
+ * heartbeat frame.
717
+ */
718
+ private mirrorOf(snapshot: TaskBoardSnapshot): HostMirror {
719
+ const sessionDefaultPermission = snapshot.sessionDefaultPermission ?? this.hostState?.sessionDefaultPermission
720
+ const maxSubtaskDepth = snapshot.maxSubtaskDepth ?? this.hostState?.maxSubtaskDepth
721
+ const teamRunAvailable = snapshot.teamRunAvailable ?? this.hostState?.teamRunAvailable
722
+ return {
723
+ revision: snapshot.revision,
724
+ scheduler: snapshot.scheduler,
725
+ power: snapshot.power,
726
+ ...(sessionDefaultPermission === undefined ? {} : { sessionDefaultPermission }),
727
+ ...(maxSubtaskDepth === undefined ? {} : { maxSubtaskDepth }),
728
+ ...(teamRunAvailable === undefined ? {} : { teamRunAvailable }),
729
+ }
730
+ }
731
+
614
732
  private async refreshRemote(preserveError?: string): Promise<boolean> {
615
733
  const transport = this.deps.transport
616
734
  if (transport === undefined) return true
@@ -634,7 +752,7 @@ export class BoardController {
634
752
  const sameGeneration = currentLedgerId === nextLedgerId
635
753
  if (sameGeneration && this.hostState !== undefined && snapshot.revision < this.hostState.revision) return false
636
754
  this.tasks = [...snapshot.tasks]
637
- this.hostState = { revision: snapshot.revision, scheduler: snapshot.scheduler, power: snapshot.power }
755
+ this.hostState = this.mirrorOf(snapshot)
638
756
  this.transportError = undefined
639
757
  if (this.selectedTaskId !== undefined && !this.tasks.some(task => task.id === this.selectedTaskId)) {
640
758
  this.selectedTaskId = undefined
package/src/core/store.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { isValidCron } from './schedule.ts'
13
13
  import { isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
14
+ import { isExecutionOutcome } from './subtask.ts'
14
15
  import type { TaskHandover } from './handover.ts'
15
16
  import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
16
17
  import { sanitizeHandover } from './handover.ts'
@@ -62,6 +63,7 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
62
63
  if (typeof record.prompt !== 'string') return false
63
64
  if (typeof record.createdAt !== 'number') return false
64
65
  if (typeof record.updatedAt !== 'number') return false
66
+ if (record.parentId !== undefined && typeof record.parentId !== 'string') return false
65
67
  if (record.workspaceId !== undefined && typeof record.workspaceId !== 'string') return false
66
68
  if (record.mode !== undefined && typeof record.mode !== 'string') return false
67
69
  if (record.permission !== undefined && typeof record.permission !== 'string') return false
@@ -179,6 +181,19 @@ export function parseLedger(raw: string | null): TaskRecord[] {
179
181
  // clear a malformed persisted rule rather than leave it in the row.
180
182
  const task: TaskRecord = { ...row, status: normalizeStatus(row.status) }
181
183
  task.schedule = normalizeSchedule(row.schedule)
184
+ // The lineage link is repaired like every other optional field: a blank or
185
+ // non-string parent id clears the link instead of dropping the row. Whether
186
+ // the link is structurally sound (parent exists, no cycle) is decided where
187
+ // the whole list is known (HostTaskLedger.parseHostTasks).
188
+ task.parentId = normalizeTargetId(row.parentId)
189
+ // Cascade bookkeeping on execution records is repaired field by field too,
190
+ // so a hand-edited ledger degrades a run group instead of the whole task.
191
+ task.executions = task.executions.map(execution => ({
192
+ ...execution,
193
+ runGroupId: normalizeTargetId(execution.runGroupId),
194
+ ownResult: isExecutionOutcome(execution.ownResult) ? execution.ownResult : undefined,
195
+ ownError: typeof execution.ownError === 'string' ? execution.ownError : undefined,
196
+ }))
182
197
  // Execution targets are normalized like the schedule: blank strings
183
198
  // clear the pin and unknown permission strings from a future version
184
199
  // fall back to the session default instead of dropping the row.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Subtask lineage: the depth gate, the ancestry/descendant queries, the
3
+ * cascade participant set one run request opens, the deferred settlement of a
4
+ * cascade parent, and the execution-target inheritance a subtask resolves at
5
+ * run time.
6
+ *
7
+ * Framework-free (no cordis, no runtime imports) so every rule is
8
+ * unit-testable in isolation. The Host ledger is the only authority; the
9
+ * browser mirrors these predicates to enable or disable controls, never to
10
+ * decide them.
11
+ */
12
+ import type { ExecutionOutcome, ExecutionRecord, TaskRecord } from './tasks.ts'
13
+
14
+ /** Lowest configurable subtask depth. */
15
+ export const SUBTASK_DEPTH_MIN = 1
16
+ /** Highest configurable subtask depth. */
17
+ export const SUBTASK_DEPTH_MAX = 3
18
+ /** Deployment default: one level of subtasks, and no subtask of a subtask. */
19
+ export const DEFAULT_SUBTASK_DEPTH = 1
20
+
21
+ /**
22
+ * Clamp a configured depth into the supported range. Anything unusable
23
+ * (absent, non-numeric, not finite) falls back to the default, so a bad
24
+ * configuration narrows the tree instead of lifting the guard.
25
+ */
26
+ export function normalizeSubtaskDepth(value: unknown): number {
27
+ if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_SUBTASK_DEPTH
28
+ const depth = Math.trunc(value)
29
+ if (depth < SUBTASK_DEPTH_MIN) return SUBTASK_DEPTH_MIN
30
+ if (depth > SUBTASK_DEPTH_MAX) return SUBTASK_DEPTH_MAX
31
+ return depth
32
+ }
33
+
34
+ /** Whether an unknown value is a settled execution outcome. */
35
+ export function isExecutionOutcome(value: unknown): value is ExecutionOutcome {
36
+ return value === 'succeeded' || value === 'failed' || value === 'cancelled'
37
+ }
38
+
39
+ function indexOf(tasks: readonly TaskRecord[]): Map<string, TaskRecord> {
40
+ return new Map(tasks.map(task => [task.id, task]))
41
+ }
42
+
43
+ /**
44
+ * Nearest-first ancestor chain of a task. The walk is cycle-safe and bounded
45
+ * by the ledger size rather than by the deployment maximum: a chain stored
46
+ * under a larger limit survives that limit being lowered, and restore and
47
+ * inheritance must still see all of it.
48
+ */
49
+ export function ancestorChain(tasks: readonly TaskRecord[], task: TaskRecord): TaskRecord[] {
50
+ const index = indexOf(tasks)
51
+ const chain: TaskRecord[] = []
52
+ const seen = new Set<string>([task.id])
53
+ let current = task.parentId === undefined ? undefined : index.get(task.parentId)
54
+ while (current !== undefined && chain.length < tasks.length) {
55
+ if (seen.has(current.id)) break
56
+ seen.add(current.id)
57
+ chain.push(current)
58
+ current = current.parentId === undefined ? undefined : index.get(current.parentId)
59
+ }
60
+ return chain
61
+ }
62
+
63
+ /** Root-to-task depth: a root task is 0, its subtask 1, and so on. */
64
+ export function taskDepth(tasks: readonly TaskRecord[], id: string): number {
65
+ const task = indexOf(tasks).get(id)
66
+ return task === undefined ? 0 : ancestorChain(tasks, task).length
67
+ }
68
+
69
+ /** Direct subtasks of a task, in ledger order. */
70
+ export function directSubtasks(tasks: readonly TaskRecord[], id: string): TaskRecord[] {
71
+ return tasks.filter(task => task.parentId === id)
72
+ }
73
+
74
+ /**
75
+ * Descendants of a task in breadth-first order, bounded by maxSubtaskDepth
76
+ * total depth. The visited set keeps a malformed ledger from looping.
77
+ */
78
+ export function descendantTasks(tasks: readonly TaskRecord[], id: string, maxSubtaskDepth: number): TaskRecord[] {
79
+ const visited = new Set<string>([id])
80
+ const found: TaskRecord[] = []
81
+ let frontier = [id]
82
+ for (let depth = 1; depth <= maxSubtaskDepth && frontier.length > 0; depth += 1) {
83
+ const next: string[] = []
84
+ for (const parentId of frontier) {
85
+ for (const task of tasks) {
86
+ if (task.parentId !== parentId || visited.has(task.id)) continue
87
+ visited.add(task.id)
88
+ found.push(task)
89
+ next.push(task.id)
90
+ }
91
+ }
92
+ frontier = next
93
+ }
94
+ return found
95
+ }
96
+
97
+ /**
98
+ * Height of a task's own subtree (0 for a leaf), bounded by the hard maximum.
99
+ * The visited set makes the walk terminate on a cyclic hand-edited ledger
100
+ * instead of recursing until the stack overflows.
101
+ */
102
+ export function subtreeHeight(tasks: readonly TaskRecord[], id: string, visited: ReadonlySet<string> = new Set<string>()): number {
103
+ if (visited.has(id)) return 0
104
+ const seen = new Set([...visited, id])
105
+ const children = directSubtasks(tasks, id)
106
+ if (children.length === 0) return 0
107
+ let height = 0
108
+ for (const child of children) height = Math.max(height, 1 + subtreeHeight(tasks, child.id, seen))
109
+ return Math.min(height, SUBTASK_DEPTH_MAX)
110
+ }
111
+
112
+ /** Why a parent link was refused; also the source of the Host error text. */
113
+ export type ParentLinkRejection =
114
+ | 'unknown-task'
115
+ | 'unknown-parent'
116
+ | 'archived-parent'
117
+ | 'self-parent'
118
+ | 'cycle'
119
+ | 'depth-exceeded'
120
+
121
+ export interface ParentLinkCheck {
122
+ /** True when the link (or the detachment) may be stored. */
123
+ ok: boolean
124
+ /** Why it was refused; absent when ok. */
125
+ reason?: ParentLinkRejection
126
+ }
127
+
128
+ /**
129
+ * Whether childId may be attached under parentId. A null parent detaches and
130
+ * is always allowed for a known task. Otherwise the link must name an
131
+ * on-board task that is neither the child itself nor one of its descendants,
132
+ * and the resulting depth (parent depth + 1 + the child own subtree height)
133
+ * must stay within maxSubtaskDepth.
134
+ */
135
+ export function checkParentLink(
136
+ tasks: readonly TaskRecord[],
137
+ childId: string,
138
+ parentId: string | null,
139
+ maxSubtaskDepth: number,
140
+ ): ParentLinkCheck {
141
+ const child = tasks.find(task => task.id === childId)
142
+ if (child === undefined) return { ok: false, reason: 'unknown-task' }
143
+ if (parentId === null || parentId === '') return { ok: true }
144
+ if (parentId === childId) return { ok: false, reason: 'self-parent' }
145
+ const parent = tasks.find(task => task.id === parentId)
146
+ if (parent === undefined) return { ok: false, reason: 'unknown-parent' }
147
+ if (parent.archivedAt !== undefined) return { ok: false, reason: 'archived-parent' }
148
+ if (descendantTasks(tasks, childId, SUBTASK_DEPTH_MAX).some(task => task.id === parentId)) return { ok: false, reason: 'cycle' }
149
+ const depth = taskDepth(tasks, parentId) + 1 + subtreeHeight(tasks, childId)
150
+ if (depth > maxSubtaskDepth) return { ok: false, reason: 'depth-exceeded' }
151
+ return { ok: true }
152
+ }
153
+
154
+ /**
155
+ * The tasks one run request opens executions for: the requested task first,
156
+ * then its on-board descendants within the configured depth. Archived tasks
157
+ * never run; a participant that already has an open execution is skipped by
158
+ * the caller, which cannot start it twice.
159
+ */
160
+ export function cascadeTargets(
161
+ tasks: readonly TaskRecord[],
162
+ rootId: string,
163
+ maxSubtaskDepth: number,
164
+ ): TaskRecord[] {
165
+ const root = tasks.find(task => task.id === rootId)
166
+ if (root === undefined) return []
167
+ return [root, ...descendantTasks(tasks, rootId, maxSubtaskDepth)]
168
+ }
169
+
170
+ /** One still-open execution belonging to a cascade run group. */
171
+ export interface OpenCascadeChild {
172
+ taskId: string
173
+ execution: ExecutionRecord
174
+ }
175
+
176
+ /** The open execution of a task inside a run group, if it still exists. */
177
+ export function openGroupExecution(task: TaskRecord, runGroupId: string): ExecutionRecord | undefined {
178
+ return task.executions.find(execution => execution.runGroupId === runGroupId && execution.endedAt === undefined)
179
+ }
180
+
181
+ /**
182
+ * The direct subtasks of parentTaskId whose execution in this run group is
183
+ * still open: the parent remaining waiters. The parent may settle once this
184
+ * list is empty and its own outcome is known.
185
+ */
186
+ export function pendingCascadeChildren(
187
+ tasks: readonly TaskRecord[],
188
+ parentTaskId: string,
189
+ runGroupId: string,
190
+ ): OpenCascadeChild[] {
191
+ const pending: OpenCascadeChild[] = []
192
+ for (const task of tasks) {
193
+ if (task.parentId !== parentTaskId) continue
194
+ const execution = openGroupExecution(task, runGroupId)
195
+ if (execution !== undefined) pending.push({ taskId: task.id, execution })
196
+ }
197
+ return pending
198
+ }
199
+
200
+ /**
201
+ * Fold a cascade parent own outcome and its children outcomes into one
202
+ * verdict: failure dominates, then cancellation, then success. The error text
203
+ * is the first failure, falling back to the first cancellation.
204
+ */
205
+ export function combineCascadeOutcome(
206
+ entries: readonly { result: ExecutionOutcome; error?: string }[],
207
+ ): { result: ExecutionOutcome; error: string | undefined } {
208
+ let result: ExecutionOutcome = 'succeeded'
209
+ let error: string | undefined
210
+ for (const entry of entries) {
211
+ if (entry.result === 'failed') {
212
+ if (result !== 'failed') error = undefined
213
+ result = 'failed'
214
+ if (error === undefined && entry.error !== undefined && entry.error !== '') error = entry.error
215
+ continue
216
+ }
217
+ if (entry.result === 'cancelled') {
218
+ if (result === 'succeeded') result = 'cancelled'
219
+ if (error === undefined && entry.error !== undefined && entry.error !== '') error = entry.error
220
+ }
221
+ }
222
+ return error === undefined ? { result, error: undefined } : { result, error }
223
+ }
224
+
225
+ /**
226
+ * The permission a task would actually run under: its handover bundle's pin
227
+ * wins over the plain pin field, exactly as the runner resolves it.
228
+ */
229
+ export function effectiveTaskPermission(task: TaskRecord): TaskRecord['permission'] {
230
+ return task.handover?.permission ?? task.permission
231
+ }
232
+
233
+ /**
234
+ * Resolve the execution targets a (sub)task actually runs with: its own value
235
+ * wins, and an unset target inherits the nearest ancestor up to the root.
236
+ * Workspace, agent preset and model inherit the ancestor's PIN only — a
237
+ * handover bundle is a per-card continuation artefact and is never inherited.
238
+ * The permission inherits the ancestor's EFFECTIVE binding, and its human
239
+ * confirmation travels with it, so a subtask of an already-confirmed elevated
240
+ * card is runnable while a subtask's own unconfirmed binding stays gated.
241
+ */
242
+ /**
243
+ * The immutable teammate name for one team-mode member. Teammate names are
244
+ * lower-kebab-case and must stay unique for the lifetime of the Team, so the
245
+ * run-group token is appended: two runs of the same tree never collide. A title
246
+ * with no ASCII word characters (a CJK title, for example) keeps a stable
247
+ * generic prefix and is still distinguished by the token.
248
+ * @param title - the member task's title.
249
+ * @param token - the run group id (the task id when no group is recorded).
250
+ * @returns the teammate name handed to the Agent Teams service.
251
+ */
252
+ export function teammateName(title: string, token: string): string {
253
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '')
254
+ const suffix = token.replace(/[^a-z0-9]/gi, '').slice(0, 8).toLowerCase()
255
+ return `${slug === '' ? 'subtask' : slug}-${suffix === '' ? 'run' : suffix}`
256
+ }
257
+
258
+ export function resolveExecutionTargets(
259
+ task: TaskRecord,
260
+ tasks: readonly TaskRecord[],
261
+ maxSubtaskDepth: number = SUBTASK_DEPTH_MAX,
262
+ ): TaskRecord {
263
+ if (task.parentId === undefined) return task
264
+ const chain = [task, ...ancestorChain(tasks, task).slice(0, maxSubtaskDepth)]
265
+ const first = (key: 'workspaceId' | 'mode' | 'model'): string | undefined => {
266
+ for (const candidate of chain) {
267
+ const value = candidate[key]
268
+ if (value !== undefined) return value
269
+ }
270
+ return undefined
271
+ }
272
+ const own = effectiveTaskPermission(task)
273
+ // An own binding (pin or handover) keeps its own confirmation stamp; only an
274
+ // absent one borrows the ancestor's binding AND that ancestor's stamp.
275
+ const owner = own === undefined
276
+ ? chain.slice(1).find(candidate => effectiveTaskPermission(candidate) !== undefined)
277
+ : undefined
278
+ return {
279
+ ...task,
280
+ workspaceId: first('workspaceId'),
281
+ mode: first('mode'),
282
+ model: first('model'),
283
+ permission: own ?? (owner === undefined ? undefined : effectiveTaskPermission(owner)),
284
+ permissionConfirmedAt: own === undefined ? owner?.permissionConfirmedAt : task.permissionConfirmedAt,
285
+ }
286
+ }