@linxin666/dsh-client-ui-task-board 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +24 -0
- package/README.zh.md +24 -0
- package/lib/client.js +917 -81
- package/lib/client.js.map +1 -1
- package/lib/index.js +1774 -1420
- package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
- package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
- package/lib/types/client/board/NewTaskModal.d.ts +7 -1
- package/lib/types/client/board/TaskCard.d.ts +11 -1
- package/lib/types/client/locales.d.ts +33 -0
- package/lib/types/core/controller.d.ts +30 -1
- package/lib/types/core/subtask.d.ts +122 -0
- package/lib/types/core/tasks.d.ts +46 -3
- package/lib/types/core/use-cases/task-archive.d.ts +10 -13
- package/lib/types/core/use-cases/task-create.d.ts +13 -4
- package/lib/types/core/use-cases/task-delete.d.ts +7 -1
- package/lib/types/core/use-cases/task-parent.d.ts +21 -0
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host/agent-tools.d.ts +27 -0
- package/lib/types/host-ledger.d.ts +88 -4
- package/lib/types/host-runner.d.ts +39 -1
- package/lib/types/host-service.d.ts +47 -0
- package/lib/types/index.d.ts +83 -5
- package/lib/types/protocol.d.ts +14 -0
- package/package.json +23 -21
- package/src/client/TaskBoardSettingsCard.tsx +48 -3
- package/src/client/board/LinkSubtaskModal.tsx +82 -0
- package/src/client/board/NewTaskModal.tsx +40 -6
- package/src/client/board/TaskBoard.tsx +88 -8
- package/src/client/board/TaskCard.tsx +52 -2
- package/src/client/board/TaskDetail.tsx +124 -1
- package/src/client/board.module.css +96 -0
- package/src/client/host-api.ts +14 -4
- package/src/client/index.ts +11 -1
- package/src/client/locales.ts +70 -2
- package/src/core/controller.ts +69 -8
- package/src/core/store.ts +15 -0
- package/src/core/subtask.ts +286 -0
- package/src/core/tasks.ts +50 -2
- package/src/core/use-cases/task-archive.ts +46 -12
- package/src/core/use-cases/task-create.ts +42 -7
- package/src/core/use-cases/task-delete.ts +11 -1
- package/src/core/use-cases/task-parent.ts +64 -0
- package/src/core/use-cases/task-update.ts +3 -1
- package/src/host/agent-tools.ts +643 -0
- package/src/host-ledger.ts +323 -45
- package/src/host-runner.ts +141 -12
- package/src/host-service.ts +127 -10
- package/src/index.ts +181 -9
- package/src/protocol.ts +26 -2
|
@@ -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
|
+
}
|
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:
|
|
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:
|
|
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
|
|
4
|
-
* transcript references, while archiving disarms any schedule so
|
|
5
|
-
* create more execution records until the user restores and
|
|
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
|
|
20
|
-
* still owns its lifecycle until the execution settles);
|
|
21
|
-
* can be archived. Archiving disarms
|
|
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
|
|
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
|
-
/**
|
|
48
|
-
|
|
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
|
|
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
|
|
4
|
-
* orchestrates those), so it is
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|