@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
package/src/core/tasks.ts CHANGED
@@ -10,6 +10,9 @@ import type { TaskHandover, TaskHandoverInput } from './handover.ts'
10
10
  /** Task lifecycle status, one per kanban column. */
11
11
  export type TaskStatus = 'backlog' | 'todo' | 'running' | 'done' | 'failed'
12
12
 
13
+ /** Settled outcome of one execution attempt. */
14
+ export type ExecutionOutcome = 'succeeded' | 'failed' | 'cancelled'
15
+
13
16
  /**
14
17
  * One real execution attempt: the run's own id, the dsh session that ran it
15
18
  * (filled once the session is created), and the settled outcome once the
@@ -25,7 +28,7 @@ export interface ExecutionRecord {
25
28
  /** When the run settled; absent while still running. */
26
29
  endedAt: number | undefined
27
30
  /** Outcome once settled. */
28
- result: 'succeeded' | 'failed' | 'cancelled' | undefined
31
+ result: ExecutionOutcome | undefined
29
32
  /** Human failure text when the run failed (prompt rejection or agent error). */
30
33
  error: string | undefined
31
34
  /**
@@ -38,6 +41,20 @@ export interface ExecutionRecord {
38
41
  frozenAt?: number
39
42
  /** Freeze source session captured from the card snapshot when the run opened. */
40
43
  frozenBy?: string
44
+ /**
45
+ * Cascade run group shared by the parent execution and every descendant
46
+ * execution one run request opened. Absent on a plain single-task run.
47
+ */
48
+ runGroupId?: string
49
+ /**
50
+ * A cascade parent's own session outcome, recorded as soon as its own turn
51
+ * settles and held while its subtask executions finish. The execution stays
52
+ * open (no `endedAt`) until the last child settles; the Host monitor skips
53
+ * an execution whose own outcome is already recorded.
54
+ */
55
+ ownResult?: ExecutionOutcome
56
+ /** Human failure text that arrived with {@link ownResult}. */
57
+ ownError?: string
41
58
  }
42
59
 
43
60
  /**
@@ -203,6 +220,14 @@ export interface TaskRecord {
203
220
  description: string
204
221
  /** The prompt sent to dsh when this task is executed. */
205
222
  prompt: string
223
+ /**
224
+ * Parent task id: present only on a subtask. The Host owns the lineage gate
225
+ * (see `core/subtask.ts`), which refuses a cycle, an archived parent, and
226
+ * any link that would exceed `maxSubtaskDepth` — so every chain the Host
227
+ * wrote under the current setting is at most that many links long. Lowering
228
+ * the limit later neither rewrites nor deletes an already stored link.
229
+ */
230
+ parentId?: string
206
231
  /** Current column. */
207
232
  status: TaskStatus
208
233
  /** Creation instant (ms epoch). */
@@ -244,6 +269,15 @@ export interface TaskRecord {
244
269
  * {@link reusableSessionId}).
245
270
  */
246
271
  reuseSession?: boolean
272
+ /**
273
+ * Run this task's subtree as an Agent Team instead of one Host-launched
274
+ * session per participant: the root becomes the Team Lead session and the
275
+ * Host spawns one teammate for each direct subtask inside it. Absent or false
276
+ * keeps the plain concurrent cascade. A deployment that does not serve the
277
+ * Agent Teams service refuses a team run instead of silently falling back,
278
+ * and a subtask's own permission pin cannot be honored in this mode.
279
+ */
280
+ teamRun?: boolean
247
281
  /**
248
282
  * Frozen context snapshot for a continuation card; absent on plain tasks.
249
283
  * Sanitized before it enters the ledger (redaction, slash-command taint,
@@ -302,6 +336,12 @@ export interface NewTaskInput {
302
336
  title: string
303
337
  description: string
304
338
  prompt: string
339
+ /**
340
+ * Parent task id, turning this creation into a subtask. Absent keeps a root
341
+ * task; the create use case validates the link (existence, depth) and
342
+ * inherits the parent's unset execution targets.
343
+ */
344
+ parentId?: string
305
345
  /** Workspace the execution must run in; empty/absent = the recent workspace. */
306
346
  workspaceId?: string
307
347
  /** Agent preset the execution session must be composed from; empty/absent = deployment default. */
