@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.
- package/README.i18n.yaml +2 -2
- package/README.md +28 -3
- package/README.zh.md +28 -3
- package/lib/client.js +1142 -467
- package/lib/client.js.map +1 -1
- package/lib/index.js +2066 -1475
- package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
- package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
- package/lib/types/client/board/NewTaskModal.d.ts +7 -1
- package/lib/types/client/board/TaskCard.d.ts +11 -1
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/locales.d.ts +33 -0
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +71 -3
- package/lib/types/core/subtask.d.ts +122 -0
- package/lib/types/core/tasks.d.ts +46 -3
- package/lib/types/core/use-cases/task-archive.d.ts +10 -13
- package/lib/types/core/use-cases/task-create.d.ts +13 -4
- package/lib/types/core/use-cases/task-delete.d.ts +7 -1
- package/lib/types/core/use-cases/task-parent.d.ts +21 -0
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host/agent-tools.d.ts +27 -0
- package/lib/types/host-ledger.d.ts +97 -4
- package/lib/types/host-runner.d.ts +39 -1
- package/lib/types/host-service.d.ts +98 -5
- package/lib/types/index.d.ts +93 -5
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +14 -0
- package/package.json +26 -21
- package/src/client/TaskBoardSettingsCard.tsx +48 -3
- package/src/client/board/LinkSubtaskModal.tsx +82 -0
- package/src/client/board/NewTaskModal.tsx +40 -6
- package/src/client/board/TaskBoard.tsx +88 -8
- package/src/client/board/TaskCard.tsx +52 -2
- package/src/client/board/TaskDetail.tsx +124 -1
- package/src/client/board.module.css +112 -131
- package/src/client/host-api.ts +14 -4
- package/src/client/index.ts +56 -18
- package/src/client/locales.ts +70 -2
- package/src/client/native-panel.tsx +124 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +128 -10
- package/src/core/store.ts +15 -0
- package/src/core/subtask.ts +286 -0
- package/src/core/tasks.ts +50 -2
- package/src/core/use-cases/task-archive.ts +46 -12
- package/src/core/use-cases/task-create.ts +42 -7
- package/src/core/use-cases/task-delete.ts +11 -1
- package/src/core/use-cases/task-parent.ts +64 -0
- package/src/core/use-cases/task-update.ts +3 -1
- package/src/host/agent-tools.ts +643 -0
- package/src/host-ledger.ts +343 -45
- package/src/host-routes.ts +37 -1
- package/src/host-runner.ts +141 -12
- package/src/host-service.ts +265 -32
- package/src/index.ts +203 -9
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +26 -2
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -53
package/src/core/controller.ts
CHANGED
|
@@ -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?:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
521
|
-
*
|
|
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 =
|
|
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
|
+
}
|