@linxin666/dsh-client-ui-task-board 0.4.1 → 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
|
@@ -1320,3 +1320,99 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
1320
1320
|
.aiParseRow .primaryButton {
|
|
1321
1321
|
flex: none;
|
|
1322
1322
|
}
|
|
1323
|
+
|
|
1324
|
+
/* --- subtask tree (parent link, cascade runs) ---------------------------------
|
|
1325
|
+
* A subtask is an ordinary card with a parent link; the badges below only mark
|
|
1326
|
+
* the lineage on the board, and the detail block lists the direct children
|
|
1327
|
+
* with their statuses.
|
|
1328
|
+
* ----------------------------------------------------------------------------- */
|
|
1329
|
+
|
|
1330
|
+
.cardSubtask {
|
|
1331
|
+
max-width: 100%;
|
|
1332
|
+
padding: 0 7px;
|
|
1333
|
+
overflow: hidden;
|
|
1334
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
1335
|
+
border-radius: 999px;
|
|
1336
|
+
background: transparent;
|
|
1337
|
+
color: var(--dsw-alias-label-secondary);
|
|
1338
|
+
font-size: 10px;
|
|
1339
|
+
line-height: 16px;
|
|
1340
|
+
text-overflow: ellipsis;
|
|
1341
|
+
white-space: nowrap;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
.cardSubtask[data-tone='failed'] {
|
|
1345
|
+
color: var(--dsw-alias-state-error-primary);
|
|
1346
|
+
border-color: var(--dsw-alias-state-error-primary);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
.cardSubtask[data-tone='running'] {
|
|
1350
|
+
color: var(--dsw-alias-state-warn-primary);
|
|
1351
|
+
border-color: var(--dsw-alias-state-warn-primary);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
.cardSubtask[data-tone='done'] {
|
|
1355
|
+
color: var(--dsw-alias-state-success-primary);
|
|
1356
|
+
border-color: var(--dsw-alias-state-success-primary);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
.subtaskList {
|
|
1360
|
+
list-style: none;
|
|
1361
|
+
margin: 0;
|
|
1362
|
+
padding: 0;
|
|
1363
|
+
display: flex;
|
|
1364
|
+
flex-direction: column;
|
|
1365
|
+
gap: 6px;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
.subtaskRow {
|
|
1369
|
+
display: flex;
|
|
1370
|
+
align-items: center;
|
|
1371
|
+
gap: 10px;
|
|
1372
|
+
padding: 6px 10px;
|
|
1373
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
1374
|
+
border-radius: 8px;
|
|
1375
|
+
flex-wrap: wrap;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
.subtaskRow .linkButton:first-child {
|
|
1379
|
+
flex: 1 1 auto;
|
|
1380
|
+
min-width: 0;
|
|
1381
|
+
text-align: left;
|
|
1382
|
+
overflow-wrap: anywhere;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
.subtaskAddRow {
|
|
1386
|
+
display: flex;
|
|
1387
|
+
gap: 8px;
|
|
1388
|
+
flex-wrap: wrap;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
.pickList {
|
|
1392
|
+
list-style: none;
|
|
1393
|
+
margin: 0;
|
|
1394
|
+
padding: 0;
|
|
1395
|
+
display: flex;
|
|
1396
|
+
flex-direction: column;
|
|
1397
|
+
gap: 6px;
|
|
1398
|
+
max-height: 320px;
|
|
1399
|
+
overflow-y: auto;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
.pickRow {
|
|
1403
|
+
display: flex;
|
|
1404
|
+
align-items: center;
|
|
1405
|
+
gap: 10px;
|
|
1406
|
+
padding: 6px 10px;
|
|
1407
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
1408
|
+
border-radius: 8px;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
.pickTitle {
|
|
1412
|
+
flex: 1 1 auto;
|
|
1413
|
+
min-width: 0;
|
|
1414
|
+
font-size: 13px;
|
|
1415
|
+
color: var(--dsw-alias-label-primary);
|
|
1416
|
+
overflow-wrap: anywhere;
|
|
1417
|
+
}
|
|
1418
|
+
|
package/src/client/host-api.ts
CHANGED
|
@@ -11,6 +11,16 @@ import {
|
|
|
11
11
|
type TaskBoardSnapshot,
|
|
12
12
|
} from '../protocol.ts'
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Client-side base for the board's routes: DOCUMENT-RELATIVE (issue #1707).
|
|
16
|
+
*
|
|
17
|
+
* The host registers `TASK_BOARD_API_PREFIX` root-absolute; the page is served
|
|
18
|
+
* with `<base href="./">`, so the browser half must drop the leading slash or
|
|
19
|
+
* a sub-path deployment escapes its entry directory. Derived from the shared
|
|
20
|
+
* constant so host and client cannot drift.
|
|
21
|
+
*/
|
|
22
|
+
const CLIENT_API_PREFIX = TASK_BOARD_API_PREFIX.slice(1)
|
|
23
|
+
|
|
14
24
|
const IMPORT_MARKER = 'dsh.taskBoard.v2.hostImported'
|
|
15
25
|
const SOURCE_KEY = 'dsh.taskBoard.v2.sourceId'
|
|
16
26
|
const IMPORT_REQUEST_KEY = 'dsh.taskBoard.v2.importRequestId'
|
|
@@ -114,7 +124,7 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
|
114
124
|
}
|
|
115
125
|
|
|
116
126
|
async state(): Promise<TaskBoardSnapshot> {
|
|
117
|
-
return await this.request(`${
|
|
127
|
+
return await this.request(`${CLIENT_API_PREFIX}/state`, { cache: 'no-store' })
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
async action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot> {
|
|
@@ -123,7 +133,7 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
|
123
133
|
|
|
124
134
|
private async post(requestId: string, action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot> {
|
|
125
135
|
const envelope: TaskBoardActionEnvelope = { requestId, action, ...(initiator === undefined || initiator === '' ? {} : { initiator }) }
|
|
126
|
-
return await this.request(`${
|
|
136
|
+
return await this.request(`${CLIENT_API_PREFIX}/action`, {
|
|
127
137
|
method: 'POST',
|
|
128
138
|
headers: { 'content-type': 'application/json' },
|
|
129
139
|
body: JSON.stringify(envelope),
|
|
@@ -154,7 +164,7 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
|
154
164
|
async parseDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft> {
|
|
155
165
|
let response: Response
|
|
156
166
|
try {
|
|
157
|
-
response = await fetch(`${
|
|
167
|
+
response = await fetch(`${CLIENT_API_PREFIX}/parse`, {
|
|
158
168
|
method: 'POST',
|
|
159
169
|
headers: { 'content-type': 'application/json' },
|
|
160
170
|
body: JSON.stringify(request),
|
|
@@ -193,7 +203,7 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
|
193
203
|
}
|
|
194
204
|
|
|
195
205
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void {
|
|
196
|
-
const events = new EventSource(`${
|
|
206
|
+
const events = new EventSource(`${CLIENT_API_PREFIX}/events`)
|
|
197
207
|
let lastStreamErrorNotify = 0
|
|
198
208
|
events.onmessage = (message: MessageEvent<string>): void => {
|
|
199
209
|
try {
|
package/src/client/index.ts
CHANGED
|
@@ -384,7 +384,17 @@ export function apply(ctx: ClientContext): void {
|
|
|
384
384
|
if (enabled) mountUi()
|
|
385
385
|
else uiDisposer?.()
|
|
386
386
|
}
|
|
387
|
-
settingsForm.subscribe(syncEnabled)
|
|
387
|
+
const unsubscribeSettings = settingsForm.subscribe(syncEnabled)
|
|
388
|
+
// The mounted surfaces and the settings subscription belong to this fiber.
|
|
389
|
+
// The loader replaces a rebuilt bundle IN PLACE (fiber teardown and re-apply
|
|
390
|
+
// without a page reload), and that same teardown removes the dictionaries
|
|
391
|
+
// this row and board render through — a row left behind would stay visible
|
|
392
|
+
// showing raw dictionary keys, and a surviving subscription could mount a
|
|
393
|
+
// second one from a dead instance.
|
|
394
|
+
ctx.effect(() => () => {
|
|
395
|
+
unsubscribeSettings()
|
|
396
|
+
uiDisposer?.()
|
|
397
|
+
}, 'task-board: DOM surfaces')
|
|
388
398
|
syncEnabled()
|
|
389
399
|
}
|
|
390
400
|
|
package/src/client/locales.ts
CHANGED
|
@@ -157,12 +157,16 @@ export const zh = {
|
|
|
157
157
|
'exec.mode.brokenSuffix': '(不可用)',
|
|
158
158
|
'exec.mode.removed': '(已移除)',
|
|
159
159
|
'exec.permission.default': '会话默认',
|
|
160
|
+
'exec.permission.inheritParent': '继承父任务({permission})',
|
|
160
161
|
'exec.permission.read-only': '只读',
|
|
161
162
|
'exec.permission.workspace-write': '工作区可写',
|
|
162
163
|
'exec.permission.danger-full-access': '完全访问',
|
|
163
164
|
'exec.model.default': '宿主默认(agent-default-model)',
|
|
164
165
|
'exec.model.unknown': '(未知模型/回退默认)',
|
|
165
166
|
'exec.reuseSession': '在同一对话继续',
|
|
167
|
+
'exec.teamRun': '用 Agent Team 执行(Leader/teammate)',
|
|
168
|
+
'exec.teamRunHint': '开启后:执行本任务只启动一个 Leader 会话,每个子任务由它派生为 teammate 并在该会话内并行工作(子任务自身的权限钉住不适用)。关闭则每个成员各开独立会话。',
|
|
169
|
+
'exec.teamRunUnavailable': '当前部署未提供 Agent Teams 服务,无法启用。',
|
|
166
170
|
'exec.reuseSessionHint': '开启后,本任务的后续执行在上一次会话里继续(该会话空闲且仍存在时),不再每次新建对话;每次复用时都会重新应用上面钉住的权限与模型。',
|
|
167
171
|
'detail.executionSettings': '执行设置',
|
|
168
172
|
'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
|
|
@@ -170,7 +174,7 @@ export const zh = {
|
|
|
170
174
|
'settings.title': '任务看板',
|
|
171
175
|
'settings.description': '控制 Host 任务看板、agent 播报与运行期间的系统空闲睡眠保护。',
|
|
172
176
|
'settings.enabled': '启用任务看板',
|
|
173
|
-
'settings.enabledHint': '
|
|
177
|
+
'settings.enabledHint': '关闭后隐藏侧边栏入口与看板视图,并注销 task_board_* agent 工具;开启时任何会话都可以通过这些工具读写看板、子任务与定时计划。',
|
|
174
178
|
'settings.announceToAgent': '向 agent 播报任务看板',
|
|
175
179
|
'settings.announceToAgentHint': '开启:每条 agent 系统提示都会包含本看板的说明;关闭:不播报,agent 仅在用户主动提及时了解看板。',
|
|
176
180
|
'settings.preventIdleSleep': '阻止系统空闲睡眠',
|
|
@@ -194,6 +198,36 @@ export const zh = {
|
|
|
194
198
|
'settings.unsaved': '未保存',
|
|
195
199
|
'settings.saveFailed': '部署未接受这些值,已保留供你修改。',
|
|
196
200
|
'settings.invalidNumber': '请输入数字,留空则使用默认值。',
|
|
201
|
+
'settings.maxSubtaskDepth': '子任务深度上限',
|
|
202
|
+
'settings.maxSubtaskDepthHint': '默认 1:一个任务只允许一层子任务,子任务不能再创建或关联子任务。最大 3。执行父任务会并发执行它的整棵子任务树,层级越深,一次执行开启的会话越多。',
|
|
203
|
+
'settings.maxSubtaskDepthOption': '{depth} 层',
|
|
204
|
+
// 子任务(父子关系、级联执行、关联/解除关联)。
|
|
205
|
+
'detail.parent': '父任务',
|
|
206
|
+
'detail.parent.open': '打开父任务',
|
|
207
|
+
'detail.subtasks': '子任务',
|
|
208
|
+
'detail.subtasks.empty': '还没有子任务',
|
|
209
|
+
'detail.subtasks.add': '添加子任务',
|
|
210
|
+
'detail.subtasks.link': '关联现有任务',
|
|
211
|
+
'detail.subtasks.detach': '解除关联',
|
|
212
|
+
'detail.subtasks.runningLock': '子任务正在执行,结算后才能解除关联或改挂父任务',
|
|
213
|
+
'detail.subtasks.runHint': '执行本任务会同时并发执行它的 {count} 个子任务;子任务失败不影响其它子任务继续执行。',
|
|
214
|
+
'detail.subtasks.waiting': '等待子任务结算',
|
|
215
|
+
'detail.subtasks.depthLimit': '当前子任务深度上限为 {depth} 层,子任务不能再创建或关联子任务。',
|
|
216
|
+
'detail.subtasks.hostOnly': '连接 Host 后才能创建或关联子任务。',
|
|
217
|
+
'new.subtaskTitle': '新建子任务',
|
|
218
|
+
'new.subtaskInherit': '未单独选择的执行设置继承父任务(工作区、模式、权限、模型),可在此覆盖。',
|
|
219
|
+
'link.subtask.title': '关联现有任务',
|
|
220
|
+
'link.subtask.hint': '只能关联没有父任务的在看板任务;关联后它成为本任务的子任务。',
|
|
221
|
+
'link.subtask.empty': '没有可关联的任务',
|
|
222
|
+
'link.subtask.confirm': '关联',
|
|
223
|
+
'card.subtask': '子任务',
|
|
224
|
+
'card.subtasks': '{count} 子任务',
|
|
225
|
+
'card.subtasksFailed': '{count} 失败',
|
|
226
|
+
'card.subtasksRunning': '{count} 运行中',
|
|
227
|
+
'card.subtasksBreakdown': '子任务 {total}:已完成 {done},运行中 {running},失败 {failed}',
|
|
228
|
+
'board.hideSubtasks': '隐藏子任务',
|
|
229
|
+
'board.showSubtasks': '显示子任务',
|
|
230
|
+
'board.subtaskFilterHint': '看板默认只显示父任务;搜索或按标签筛选时会自动展开子任务。',
|
|
197
231
|
} satisfies Record<string, string>
|
|
198
232
|
|
|
199
233
|
/** en dictionary, complete against the zh key set. */
|
|
@@ -348,12 +382,16 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
348
382
|
'exec.mode.brokenSuffix': ' (unavailable)',
|
|
349
383
|
'exec.mode.removed': ' (removed)',
|
|
350
384
|
'exec.permission.default': 'Session default',
|
|
385
|
+
'exec.permission.inheritParent': 'Inherit parent ({permission})',
|
|
351
386
|
'exec.permission.read-only': 'Read-only',
|
|
352
387
|
'exec.permission.workspace-write': 'Workspace Write',
|
|
353
388
|
'exec.permission.danger-full-access': 'Full Access',
|
|
354
389
|
'exec.model.default': 'Host default (agent-default-model)',
|
|
355
390
|
'exec.model.unknown': ' (unknown / fallback to default)',
|
|
356
391
|
'exec.reuseSession': 'Continue in the same conversation',
|
|
392
|
+
'exec.teamRun': 'Run with an Agent Team (Lead + teammates)',
|
|
393
|
+
'exec.teamRunHint': 'On: running this task starts one Lead session and spawns a teammate per subtask inside it, working in parallel (a subtask permission pin does not apply). Off: every member gets its own independent session.',
|
|
394
|
+
'exec.teamRunUnavailable': 'This deployment serves no Agent Teams service, so team runs cannot be enabled.',
|
|
357
395
|
'exec.reuseSessionHint': 'When on, later runs continue in the previous session (when that session is idle and still exists) instead of starting a new conversation each time; the pinned permission and model above are re-applied on every reuse.',
|
|
358
396
|
'detail.executionSettings': 'Execution Settings',
|
|
359
397
|
'exec.hint': 'Applied when the task runs: the workspace decides where the execution session lands; the mode composes the session\'s agent preset; the permission is applied through the /permission command. Blank = runtime default.',
|
|
@@ -361,7 +399,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
361
399
|
'settings.title': 'Task Board',
|
|
362
400
|
'settings.description': 'Configure the Host task board, agent announcement, and idle-system-sleep protection while work is pending.',
|
|
363
401
|
'settings.enabled': 'Enable the task board',
|
|
364
|
-
'settings.enabledHint': 'When off, the sidebar entry and board view are hidden.',
|
|
402
|
+
'settings.enabledHint': 'When off, the sidebar entry and board view are hidden and the task_board_* agent tools are unregistered; while on, any session can read and drive the board, its subtasks and its schedules through those tools.',
|
|
365
403
|
'settings.announceToAgent': 'Announce the task board to agents',
|
|
366
404
|
'settings.announceToAgentHint': 'On: every agent system prompt includes a note about this board. Off: no announcement; agents learn about the board only when you mention it.',
|
|
367
405
|
'settings.preventIdleSleep': 'Prevent idle system sleep',
|
|
@@ -385,6 +423,36 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
385
423
|
'settings.unsaved': 'Unsaved',
|
|
386
424
|
'settings.saveFailed': 'The deployment did not accept these values; they were left for you to correct.',
|
|
387
425
|
'settings.invalidNumber': 'Enter a number, or leave blank to use the default.',
|
|
426
|
+
'settings.maxSubtaskDepth': 'Subtask depth limit',
|
|
427
|
+
'settings.maxSubtaskDepthHint': 'Default 1: a task may carry one level of subtasks, and a subtask cannot create or link further subtasks. Maximum 3. Running a task also runs its whole subtask tree concurrently, and every extra level opens more sessions per run.',
|
|
428
|
+
'settings.maxSubtaskDepthOption': '{depth} levels',
|
|
429
|
+
// Subtasks (parent/child links, cascade runs, link/detach).
|
|
430
|
+
'detail.parent': 'Parent task',
|
|
431
|
+
'detail.parent.open': 'Open parent task',
|
|
432
|
+
'detail.subtasks': 'Subtasks',
|
|
433
|
+
'detail.subtasks.empty': 'No subtasks yet',
|
|
434
|
+
'detail.subtasks.add': 'Add subtask',
|
|
435
|
+
'detail.subtasks.link': 'Link existing task',
|
|
436
|
+
'detail.subtasks.detach': 'Detach',
|
|
437
|
+
'detail.subtasks.runningLock': 'This subtask is running; it can be detached or re-linked once it settles',
|
|
438
|
+
'detail.subtasks.runHint': 'Running this task also runs its {count} subtasks concurrently; one subtask failing does not stop the others.',
|
|
439
|
+
'detail.subtasks.waiting': 'Waiting for subtasks',
|
|
440
|
+
'detail.subtasks.depthLimit': 'The subtask depth limit is {depth}; a subtask cannot create or link further subtasks.',
|
|
441
|
+
'detail.subtasks.hostOnly': 'Connect to the Host to create or link subtasks.',
|
|
442
|
+
'new.subtaskTitle': 'New Subtask',
|
|
443
|
+
'new.subtaskInherit': 'Execution settings left unset inherit the parent (workspace, mode, permission, model); override any of them here.',
|
|
444
|
+
'link.subtask.title': 'Link an existing task',
|
|
445
|
+
'link.subtask.hint': 'Only an on-board task without a parent can be linked; it then becomes a subtask of this task.',
|
|
446
|
+
'link.subtask.empty': 'No task can be linked',
|
|
447
|
+
'link.subtask.confirm': 'Link',
|
|
448
|
+
'card.subtask': 'subtask',
|
|
449
|
+
'card.subtasks': '{count} subtasks',
|
|
450
|
+
'card.subtasksFailed': '{count} failed',
|
|
451
|
+
'card.subtasksRunning': '{count} running',
|
|
452
|
+
'card.subtasksBreakdown': 'Subtasks {total}: {done} done, {running} running, {failed} failed',
|
|
453
|
+
'board.hideSubtasks': 'Hide subtasks',
|
|
454
|
+
'board.showSubtasks': 'Show subtasks',
|
|
455
|
+
'board.subtaskFilterHint': 'The board shows parent tasks only; a text or label filter reveals the subtasks automatically.',
|
|
388
456
|
}
|
|
389
457
|
|
|
390
458
|
/** The dictionary key union. */
|
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,
|
|
@@ -111,6 +113,13 @@ export interface ExecutionOptionsSnapshot {
|
|
|
111
113
|
models?: readonly ExecutionModelOption[]
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Host-owned state the browser mirrors. SSE frames carry the volatile subset
|
|
118
|
+
* (revision/scheduler/power); a full snapshot also carries the deployment
|
|
119
|
+
* constants the UI reads (session default permission, subtask depth).
|
|
120
|
+
*/
|
|
121
|
+
export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable'>
|
|
122
|
+
|
|
114
123
|
/** Immutable controller snapshot for UI subscriptions. */
|
|
115
124
|
export interface ControllerSnapshot {
|
|
116
125
|
tasks: readonly TaskRecord[]
|
|
@@ -126,7 +135,7 @@ export interface ControllerSnapshot {
|
|
|
126
135
|
/** Whether this deployment can parse pasted text into task fields (issue #1540). */
|
|
127
136
|
canParseTask?: boolean
|
|
128
137
|
transportError?: string
|
|
129
|
-
host?:
|
|
138
|
+
host?: HostMirror
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
/** The selected task (resolved from the ledger), or undefined. */
|
|
@@ -173,7 +182,7 @@ export class BoardController {
|
|
|
173
182
|
private readonly pendingTaskIds = new Set<string>()
|
|
174
183
|
private readonly taskQueues = new Map<string, Promise<void>>()
|
|
175
184
|
private transportError: string | undefined
|
|
176
|
-
private hostState:
|
|
185
|
+
private hostState: HostMirror | undefined
|
|
177
186
|
private remoteSubscribed = false
|
|
178
187
|
private remoteInitialization: Promise<boolean> | undefined
|
|
179
188
|
|
|
@@ -291,7 +300,7 @@ export class BoardController {
|
|
|
291
300
|
|
|
292
301
|
createTask(input: NewTaskInput): TaskRecord | undefined {
|
|
293
302
|
const id = this.uuid()
|
|
294
|
-
const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id)
|
|
303
|
+
const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth())
|
|
295
304
|
if (task === undefined) return undefined
|
|
296
305
|
this.tasks = [...tasks]
|
|
297
306
|
this.persistAndNotify()
|
|
@@ -302,7 +311,10 @@ export class BoardController {
|
|
|
302
311
|
async createTaskConfirmed(input: NewTaskInput): Promise<TaskRecord | undefined> {
|
|
303
312
|
if (this.deps.transport === undefined) return this.createTask(input)
|
|
304
313
|
const id = this.uuid()
|
|
305
|
-
|
|
314
|
+
// The preview must obey the SAME lineage gate the Host will: a browser-side
|
|
315
|
+
// default of one level would reject a legal subtask-of-subtask before the
|
|
316
|
+
// request is ever sent.
|
|
317
|
+
const preview = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth()).task
|
|
306
318
|
if (preview === undefined) return undefined
|
|
307
319
|
return await this.commitRemote({ kind: 'create', id, input }, id)
|
|
308
320
|
? this.tasks.find(task => task.id === id)
|
|
@@ -392,7 +404,7 @@ export class BoardController {
|
|
|
392
404
|
* @returns true when applied.
|
|
393
405
|
*/
|
|
394
406
|
archiveTask(id: string): boolean {
|
|
395
|
-
const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now())
|
|
407
|
+
const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now(), this.mirrorDepth())
|
|
396
408
|
if (!archived) return false
|
|
397
409
|
if (this.deps.transport !== undefined) {
|
|
398
410
|
void this.commitRemote({ kind: 'archive', taskId: id }, id)
|
|
@@ -403,9 +415,27 @@ export class BoardController {
|
|
|
403
415
|
return true
|
|
404
416
|
}
|
|
405
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Attach an existing task under a parent (or detach it with a null parent)
|
|
420
|
+
* through the Host. The lineage gate — parent exists and is on-board, no
|
|
421
|
+
* cycle, depth within the deployment limit — belongs to the Host; a refusal
|
|
422
|
+
* surfaces through the transport error like every other rejected action.
|
|
423
|
+
* @returns true when the link was accepted by the authority.
|
|
424
|
+
*/
|
|
425
|
+
async setParent(id: string, parentId: string | null): Promise<boolean> {
|
|
426
|
+
if (this.deps.transport === undefined) {
|
|
427
|
+
const result = applySetParent(this.tasks, id, parentId, this.now(), this.mirrorDepth())
|
|
428
|
+
if (!result.applied) return false
|
|
429
|
+
this.tasks = [...result.tasks]
|
|
430
|
+
this.persistAndNotify()
|
|
431
|
+
return true
|
|
432
|
+
}
|
|
433
|
+
return await this.commitRemote({ kind: 'set-parent', taskId: id, parentId }, id)
|
|
434
|
+
}
|
|
435
|
+
|
|
406
436
|
/** Restore an archived task back onto the board (same status column). */
|
|
407
437
|
restoreTask(id: string): boolean {
|
|
408
|
-
const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now())
|
|
438
|
+
const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now(), this.mirrorDepth())
|
|
409
439
|
if (!archived) return false
|
|
410
440
|
if (this.deps.transport !== undefined) {
|
|
411
441
|
void this.commitRemote({ kind: 'restore', taskId: id }, id).then(restored => {
|
|
@@ -604,13 +634,44 @@ export class BoardController {
|
|
|
604
634
|
if (event !== undefined && this.hostState !== undefined && event.revision === this.hostState.revision
|
|
605
635
|
&& typeof event.scheduler === 'object' && event.scheduler !== null
|
|
606
636
|
&& typeof event.power === 'object' && event.power !== null) {
|
|
607
|
-
this.hostState = { revision: event.revision, scheduler: event.scheduler, power: event.power }
|
|
637
|
+
this.hostState = { ...this.hostState, revision: event.revision, scheduler: event.scheduler, power: event.power }
|
|
608
638
|
this.notify()
|
|
609
639
|
return
|
|
610
640
|
}
|
|
611
641
|
void this.refreshRemote()
|
|
612
642
|
}
|
|
613
643
|
|
|
644
|
+
/**
|
|
645
|
+
* Deployment subtask depth limit the browser mirrors from the last Host
|
|
646
|
+
* snapshot; the fallback is the deployment default for the legacy path (no
|
|
647
|
+
* transport) and for the window before the first snapshot arrives.
|
|
648
|
+
*/
|
|
649
|
+
private mirrorDepth(): number {
|
|
650
|
+
return this.hostState?.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Project a Host snapshot onto the browser's mirror. SSE frames and partial
|
|
655
|
+
* snapshots carry only the volatile subset (revision/scheduler/power), so the
|
|
656
|
+
* deployment constants the UI reads — the session-default permission the
|
|
657
|
+
* confirmation banner compares against and the subtask depth limit — are
|
|
658
|
+
* carried over from the last full snapshot instead of being dropped by a
|
|
659
|
+
* heartbeat frame.
|
|
660
|
+
*/
|
|
661
|
+
private mirrorOf(snapshot: TaskBoardSnapshot): HostMirror {
|
|
662
|
+
const sessionDefaultPermission = snapshot.sessionDefaultPermission ?? this.hostState?.sessionDefaultPermission
|
|
663
|
+
const maxSubtaskDepth = snapshot.maxSubtaskDepth ?? this.hostState?.maxSubtaskDepth
|
|
664
|
+
const teamRunAvailable = snapshot.teamRunAvailable ?? this.hostState?.teamRunAvailable
|
|
665
|
+
return {
|
|
666
|
+
revision: snapshot.revision,
|
|
667
|
+
scheduler: snapshot.scheduler,
|
|
668
|
+
power: snapshot.power,
|
|
669
|
+
...(sessionDefaultPermission === undefined ? {} : { sessionDefaultPermission }),
|
|
670
|
+
...(maxSubtaskDepth === undefined ? {} : { maxSubtaskDepth }),
|
|
671
|
+
...(teamRunAvailable === undefined ? {} : { teamRunAvailable }),
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
614
675
|
private async refreshRemote(preserveError?: string): Promise<boolean> {
|
|
615
676
|
const transport = this.deps.transport
|
|
616
677
|
if (transport === undefined) return true
|
|
@@ -634,7 +695,7 @@ export class BoardController {
|
|
|
634
695
|
const sameGeneration = currentLedgerId === nextLedgerId
|
|
635
696
|
if (sameGeneration && this.hostState !== undefined && snapshot.revision < this.hostState.revision) return false
|
|
636
697
|
this.tasks = [...snapshot.tasks]
|
|
637
|
-
this.hostState =
|
|
698
|
+
this.hostState = this.mirrorOf(snapshot)
|
|
638
699
|
this.transportError = undefined
|
|
639
700
|
if (this.selectedTaskId !== undefined && !this.tasks.some(task => task.id === this.selectedTaskId)) {
|
|
640
701
|
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.
|