@@ -312,6 +352,8 @@ export interface NewTaskInput {
312
352
  model?: string
313
353
  /** Reuse the previous execution's session for later runs (issue #1419). */
314
354
  reuseSession?: boolean
355
+ /** Run the subtree as an Agent Team (Team Lead session plus one teammate per direct subtask). */
356
+ teamRun?: boolean
315
357
  /**
316
358
  * Optional scheduled-run rule requested at creation time (the new-task
317
359
  * dialog): an enable flag plus a 5-field cron expression. The create use
@@ -397,6 +439,7 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
397
439
  title: input.title.trim(),
398
440
  description: input.description.trim(),
399
441
  prompt: input.prompt.trim(),
442
+ parentId: normalizeTargetId(input.parentId),
400
443
  status: 'todo',
401
444
  createdAt: now,
402
445
  updatedAt: now,
@@ -406,6 +449,7 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
406
449
  permission: isTaskPermission(input.permission) ? input.permission : undefined,
407
450
  model: normalizeTargetId(input.model),
408
451
  reuseSession: input.reuseSession === true ? true : undefined,
452
+ teamRun: input.teamRun === true ? true : undefined,
409
453
  ...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
410
454
  ...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
411
455
  ...(tags === undefined ? {} : { tags }),
@@ -445,12 +489,15 @@ export function withSchedule(
445
489
  /**
446
490
  * Open a fresh execution on a task: move it to 'running' and append a
447
491
  * running execution record. Returns the new task and the new execution.
492
+ * @param runGroupId - cascade run group, when this run also opened executions
493
+ * for the task's subtasks (see `core/subtask.ts`).
448
494
  */
449
495
  export function startExecution(
450
496
  task: TaskRecord,
451
497
  now: number,
452
498
  executionId: string,
453
499
  initiatedBy?: string,
500
+ runGroupId?: string,
454
501
  ): { task: TaskRecord; execution: ExecutionRecord } {
455
502
  const execution: ExecutionRecord = {
456
503
  id: executionId,
@@ -459,6 +506,7 @@ export function startExecution(
459
506
  endedAt: undefined,
460
507
  result: undefined,
461
508
  error: undefined,
509
+ ...(runGroupId === undefined || runGroupId === '' ? {} : { runGroupId }),
462
510
  ...(initiatedBy === undefined || initiatedBy === '' ? {} : { initiatedBy }),
463
511
  // Capture the card's freeze provenance on the execution record so the
464
512
  // audit trail stays queryable even if the snapshot is replaced later.
@@ -486,7 +534,7 @@ export function startExecution(
486
534
  export function settleExecution(
487
535
  task: TaskRecord,
488
536
  executionId: string,
489
- outcome: 'succeeded' | 'failed' | 'cancelled',
537
+ outcome: ExecutionOutcome,
490
538
  now: number,
491
539
  error: string | undefined,
492
540
  ): TaskRecord {
@@ -1,9 +1,16 @@
1
1
  /**
2
2
  * Archive/restore task use case: move a task off the main board and back,
3
- * whatever its status but `running`. The task keeps its status, execution history, and
4
- * transcript references, while archiving disarms any schedule so it cannot
5
- * create more execution records until the user restores and re-enables it.
3
+ * whatever its status but running. The task keeps its status, execution
4
+ * history, and transcript references, while archiving disarms any schedule so
5
+ * it cannot create more execution records until the user restores and
6
+ * re-enables it.
7
+ *
8
+ * Subtask trees move as a unit: archiving takes the whole subtree (an on-board
9
+ * subtask under an archived parent would be unreachable from its parent) and
10
+ * refuses the whole group while any member is running. Restoring brings the
11
+ * task, its ancestors and its subtree back together.
6
12
  */
13
+ import { DEFAULT_SUBTASK_DEPTH, ancestorChain, descendantTasks, normalizeSubtaskDepth } from '../subtask.ts'
7
14
  import type { TaskRecord } from '../tasks.ts'
8
15
  import { ARCHIVABLE_STATUSES } from '../tasks.ts'
9
16
 
@@ -15,21 +22,37 @@ export interface ArchiveTaskResult {
15
22
  archived: boolean
16
23
  }
17
24
 
25
+ /** The archive/restore id set: the task, its subtree, and (for restore) its ancestors. */
26
+ function groupIds(tasks: readonly TaskRecord[], id: string, maxSubtaskDepth: number, withAncestors: boolean): Set<string> {
27
+ const target = tasks.find(task => task.id === id)
28
+ const ids = new Set<string>([id])
29
+ for (const task of descendantTasks(tasks, id, normalizeSubtaskDepth(maxSubtaskDepth))) ids.add(task.id)
30
+ if (withAncestors && target !== undefined) {
31
+ for (const ancestor of ancestorChain(tasks, target)) ids.add(ancestor.id)
32
+ }
33
+ return ids
34
+ }
35
+
18
36
  /**
19
- * Archive one task: only a `running` task stays on the board (its runner
20
- * still owns its lifecycle until the execution settles); every other status
21
- * can be archived. Archiving disarms a schedule; already-archived tasks are
22
- * a no-op.
37
+ * Archive one task and its subtasks: only a running member keeps the group on
38
+ * the board (its runner still owns its lifecycle until the execution settles);
39
+ * any other status can be archived. Archiving disarms schedules; an
40
+ * already-archived task is a no-op.
23
41
  */
24
42
  export function applyArchiveTask(
25
43
  tasks: readonly TaskRecord[],
26
44
  id: string,
27
45
  now: number,
46
+ maxSubtaskDepth: number = DEFAULT_SUBTASK_DEPTH,
28
47
  ): ArchiveTaskResult {
48
+ const target = tasks.find(task => task.id === id)
49
+ if (target === undefined || target.archivedAt !== undefined) return { tasks, archived: false }
50
+ const ids = groupIds(tasks, id, maxSubtaskDepth, false)
51
+ const declining = tasks.some(task => ids.has(task.id) && !(ARCHIVABLE_STATUSES as readonly string[]).includes(task.status))
52
+ if (declining) return { tasks, archived: false }
29
53
  let applied = false
30
54
  const next = tasks.map(task => {
31
- if (task.id !== id || task.archivedAt !== undefined) return task
32
- if (!(ARCHIVABLE_STATUSES as readonly string[]).includes(task.status)) return task
55
+ if (!ids.has(task.id) || task.archivedAt !== undefined) return task
33
56
  applied = true
34
57
  const schedule = task.schedule === undefined
35
58
  ? undefined
@@ -44,11 +67,22 @@ export function applyArchiveTask(
44
67
  return { tasks: next, archived: applied }
45
68
  }
46
69
 
47
- /** Restore one task back onto the main board (clears the archive marker). */
48
- export function applyRestoreTask(tasks: readonly TaskRecord[], id: string, now: number): ArchiveTaskResult {
70
+ /**
71
+ * Restore one task back onto the main board, together with its ancestors (an
72
+ * on-board subtask must not point at an archived parent) and its subtasks.
73
+ */
74
+ export function applyRestoreTask(
75
+ tasks: readonly TaskRecord[],
76
+ id: string,
77
+ now: number,
78
+ maxSubtaskDepth: number = DEFAULT_SUBTASK_DEPTH,
79
+ ): ArchiveTaskResult {
80
+ const target = tasks.find(task => task.id === id)
81
+ if (target === undefined || target.archivedAt === undefined) return { tasks, archived: false }
82
+ const ids = groupIds(tasks, id, maxSubtaskDepth, true)
49
83
  let applied = false
50
84
  const next = tasks.map(task => {
51
- if (task.id !== id || task.archivedAt === undefined) return task
85
+ if (!ids.has(task.id) || task.archivedAt === undefined) return task
52
86
  applied = true
53
87
  const { archivedAt: _archived, ...rest } = task
54
88
  return { ...rest, updatedAt: now }
@@ -1,35 +1,70 @@
1
1
  /**
2
2
  * Create-task use case: mint a new task from user input, rejecting a blank
3
- * title. Pure ledger transition (no persistence or notify — the controller
4
- * orchestrates those), so it is unit-testable without any runtime face.
3
+ * title and any parent link the lineage gate refuses. Pure ledger transition
4
+ * (no persistence or notify - the controller orchestrates those), so it is
5
+ * unit-testable without any runtime face.
5
6
  */
6
7
  import { isValidCron, nextRunAtMs } from '../schedule.ts'
8
+ import { DEFAULT_SUBTASK_DEPTH, normalizeSubtaskDepth, taskDepth } from '../subtask.ts'
7
9
  import { createTask, withSchedule, type NewTaskInput, type TaskRecord } from '../tasks.ts'
8
10
 
9
11
  /** Result of a create transition: the new task (when accepted) + the next ledger. */
10
12
  export interface CreateTaskResult {
11
- /** The minted task, or undefined when the input was rejected (blank title). */
13
+ /** The minted task, or undefined when the input was rejected. */
12
14
  task: TaskRecord | undefined
13
15
  /** The next ledger; identical reference when rejected. */
14
16
  tasks: readonly TaskRecord[]
17
+ /** Why the input was rejected; absent when a task was minted. */
18
+ error?: string
15
19
  }
16
20
 
17
21
  /**
18
22
  * Apply a create against the current ledger. Returns the new task and the
19
- * appended ledger, or the unchanged ledger when the title is blank.
23
+ * appended ledger, or the unchanged ledger when the title is blank or the
24
+ * requested parent link is refused.
25
+ *
26
+ * A subtask inherits the execution targets its parent already pins (workspace,
27
+ * agent preset, permission, model) unless the form supplied its own; the
28
+ * parent human permission confirmation travels with an inherited permission,
29
+ * so a subtask of an already-confirmed elevated card is runnable at once.
20
30
  * @param tasks - current ledger.
21
- * @param input - raw user input (title/description/prompt + optional schedule).
31
+ * @param input - raw user input (title/description/prompt + optional schedule/parent).
22
32
  * @param now - clock instant (ms epoch).
23
33
  * @param id - minted task id.
34
+ * @param maxSubtaskDepth - deployment subtask depth limit.
24
35
  */
25
36
  export function applyCreateTask(
26
37
  tasks: readonly TaskRecord[],
27
38
  input: NewTaskInput,
28
39
  now: number,
29
40
  id: string,
41
+ maxSubtaskDepth: number = DEFAULT_SUBTASK_DEPTH,
30
42
  ): CreateTaskResult {
31
- if (input.title.trim() === '') return { task: undefined, tasks }
32
- let task = createTask(input, now, id)
43
+ if (input.title.trim() === '') return { task: undefined, tasks, error: 'title is required' }
44
+ const limit = normalizeSubtaskDepth(maxSubtaskDepth)
45
+ let parent: TaskRecord | undefined
46
+ if (input.parentId !== undefined) {
47
+ parent = tasks.find(task => task.id === input.parentId)
48
+ if (parent === undefined) return { task: undefined, tasks, error: 'parent task not found' }
49
+ if (parent.archivedAt !== undefined) return { task: undefined, tasks, error: 'parent task is archived' }
50
+ if (taskDepth(tasks, parent.id) + 1 > limit) {
51
+ return { task: undefined, tasks, error: `subtask depth limit (${limit}) exceeded` }
52
+ }
53
+ }
54
+ // A subtask copies the execution targets its parent pins so the card shows
55
+ // what it will run with — EXCEPT the permission. A copied permission would
56
+ // also have to copy the parent's human confirmation to stay runnable, and the
57
+ // child would keep both after being detached: one confirmed parent would mint
58
+ // unbounded confirmed elevated roots. The permission is resolved live from the
59
+ // attached ancestors at launch instead (see core/subtask.ts), so detaching
60
+ // removes the inheritance by construction.
61
+ const effective: NewTaskInput = parent === undefined ? input : {
62
+ ...input,
63
+ workspaceId: input.workspaceId ?? parent.workspaceId,
64
+ mode: input.mode ?? parent.mode,
65
+ model: input.model ?? parent.model,
66
+ }
67
+ let task = createTask(effective, now, id)
33
68
  // Arm the requested schedule (new-task dialog): only an enabled rule with
34
69
  // a valid cron is applied; blank, invalid, or disabled requests leave the
35
70
  // task unscheduled.
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * Delete-task use case: drop a task from the ledger and clear the selection
3
3
  * when it pointed at the removed task. Pure ledger transition (no persistence
4
- * or notify — the controller orchestrates those).
4
+ * or notify - the controller orchestrates those).
5
+ *
6
+ * A task that still has subtasks is refused: deleting the parent would leave
7
+ * dangling child links, and silently detaching (or cascading) the children is
8
+ * a data-loss decision the user must make explicitly.
5
9
  */
6
10
  import type { TaskRecord } from '../tasks.ts'
7
11
 
@@ -11,6 +15,8 @@ export interface DeleteTaskResult {
11
15
  tasks: readonly TaskRecord[]
12
16
  /** Whether the previous selection referenced the removed task. */
13
17
  selectionCleared: boolean
18
+ /** True when the task still carries subtasks, so nothing was removed. */
19
+ blocked: boolean
14
20
  }
15
21
 
16
22
  /**
@@ -25,9 +31,13 @@ export function applyDeleteTask(
25
31
  selectedTaskId: string | undefined,
26
32
  id: string,
27
33
  ): DeleteTaskResult {
34
+ if (tasks.some(task => task.parentId === id)) {
35
+ return { tasks, selectionCleared: false, blocked: true }
36
+ }
28
37
  const next = tasks.filter(task => task.id !== id)
29
38
  return {
30
39
  tasks: next,
31
40
  selectionCleared: selectedTaskId === id,
41
+ blocked: false,
32
42
  }
33
43
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Parent-link use case: attach an existing on-board task under another as a
3
+ * subtask, or detach it back to the root. The lineage gate is the same one
4
+ * creation uses (unknown or archived parent, self, cycle, depth), so the two
5
+ * ways into the tree cannot disagree. Pure ledger transition.
6
+ */
7
+ import { DEFAULT_SUBTASK_DEPTH, checkParentLink, normalizeSubtaskDepth, type ParentLinkRejection } from '../subtask.ts'
8
+ import type { TaskRecord } from '../tasks.ts'
9
+
10
+ /** Result of a parent-link transition. */
11
+ export interface SetParentResult {
12
+ /** The next ledger; the input reference when refused. */
13
+ tasks: readonly TaskRecord[]
14
+ /** Whether the link (or the detachment) was stored. */
15
+ applied: boolean
16
+ /** User-facing refusal reason; absent when applied. */
17
+ error?: string
18
+ }
19
+
20
+ /** Host error text per refusal reason (the wire carries the same strings). */
21
+ const REJECTION_TEXT: Record<ParentLinkRejection, string> = {
22
+ 'unknown-task': 'task not found',
23
+ 'unknown-parent': 'parent task not found',
24
+ 'archived-parent': 'parent task is archived',
25
+ 'self-parent': 'a task cannot be its own parent',
26
+ 'cycle': 'a task cannot be attached under its own subtask',
27
+ 'depth-exceeded': 'subtask depth limit exceeded',
28
+ }
29
+
30
+ /**
31
+ * Attach or detach one task. A null (or blank) parent detaches, which is always
32
+ * allowed for a known on-board task; attaching validates the whole chain so a
33
+ * cyclic or too-deep ledger can never be stored.
34
+ * @param tasks - current ledger.
35
+ * @param id - the task being moved.
36
+ * @param parentId - the new parent, or null to detach.
37
+ * @param now - clock instant (ms epoch).
38
+ * @param maxSubtaskDepth - deployment subtask depth limit.
39
+ */
40
+ export function applySetParent(
41
+ tasks: readonly TaskRecord[],
42
+ id: string,
43
+ parentId: string | null,
44
+ now: number,
45
+ maxSubtaskDepth: number = DEFAULT_SUBTASK_DEPTH,
46
+ ): SetParentResult {
47
+ const task = tasks.find(candidate => candidate.id === id)
48
+ if (task === undefined) return { tasks, applied: false, error: REJECTION_TEXT['unknown-task'] }
49
+ if (task.archivedAt !== undefined) return { tasks, applied: false, error: 'archived task is read-only' }
50
+ // A task with an open execution may be a participant of a running cascade.
51
+ // Moving it would break that group's parent walk and leave the parent card
52
+ // in the running column forever, so the link is frozen until it settles.
53
+ if (task.status === 'running' || task.executions.some(execution => execution.endedAt === undefined)) {
54
+ return { tasks, applied: false, error: 'running task cannot be re-parented' }
55
+ }
56
+ const check = checkParentLink(tasks, id, parentId, normalizeSubtaskDepth(maxSubtaskDepth))
57
+ if (!check.ok) return { tasks, applied: false, error: REJECTION_TEXT[check.reason ?? 'unknown-task'] }
58
+ const nextParentId = parentId === null || parentId === '' ? undefined : parentId
59
+ if (nextParentId === task.parentId) return { tasks: [...tasks], applied: true }
60
+ return {
61
+ tasks: tasks.map(candidate => candidate.id === id ? { ...candidate, parentId: nextParentId, updatedAt: now } : candidate),
62
+ applied: true,
63
+ }
64
+ }
@@ -16,7 +16,7 @@ import type { TaskHandoverInput } from '../handover.ts'
16
16
  * Editable fields on a task (the update patch surface). `freeze` replaces the
17
17
  * continuation-card snapshot (restamping frozenAt); an explicit null clears it.
18
18
  */
19
- export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession'>> & {
19
+ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun'>> & {
20
20
  freeze?: FreezeSnapshot & { redacted?: boolean } | null
21
21
  /** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
22
22
  handover?: TaskHandoverInput | null
@@ -106,6 +106,8 @@ export function applyUpdateTask(
106
106
  // Session reuse is a boolean opt-in; false (or an explicit null from a
107
107
  // caller that needs to clear it) returns the task to one session per run.
108
108
  if ('reuseSession' in patch) next.reuseSession = patch.reuseSession === true ? true : undefined
109
+ // Team execution is a boolean opt-in with the same clear-by-false rule.
110
+ if ('teamRun' in patch) next.teamRun = patch.teamRun === true ? true : undefined
109
111
  if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
110
112
  if (mode !== undefined || 'mode' in patch) next.mode = mode
111
113
  if (permission !== undefined || 'permission' in patch) next.permission = permission