@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/host-ledger.ts
CHANGED
|
@@ -5,11 +5,21 @@ import { dirname, join } from 'node:path'
|
|
|
5
5
|
import { dshHome } from './dsh-home.ts'
|
|
6
6
|
import { isValidCron, nextRunAtMs } from './core/schedule.ts'
|
|
7
7
|
import { isTaskRecord, parseLedger } from './core/store.ts'
|
|
8
|
-
import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
8
|
+
import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_SUBTASK_DEPTH,
|
|
11
|
+
cascadeTargets,
|
|
12
|
+
combineCascadeOutcome,
|
|
13
|
+
normalizeSubtaskDepth,
|
|
14
|
+
openGroupExecution,
|
|
15
|
+
pendingCascadeChildren,
|
|
16
|
+
resolveExecutionTargets,
|
|
17
|
+
} from './core/subtask.ts'
|
|
9
18
|
import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
|
|
10
19
|
import { applyCreateTask } from './core/use-cases/task-create.ts'
|
|
11
20
|
import { applyDeleteTask } from './core/use-cases/task-delete.ts'
|
|
12
21
|
import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
|
|
22
|
+
import { applySetParent } from './core/use-cases/task-parent.ts'
|
|
13
23
|
import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
|
|
14
24
|
import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
15
25
|
import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
|
|
@@ -40,9 +50,32 @@ export interface LedgerState {
|
|
|
40
50
|
scheduler: TaskBoardSchedulerSnapshot
|
|
41
51
|
}
|
|
42
52
|
|
|
53
|
+
/** Human-facing text for a refused binding, per surface. */
|
|
54
|
+
function bindingRefusalMessage(
|
|
55
|
+
refusal: { kind: 'root' | 'inherited' | 'subtask-pin'; title: string },
|
|
56
|
+
sessionDefault: TaskPermission,
|
|
57
|
+
surface: 'run' | 'schedule',
|
|
58
|
+
): string {
|
|
59
|
+
if (refusal.kind === 'subtask-pin') {
|
|
60
|
+
return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session; clear that card's permission or run the tree without Agent Team`
|
|
61
|
+
}
|
|
62
|
+
if (surface === 'schedule') return `task "${refusal.title}" has an unconfirmed above-default permission`
|
|
63
|
+
if (refusal.kind === 'root') {
|
|
64
|
+
return `confirmation-required: the effective permission is above the session default (${sessionDefault}); confirm the card's permission binding first`
|
|
65
|
+
}
|
|
66
|
+
return `confirmation-required: subtask "${refusal.title}" inherits an above-default permission; confirm that card's permission binding first`
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
export interface OpenedRun {
|
|
44
70
|
task: TaskRecord
|
|
45
71
|
execution: ExecutionRecord
|
|
72
|
+
/**
|
|
73
|
+
* How the Host obtains this run's session. Absent or `session` launches a
|
|
74
|
+
* fresh (or reused) execution session; `teammate` means the run belongs to a
|
|
75
|
+
* team-mode cascade, where the Host asks the root's Lead session to spawn a
|
|
76
|
+
* teammate instead of launching a session of its own.
|
|
77
|
+
*/
|
|
78
|
+
dispatch?: 'session' | 'teammate'
|
|
46
79
|
}
|
|
47
80
|
|
|
48
81
|
/** Minimal value copy used by the Host session monitor. */
|
|
@@ -298,6 +331,39 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
|
|
|
298
331
|
return { ...newer, executions: retainRecentExecutions(executions) }
|
|
299
332
|
}
|
|
300
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Repair parent links after a load or an import: a link whose parent row is
|
|
336
|
+
* missing, cyclic, or names an archived parent while the child is on board is
|
|
337
|
+
* dropped (the task becomes a root) instead of dropping the task. A chain
|
|
338
|
+
* deeper than the current `maxSubtaskDepth` is deliberately KEPT: the limit
|
|
339
|
+
* gates new links, it never deletes a stored one, and every cascade walk is
|
|
340
|
+
* bounded by the limit anyway. The Host is the only writer of these links, so
|
|
341
|
+
* this is a last-resort guard against a hand-edited or imported document.
|
|
342
|
+
*/
|
|
343
|
+
export function repairParentLinks(tasks: readonly TaskRecord[]): TaskRecord[] {
|
|
344
|
+
const byId = new Map(tasks.map(task => [task.id, task]))
|
|
345
|
+
return tasks.map(task => {
|
|
346
|
+
if (task.parentId === undefined) return task
|
|
347
|
+
const seen = new Set<string>([task.id])
|
|
348
|
+
let current: TaskRecord | undefined = byId.get(task.parentId)
|
|
349
|
+
let depth = 0
|
|
350
|
+
while (current !== undefined) {
|
|
351
|
+
// The first hop is the parent itself: an archived parent holding an
|
|
352
|
+
// on-board child is outside the archive cascade invariant, and only an
|
|
353
|
+
// import can produce it.
|
|
354
|
+
if (seen.has(current.id) || (depth === 0 && current.archivedAt !== undefined && task.archivedAt === undefined)) {
|
|
355
|
+
return { ...task, parentId: undefined }
|
|
356
|
+
}
|
|
357
|
+
seen.add(current.id)
|
|
358
|
+
depth += 1
|
|
359
|
+
current = current.parentId === undefined ? undefined : byId.get(current.parentId)
|
|
360
|
+
}
|
|
361
|
+
// A walk that never started (depth 0) means the parent row did not survive
|
|
362
|
+
// the parse: the link is dangling and the task goes back to the root.
|
|
363
|
+
return depth === 0 ? { ...task, parentId: undefined } : task
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
301
367
|
function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
302
368
|
const rawById = new Map<string, Record<string, unknown>>()
|
|
303
369
|
for (const value of values) {
|
|
@@ -305,7 +371,7 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
|
305
371
|
const raw = value as Record<string, unknown>
|
|
306
372
|
if (typeof raw.id === 'string') rawById.set(raw.id, raw)
|
|
307
373
|
}
|
|
308
|
-
return parseLedger(JSON.stringify(values)).map(task => {
|
|
374
|
+
return repairParentLinks(parseLedger(JSON.stringify(values))).map(task => {
|
|
309
375
|
const rawSchedule = rawById.get(task.id)?.schedule
|
|
310
376
|
if (typeof rawSchedule !== 'object' || rawSchedule === null) return task
|
|
311
377
|
const schedule = rawSchedule as Record<string, unknown>
|
|
@@ -337,9 +403,16 @@ export class HostTaskLedger {
|
|
|
337
403
|
|
|
338
404
|
/** Session-default permission the confirmation gate compares against. */
|
|
339
405
|
readonly sessionDefaultPermission: TaskPermission
|
|
406
|
+
/**
|
|
407
|
+
* Deployment subtask depth limit (1..3): the lineage gate every write obeys.
|
|
408
|
+
* The settings card edits it live, so {@link setMaxSubtaskDepth} mutates it
|
|
409
|
+
* instead of remounting the row.
|
|
410
|
+
*/
|
|
411
|
+
private depthLimit: number
|
|
340
412
|
|
|
341
|
-
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission } = {}) {
|
|
413
|
+
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission; maxSubtaskDepth?: number } = {}) {
|
|
342
414
|
this.sessionDefaultPermission = options.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION
|
|
415
|
+
this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH)
|
|
343
416
|
mkdirSync(dir, { recursive: true })
|
|
344
417
|
this.file = join(dir, 'ledger-v2.json')
|
|
345
418
|
this.lockFile = join(dir, 'ledger-v2.lock')
|
|
@@ -362,6 +435,23 @@ export class HostTaskLedger {
|
|
|
362
435
|
}
|
|
363
436
|
}
|
|
364
437
|
|
|
438
|
+
/** Current subtask depth limit (1..3). */
|
|
439
|
+
get maxSubtaskDepth(): number {
|
|
440
|
+
return this.depthLimit
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Apply a live settings edit of the subtask depth limit. A no-op when the
|
|
445
|
+
* normalized limit is unchanged, so a coarse volatile invalidation that
|
|
446
|
+
* changed nothing emits nothing.
|
|
447
|
+
*/
|
|
448
|
+
setMaxSubtaskDepth(depth: number): void {
|
|
449
|
+
const next = normalizeSubtaskDepth(depth)
|
|
450
|
+
if (next === this.depthLimit) return
|
|
451
|
+
this.depthLimit = next
|
|
452
|
+
this.notify()
|
|
453
|
+
}
|
|
454
|
+
|
|
365
455
|
/** Remove leftover *.tmp-* files from previous crashes or interrupted writes. */
|
|
366
456
|
private cleanStaleTemporaryFiles(dir: string): void {
|
|
367
457
|
try {
|
|
@@ -403,6 +493,9 @@ export class HostTaskLedger {
|
|
|
403
493
|
if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
|
|
404
494
|
for (const execution of task.executions) {
|
|
405
495
|
if (execution.endedAt !== undefined) continue
|
|
496
|
+
// A deferred cascade parent already knows its own outcome; the monitor
|
|
497
|
+
// has nothing left to inspect, and its children's settles finalize it.
|
|
498
|
+
if (execution.ownResult !== undefined) continue
|
|
406
499
|
openExecutions.push({
|
|
407
500
|
taskId: task.id,
|
|
408
501
|
executionId: execution.id,
|
|
@@ -423,6 +516,26 @@ export class HostTaskLedger {
|
|
|
423
516
|
return count
|
|
424
517
|
}
|
|
425
518
|
|
|
519
|
+
/**
|
|
520
|
+
* The earliest armed `nextRunAt` strictly after the supplied Host time, or
|
|
521
|
+
* undefined when no schedule is armed in the future. This is the instant the
|
|
522
|
+
* Host arms its native timer at, so the board wakes exactly when work is due
|
|
523
|
+
* instead of polling a fixed heartbeat.
|
|
524
|
+
* @param now - current Host time in ms epoch.
|
|
525
|
+
* @returns the nearest future trigger, or undefined when none is armed.
|
|
526
|
+
*/
|
|
527
|
+
nextArmedRunAt(now: number): number | undefined {
|
|
528
|
+
let nearest: number | undefined
|
|
529
|
+
for (const task of this.document.tasks) {
|
|
530
|
+
if (task.archivedAt !== undefined) continue
|
|
531
|
+
const schedule = task.schedule
|
|
532
|
+
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined) continue
|
|
533
|
+
if (schedule.nextRunAt <= now) continue
|
|
534
|
+
if (nearest === undefined || schedule.nextRunAt < nearest) nearest = schedule.nextRunAt
|
|
535
|
+
}
|
|
536
|
+
return nearest
|
|
537
|
+
}
|
|
538
|
+
|
|
426
539
|
/** Return value-only references for schedules due at the supplied Host time. */
|
|
427
540
|
dueSchedules(now: number): DueScheduleReference[] {
|
|
428
541
|
const due: DueScheduleReference[] = []
|
|
@@ -457,7 +570,7 @@ export class HostTaskLedger {
|
|
|
457
570
|
requestId: string,
|
|
458
571
|
action: TaskBoardAction,
|
|
459
572
|
initiator?: string,
|
|
460
|
-
): { state: LedgerState;
|
|
573
|
+
): { state: LedgerState; runs?: OpenedRun[] } {
|
|
461
574
|
const fingerprint = createHash('sha256').update(JSON.stringify(action)).digest('hex')
|
|
462
575
|
const cached = this.requestCache.get(requestId)
|
|
463
576
|
if (cached !== undefined) {
|
|
@@ -479,27 +592,32 @@ export class HostTaskLedger {
|
|
|
479
592
|
}
|
|
480
593
|
}
|
|
481
594
|
|
|
482
|
-
|
|
595
|
+
/**
|
|
596
|
+
* Open the cascade one due schedule triggers: an empty array means nothing
|
|
597
|
+
* ran (already running, or a participant whose elevated permission is still
|
|
598
|
+
* unconfirmed), and the rule rolls to its next occurrence either way.
|
|
599
|
+
*/
|
|
600
|
+
openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun[] {
|
|
483
601
|
const task = this.document.tasks.find(item => item.id === taskId)
|
|
484
|
-
if (task === undefined || task.archivedAt !== undefined) return
|
|
485
|
-
|
|
486
|
-
// An unconfirmed above-default permission must never run unattended:
|
|
487
|
-
// cron refuses the card and rolls to the next occurrence, exactly
|
|
488
|
-
// like the already-running refusal.
|
|
602
|
+
if (task === undefined || task.archivedAt !== undefined) return []
|
|
603
|
+
const rollForward = (): void => {
|
|
489
604
|
this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
|
|
490
605
|
this.commit()
|
|
491
|
-
|
|
606
|
+
}
|
|
607
|
+
const refusal = this.bindingRefusal(task)
|
|
608
|
+
if (refusal !== undefined) {
|
|
609
|
+
// An unconfirmed above-default permission must never run unattended: the
|
|
610
|
+
// whole tree is refused and the schedule rolls to the next occurrence,
|
|
611
|
+
// exactly like the already-running refusal.
|
|
612
|
+
this.document.scheduler.error = `scheduled run refused for task ${taskId}: ${bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'schedule')}`
|
|
613
|
+
rollForward()
|
|
614
|
+
return []
|
|
492
615
|
}
|
|
493
616
|
if (task.status === 'running' || hasOpenExecution(task)) {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
return undefined
|
|
617
|
+
rollForward()
|
|
618
|
+
return []
|
|
497
619
|
}
|
|
498
|
-
|
|
499
|
-
this.document.tasks = this.document.tasks.map(item => item.id === taskId ? opened.task : item)
|
|
500
|
-
this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, triggeredAt, triggeredAt)]
|
|
501
|
-
this.commit()
|
|
502
|
-
return opened
|
|
620
|
+
return this.startCascade(task, triggeredAt, undefined, false, nextRunAt)
|
|
503
621
|
}
|
|
504
622
|
|
|
505
623
|
skipMissed(now: number): void {
|
|
@@ -535,6 +653,38 @@ export class HostTaskLedger {
|
|
|
535
653
|
this.commit(false)
|
|
536
654
|
}
|
|
537
655
|
|
|
656
|
+
/**
|
|
657
|
+
* Settle one execution. A cascade participant first records its OWN outcome
|
|
658
|
+
* and only finalizes once every child execution in its run group has
|
|
659
|
+
* settled, so a parent card leaves 'running' with the whole tree's verdict
|
|
660
|
+
* rather than its own turn alone.
|
|
661
|
+
*/
|
|
662
|
+
settle(taskId: string, executionId: string, outcome: ExecutionOutcome, error?: string): void {
|
|
663
|
+
const now = this.now()
|
|
664
|
+
const task = this.document.tasks.find(item => item.id === taskId)
|
|
665
|
+
const execution = task?.executions.find(entry => entry.id === executionId)
|
|
666
|
+
if (task === undefined || execution === undefined || execution.endedAt !== undefined) return
|
|
667
|
+
const groupId = execution.runGroupId
|
|
668
|
+
if (groupId === undefined) {
|
|
669
|
+
this.document.tasks = this.document.tasks.map(item => item.id === taskId
|
|
670
|
+
? settleExecution(item, executionId, outcome, now, error)
|
|
671
|
+
: item)
|
|
672
|
+
this.commit()
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
let changed = false
|
|
676
|
+
if (execution.ownResult === undefined) {
|
|
677
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
|
|
678
|
+
...item,
|
|
679
|
+
updatedAt: now,
|
|
680
|
+
executions: item.executions.map(entry => entry.id === executionId ? { ...entry, ownResult: outcome, ownError: error } : entry),
|
|
681
|
+
})
|
|
682
|
+
changed = true
|
|
683
|
+
}
|
|
684
|
+
if (this.settleCascade(taskId, groupId, now)) changed = true
|
|
685
|
+
if (changed) this.commit()
|
|
686
|
+
}
|
|
687
|
+
|
|
538
688
|
attachSession(taskId: string, executionId: string, sessionId: string): void {
|
|
539
689
|
const now = this.now()
|
|
540
690
|
this.document.tasks = this.document.tasks.map(task => task.id !== taskId ? task : {
|
|
@@ -545,16 +695,9 @@ export class HostTaskLedger {
|
|
|
545
695
|
this.commit()
|
|
546
696
|
}
|
|
547
697
|
|
|
548
|
-
|
|
549
|
-
this.document.tasks = this.document.tasks.map(task => task.id === taskId
|
|
550
|
-
? settleExecution(task, executionId, outcome, this.now(), error)
|
|
551
|
-
: task)
|
|
552
|
-
this.commit()
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; run?: OpenedRun } {
|
|
698
|
+
private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; runs?: OpenedRun[] } {
|
|
556
699
|
const now = this.now()
|
|
557
|
-
let
|
|
700
|
+
let runs: OpenedRun[] | undefined
|
|
558
701
|
switch (action.kind) {
|
|
559
702
|
case 'import': {
|
|
560
703
|
const sources = new Set(this.document.scheduler.importedSources ?? [])
|
|
@@ -565,7 +708,9 @@ export class HostTaskLedger {
|
|
|
565
708
|
const incoming = parseHostTasks(action.tasks)
|
|
566
709
|
const merged = new Map(this.document.tasks.map(task => [task.id, task]))
|
|
567
710
|
for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id)!, task) : task)
|
|
568
|
-
this
|
|
711
|
+
// An imported child can name a parent this ledger has never seen (a
|
|
712
|
+
// partial export): the dangling link is dropped, the task survives.
|
|
713
|
+
this.document.tasks = repairParentLinks([...merged.values()])
|
|
569
714
|
this.document.scheduler.importedSources = [...sources, action.sourceId]
|
|
570
715
|
this.document.scheduler.error = invalidScheduleIds.length === 0
|
|
571
716
|
? undefined
|
|
@@ -582,8 +727,8 @@ export class HostTaskLedger {
|
|
|
582
727
|
const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
|
|
583
728
|
? action.input
|
|
584
729
|
: { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
|
|
585
|
-
const result = applyCreateTask(this.document.tasks, input, now, action.id)
|
|
586
|
-
if (result.task === undefined) throw new Error('invalid task')
|
|
730
|
+
const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth)
|
|
731
|
+
if (result.task === undefined) throw new Error(result.error ?? 'invalid task')
|
|
587
732
|
this.document.tasks = [...result.tasks]
|
|
588
733
|
break
|
|
589
734
|
}
|
|
@@ -607,14 +752,24 @@ export class HostTaskLedger {
|
|
|
607
752
|
this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, patch, now)]
|
|
608
753
|
break
|
|
609
754
|
}
|
|
610
|
-
case 'delete':
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
755
|
+
case 'delete': {
|
|
756
|
+
const task = this.document.tasks.find(task => task.id === action.taskId)
|
|
757
|
+
if (task === undefined) throw new Error('task not found')
|
|
758
|
+
if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be deleted')
|
|
759
|
+
// Subtasks keep their link: deleting the parent would leave dangling
|
|
760
|
+
// children, so the user detaches or deletes them explicitly first.
|
|
761
|
+
if (this.document.tasks.some(item => item.parentId === action.taskId)) {
|
|
762
|
+
throw new Error('task has subtasks; detach or delete them first')
|
|
615
763
|
}
|
|
616
764
|
this.document.tasks = [...applyDeleteTask(this.document.tasks, undefined, action.taskId).tasks]
|
|
617
765
|
break
|
|
766
|
+
}
|
|
767
|
+
case 'set-parent': {
|
|
768
|
+
const result = applySetParent(this.document.tasks, action.taskId, action.parentId, now, this.maxSubtaskDepth)
|
|
769
|
+
if (!result.applied) throw new Error(result.error ?? 'parent link refused')
|
|
770
|
+
this.document.tasks = [...result.tasks]
|
|
771
|
+
break
|
|
772
|
+
}
|
|
618
773
|
case 'move': {
|
|
619
774
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
620
775
|
if (task === undefined) throw new Error('task not found')
|
|
@@ -625,13 +780,15 @@ export class HostTaskLedger {
|
|
|
625
780
|
break
|
|
626
781
|
}
|
|
627
782
|
case 'archive': {
|
|
628
|
-
|
|
783
|
+
if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be archived')
|
|
784
|
+
const result = applyArchiveTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
|
|
629
785
|
if (!result.archived) throw new Error('task cannot be archived')
|
|
630
786
|
this.document.tasks = [...result.tasks]
|
|
631
787
|
break
|
|
632
788
|
}
|
|
633
789
|
case 'restore': {
|
|
634
|
-
|
|
790
|
+
if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
|
|
791
|
+
const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
|
|
635
792
|
if (!result.archived) throw new Error('task is not archived')
|
|
636
793
|
this.document.tasks = [...result.tasks]
|
|
637
794
|
break
|
|
@@ -658,17 +815,148 @@ export class HostTaskLedger {
|
|
|
658
815
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
659
816
|
if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
660
817
|
if (task === undefined || task.status === 'running' || hasOpenExecution(task)) throw new Error('task is already running or missing')
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
818
|
+
// The confirmation gate judges the RESOLVED binding: a subtask that
|
|
819
|
+
// inherits an elevated permission from its parent is exactly as
|
|
820
|
+
// unconfirmed as the parent would be without its own stamp.
|
|
821
|
+
const refusal = this.bindingRefusal(task)
|
|
822
|
+
if (refusal !== undefined) throw new Error(bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'run'))
|
|
823
|
+
runs = this.startCascade(task, now, initiator, action.kind === 'rerun')
|
|
667
824
|
break
|
|
668
825
|
}
|
|
669
826
|
}
|
|
827
|
+
// startCascade committed the opened participants itself; committing again
|
|
828
|
+
// here would bump the revision twice for one action.
|
|
829
|
+
if (runs === undefined || runs.length === 0) this.commit()
|
|
830
|
+
return { state: this.state(), ...(runs === undefined ? {} : { runs }) }
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* The binding that makes a run illegal, if any.
|
|
835
|
+
*
|
|
836
|
+
* A plain cascade launches one session per participant, so every participant
|
|
837
|
+
* carries its own resolved binding and each one gates the run. A team run
|
|
838
|
+
* launches only the Lead session: the Lead's binding gates it, while a
|
|
839
|
+
* subtask's OWN above-default pin cannot be applied to a teammate and is
|
|
840
|
+
* refused instead of being silently dropped (an inherited binding is the
|
|
841
|
+
* Lead's own and stays allowed once the Lead is confirmed).
|
|
842
|
+
* @param root - the task being run.
|
|
843
|
+
* @returns the first refusal, or undefined when the run may start.
|
|
844
|
+
*/
|
|
845
|
+
private bindingRefusal(root: TaskRecord): { kind: 'root' | 'inherited' | 'subtask-pin'; title: string } | undefined {
|
|
846
|
+
const participants = this.cascadeParticipants(root.id)
|
|
847
|
+
if (root.teamRun === true) {
|
|
848
|
+
const lead = participants.find(participant => participant.id === root.id)
|
|
849
|
+
if (lead !== undefined && requiresPermissionConfirmation(lead, this.sessionDefaultPermission)) {
|
|
850
|
+
return { kind: 'root', title: lead.title }
|
|
851
|
+
}
|
|
852
|
+
// The raw record carries the subtask's OWN binding: the resolved
|
|
853
|
+
// participant above already folded the inherited one into its permission.
|
|
854
|
+
const pinned = participants.find(participant => {
|
|
855
|
+
if (participant.id === root.id) return false
|
|
856
|
+
const raw = this.document.tasks.find(task => task.id === participant.id)
|
|
857
|
+
return raw !== undefined && requiresPermissionConfirmation(raw, this.sessionDefaultPermission)
|
|
858
|
+
})
|
|
859
|
+
return pinned === undefined ? undefined : { kind: 'subtask-pin', title: pinned.title }
|
|
860
|
+
}
|
|
861
|
+
const unconfirmed = participants.find(participant => requiresPermissionConfirmation(participant, this.sessionDefaultPermission))
|
|
862
|
+
if (unconfirmed === undefined) return undefined
|
|
863
|
+
return { kind: unconfirmed.id === root.id ? 'root' : 'inherited', title: unconfirmed.title }
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* The tasks a cascade from `rootId` would actually open executions for, with
|
|
868
|
+
* their effective execution targets resolved. Archived members never run, and
|
|
869
|
+
* a member that already has an open execution is skipped (a task cannot run
|
|
870
|
+
* twice), so the result is the participant set the launch will use.
|
|
871
|
+
*/
|
|
872
|
+
private cascadeParticipants(rootId: string): TaskRecord[] {
|
|
873
|
+
return cascadeTargets(this.document.tasks, rootId, this.maxSubtaskDepth)
|
|
874
|
+
.map(participant => resolveExecutionTargets(participant, this.document.tasks, this.maxSubtaskDepth))
|
|
875
|
+
.filter(participant => participant.archivedAt === undefined
|
|
876
|
+
&& participant.status !== 'running'
|
|
877
|
+
&& !hasOpenExecution(participant))
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Whether a task or any of its subtasks still has an open execution. */
|
|
881
|
+
private subtreeHasOpenExecution(id: string): boolean {
|
|
882
|
+
return cascadeTargets(this.document.tasks, id, this.maxSubtaskDepth)
|
|
883
|
+
.some(task => task.status === 'running' || hasOpenExecution(task))
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Open one execution per cascade participant (the requested task and its
|
|
888
|
+
* on-board descendants, within the depth limit) under a single run group, and
|
|
889
|
+
* commit the ledger once. The returned runs are ordered root-first, so the
|
|
890
|
+
* Host launches the parent before its subtasks.
|
|
891
|
+
* @param root - the task the user ran.
|
|
892
|
+
* @param now - clock instant (ms epoch).
|
|
893
|
+
* @param initiator - the DSH session that asked for the run (audit only).
|
|
894
|
+
* @param rerun - true to reset the root to 'todo' before starting it.
|
|
895
|
+
* @param nextRunAt - when set, the root's schedule also rolls forward.
|
|
896
|
+
*/
|
|
897
|
+
private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false, nextRunAt?: number): OpenedRun[] {
|
|
898
|
+
const before = this.document.tasks
|
|
899
|
+
const groupId = crypto.randomUUID()
|
|
900
|
+
// A team-mode root runs as the Team Lead: every other member of the same
|
|
901
|
+
// cascade is handed to that Lead as a teammate (the tree is flattened into
|
|
902
|
+
// one Team, because only the Lead may spawn).
|
|
903
|
+
const team = root.teamRun === true
|
|
904
|
+
const started = new Map<string, TaskRecord>()
|
|
905
|
+
const runs: OpenedRun[] = []
|
|
906
|
+
for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
|
|
907
|
+
if (task.archivedAt !== undefined) continue
|
|
908
|
+
if (task.status === 'running' || hasOpenExecution(task)) continue
|
|
909
|
+
const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
|
|
910
|
+
const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
|
|
911
|
+
started.set(task.id, opened.task)
|
|
912
|
+
runs.push({
|
|
913
|
+
task: resolveExecutionTargets(opened.task, before, this.maxSubtaskDepth),
|
|
914
|
+
execution: opened.execution,
|
|
915
|
+
...(team && task.id !== root.id ? { dispatch: 'teammate' as const } : {}),
|
|
916
|
+
})
|
|
917
|
+
}
|
|
918
|
+
if (runs.length === 0) return []
|
|
919
|
+
let tasks: readonly TaskRecord[] = before.map(task => started.get(task.id) ?? task)
|
|
920
|
+
if (nextRunAt !== undefined) tasks = applyScheduleNextRun(tasks, root.id, nextRunAt, now, now)
|
|
921
|
+
this.document.tasks = [...tasks]
|
|
670
922
|
this.commit()
|
|
671
|
-
return
|
|
923
|
+
return runs
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Finalize every cascade parent that is ready, walking from `taskId` up the
|
|
928
|
+
* lineage: a member settles once its own outcome is recorded and none of its
|
|
929
|
+
* group children is still open, and its verdict folds those children in
|
|
930
|
+
* (failure dominates, then cancellation).
|
|
931
|
+
*/
|
|
932
|
+
private settleCascade(taskId: string, groupId: string, now: number): boolean {
|
|
933
|
+
let changed = false
|
|
934
|
+
const visited = new Set<string>()
|
|
935
|
+
let current: string | undefined = taskId
|
|
936
|
+
while (current !== undefined && !visited.has(current)) {
|
|
937
|
+
visited.add(current)
|
|
938
|
+
const task: TaskRecord | undefined = this.document.tasks.find(item => item.id === current)
|
|
939
|
+
if (task === undefined) break
|
|
940
|
+
const execution = openGroupExecution(task, groupId)
|
|
941
|
+
if (execution === undefined || execution.ownResult === undefined) break
|
|
942
|
+
if (pendingCascadeChildren(this.document.tasks, current, groupId).length > 0) break
|
|
943
|
+
const entries: Array<{ result: ExecutionOutcome; error?: string }> = [
|
|
944
|
+
{ result: execution.ownResult, ...(execution.ownError === undefined ? {} : { error: execution.ownError }) },
|
|
945
|
+
]
|
|
946
|
+
for (const child of this.document.tasks) {
|
|
947
|
+
if (child.parentId !== current) continue
|
|
948
|
+
const childExecution = child.executions.find(entry => entry.runGroupId === groupId && entry.result !== undefined)
|
|
949
|
+
if (childExecution?.result === undefined) continue
|
|
950
|
+
entries.push({ result: childExecution.result, ...(childExecution.error === undefined ? {} : { error: childExecution.error }) })
|
|
951
|
+
}
|
|
952
|
+
const combined = combineCascadeOutcome(entries)
|
|
953
|
+
this.document.tasks = this.document.tasks.map(item => item.id === current
|
|
954
|
+
? settleExecution(item, execution.id, combined.result, now, combined.error)
|
|
955
|
+
: item)
|
|
956
|
+
changed = true
|
|
957
|
+
current = task.parentId
|
|
958
|
+
}
|
|
959
|
+
return changed
|
|
672
960
|
}
|
|
673
961
|
|
|
674
962
|
private repairSchedules(skipPast: boolean, persist = true): void {
|
|
@@ -694,13 +982,23 @@ export class HostTaskLedger {
|
|
|
694
982
|
private reconcileInterruptedStarts(persist = true): void {
|
|
695
983
|
const now = this.now()
|
|
696
984
|
let changed = false
|
|
985
|
+
const interrupted: Array<{ taskId: string; runGroupId: string | undefined }> = []
|
|
697
986
|
this.document.tasks = this.document.tasks.map(task => {
|
|
698
987
|
if (task.status !== 'running') return task
|
|
699
988
|
const execution = task.executions.at(-1)
|
|
700
989
|
if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task
|
|
701
990
|
changed = true
|
|
991
|
+
interrupted.push({ taskId: task.id, runGroupId: execution.runGroupId })
|
|
702
992
|
return settleExecution(task, execution.id, 'cancelled', now, 'host restarted before the execution session was recorded')
|
|
703
993
|
})
|
|
994
|
+
// A cancelled participant may have been the last pending child of a
|
|
995
|
+
// deferred cascade parent; walking up from its parent is what lets that
|
|
996
|
+
// parent finalize instead of staying in the running column forever.
|
|
997
|
+
for (const entry of interrupted) {
|
|
998
|
+
if (entry.runGroupId === undefined) continue
|
|
999
|
+
const parentId = this.document.tasks.find(task => task.id === entry.taskId)?.parentId
|
|
1000
|
+
if (parentId !== undefined && this.settleCascade(parentId, entry.runGroupId, now)) changed = true
|
|
1001
|
+
}
|
|
704
1002
|
if (changed && persist) this.commit()
|
|
705
1003
|
}
|
|
706
1004
|
|
package/src/host-routes.ts
CHANGED
|
@@ -73,15 +73,51 @@ function resolveAccess(access: TaskBoardRouteAccess): ResolvedRouteAccess {
|
|
|
73
73
|
return { trustedProxyHosts, ...(access.proxyToken === undefined ? {} : { proxyToken: access.proxyToken }) }
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Harness browser-auth cookie prefix (dsh-client-connection): the
|
|
78
|
+
* authority-bound signed cookie the Host mints in exchange for its launch
|
|
79
|
+
* token. dsh-web's remote channel redeems that token before re-issuing paired
|
|
80
|
+
* traffic to 127.0.0.1 (packages/dsh-remote-web-ui/src/inner-auth.ts), and the
|
|
81
|
+
* official DSH Desktop shell keeps its own copy from the same exchange and
|
|
82
|
+
* attaches it to every request it forwards for the dsh-app://app/ page. The
|
|
83
|
+
* two constants describe one fact and move together.
|
|
84
|
+
*/
|
|
85
|
+
const BROWSER_AUTH_COOKIE_PREFIX = 'dsh-auth-'
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether a Cookie header carries the Host's browser-auth credential. Only an
|
|
89
|
+
* application on this machine can hold it: the desktop shell never lets it
|
|
90
|
+
* reach the page's cookie jar, and SameSite=Strict keeps a cross-site page
|
|
91
|
+
* from attaching it.
|
|
92
|
+
* @param header - the raw Cookie header value.
|
|
93
|
+
*/
|
|
94
|
+
function carriesBrowserAuthCookie(header: string | undefined): boolean {
|
|
95
|
+
if (header === undefined) return false
|
|
96
|
+
return header.split(';').some(segment => segment.trim().startsWith(BROWSER_AUTH_COOKIE_PREFIX))
|
|
97
|
+
}
|
|
98
|
+
|
|
76
99
|
/**
|
|
77
100
|
* Browser-signal tripwire, NOT an authority check: a bare curl sends neither
|
|
78
101
|
* header and is refused, but a curl with a forged Origin passes this too.
|
|
79
102
|
* The real boundary is the loopback socket + Host + origin-equality checks
|
|
80
103
|
* in isTrustedTaskBoardRequest below; do not rely on this marker alone.
|
|
104
|
+
*
|
|
105
|
+
* A first-party client that presents NEITHER header must still pass. The DSH
|
|
106
|
+
* Desktop shell serves the Web GUI from dsh-app://app/ and forwards that
|
|
107
|
+
* page's Host requests itself, deleting origin and sec-fetch-site on the
|
|
108
|
+
* way (dsh-desktop-host's forwardWebRequest), so the panel's own fetch
|
|
109
|
+
* arrives marker-less and every route behind this guard answered 403 - which
|
|
110
|
+
* the panel renders as its board.hostError.unauthorized message. The shell
|
|
111
|
+
* does attach the Host's browser-auth cookie, redeemed from the Host's launch
|
|
112
|
+
* URL at startup and deliberately withheld from the page. That credential,
|
|
113
|
+
* not a header the shell strips, is the browser signal of an application on
|
|
114
|
+
* this machine; a marker-less, credential-less request stays refused.
|
|
81
115
|
*/
|
|
82
116
|
function browserSameOriginMarker(req: IncomingMessage): boolean {
|
|
83
117
|
const site = req.headers['sec-fetch-site']
|
|
84
|
-
|
|
118
|
+
if (site === 'same-origin') return true
|
|
119
|
+
if (typeof req.headers.origin === 'string') return true
|
|
120
|
+
return carriesBrowserAuthCookie(req.headers.cookie)
|
|
85
121
|
}
|
|
86
122
|
|
|
87
123
|
function sameAuthority(req: IncomingMessage, host: URL): boolean {
|