@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.4
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 +21 -13
- package/README.zh.md +21 -13
- package/lib/client.js +1566 -929
- package/lib/client.js.map +1 -1
- package/lib/index.js +934 -176
- package/lib/types/client/board/TaskForm.d.ts +19 -1
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/locales.d.ts +38 -2
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +42 -2
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +26 -2
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +65 -4
- package/lib/types/host-runner.d.ts +76 -3
- package/lib/types/host-service.d.ts +77 -5
- package/lib/types/index.d.ts +11 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +9 -3
- package/package.json +26 -23
- package/src/client/board/LinkSubtaskModal.tsx +21 -11
- package/src/client/board/NewTaskModal.tsx +271 -150
- package/src/client/board/TaskCard.tsx +24 -5
- package/src/client/board/TaskDetail.tsx +83 -33
- package/src/client/board/TaskForm.tsx +48 -3
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board.module.css +130 -136
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +50 -18
- package/src/client/locales.ts +82 -8
- package/src/client/native-panel.tsx +124 -0
- package/src/client/schedule-zone.ts +135 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +66 -4
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +10 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +38 -4
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host/agent-tools.ts +40 -8
- package/src/host-ledger.ts +223 -23
- package/src/host-routes.ts +37 -1
- package/src/host-runner.ts +164 -14
- package/src/host-service.ts +232 -39
- package/src/index.ts +23 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +27 -8
- 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/lib/types/host/run-guarded.d.ts +0 -39
- 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/run-guarded.ts +0 -76
package/src/host-ledger.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'
|
|
|
3
3
|
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { dshHome } from './dsh-home.ts'
|
|
6
|
-
import { isValidCron, nextRunAtMs } from './core/schedule.ts'
|
|
6
|
+
import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from './core/schedule.ts'
|
|
7
7
|
import { isTaskRecord, parseLedger } from './core/store.ts'
|
|
8
8
|
import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
9
9
|
import {
|
|
@@ -21,7 +21,7 @@ import { applyDeleteTask } from './core/use-cases/task-delete.ts'
|
|
|
21
21
|
import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
|
|
22
22
|
import { applySetParent } from './core/use-cases/task-parent.ts'
|
|
23
23
|
import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
|
|
24
|
-
import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
24
|
+
import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_OLDER_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
25
25
|
import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
|
|
26
26
|
|
|
27
27
|
interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
|
|
@@ -84,12 +84,21 @@ export interface OpenExecutionReference {
|
|
|
84
84
|
readonly executionId: string
|
|
85
85
|
readonly sessionId: string | undefined
|
|
86
86
|
readonly startedAt: number
|
|
87
|
+
/**
|
|
88
|
+
* True for a member of a team-mode run other than its Lead. A teammate is a
|
|
89
|
+
* durable member of the Team: it stays alive after its turn ends, so the
|
|
90
|
+
* roster may keep reporting its session as running, and the turn it completed
|
|
91
|
+
* is the only verdict it exposes.
|
|
92
|
+
*/
|
|
93
|
+
readonly teamMember: boolean
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
/** Minimal value copy used by the Host scheduler. */
|
|
90
97
|
export interface DueScheduleReference {
|
|
91
98
|
readonly taskId: string
|
|
92
99
|
readonly cron: string
|
|
100
|
+
/** Zone this rule's wall clock is read in. */
|
|
101
|
+
readonly timeZone: string
|
|
93
102
|
readonly nextRunAt: number
|
|
94
103
|
}
|
|
95
104
|
|
|
@@ -106,7 +115,12 @@ interface CachedRequest {
|
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
function timeZone(): string {
|
|
109
|
-
return
|
|
118
|
+
return resolveHostTimeZone()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The zone a schedule is evaluated in: its own, or the Host zone when it stores none. */
|
|
122
|
+
function scheduleZone(schedule: { timeZone?: string }): string {
|
|
123
|
+
return schedule.timeZone ?? timeZone()
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
|
|
@@ -381,6 +395,7 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
|
381
395
|
schedule: {
|
|
382
396
|
enabled: false,
|
|
383
397
|
cron: schedule.cron,
|
|
398
|
+
...(typeof schedule.timeZone === 'string' && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {}),
|
|
384
399
|
nextRunAt: undefined,
|
|
385
400
|
lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt)
|
|
386
401
|
? schedule.lastTriggeredAt
|
|
@@ -426,6 +441,10 @@ export class HostTaskLedger {
|
|
|
426
441
|
}
|
|
427
442
|
this.repairSchedules(true)
|
|
428
443
|
this.reconcileInterruptedStarts()
|
|
444
|
+
// Boot recovery also folds every already-decided run: a team run whose
|
|
445
|
+
// Lead recorded its verdict in an earlier process, and a lineage whose
|
|
446
|
+
// last settle never reached its parent, both leave the running column here.
|
|
447
|
+
this.finalizeReadyRuns(false)
|
|
429
448
|
// Persist a freshly generated ledger identity and any recovery error
|
|
430
449
|
// immediately, even when there are no tasks to trigger a later action.
|
|
431
450
|
this.commit(false)
|
|
@@ -488,6 +507,15 @@ export class HostTaskLedger {
|
|
|
488
507
|
*/
|
|
489
508
|
runtimeView(): LedgerRuntimeView {
|
|
490
509
|
let armedSchedules = 0
|
|
510
|
+
// Run groups opened by a team-mode card: every other member of those groups
|
|
511
|
+
// runs as a teammate inside that card's Lead session.
|
|
512
|
+
const teamGroups = new Set<string>()
|
|
513
|
+
for (const task of this.document.tasks) {
|
|
514
|
+
if (task.teamRun !== true) continue
|
|
515
|
+
for (const execution of task.executions) {
|
|
516
|
+
if (execution.runGroupId !== undefined) teamGroups.add(execution.runGroupId)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
491
519
|
const openExecutions: OpenExecutionReference[] = []
|
|
492
520
|
for (const task of this.document.tasks) {
|
|
493
521
|
if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
|
|
@@ -501,6 +529,9 @@ export class HostTaskLedger {
|
|
|
501
529
|
executionId: execution.id,
|
|
502
530
|
sessionId: execution.sessionId,
|
|
503
531
|
startedAt: execution.startedAt,
|
|
532
|
+
teamMember: task.teamRun !== true
|
|
533
|
+
&& execution.runGroupId !== undefined
|
|
534
|
+
&& teamGroups.has(execution.runGroupId),
|
|
504
535
|
})
|
|
505
536
|
}
|
|
506
537
|
}
|
|
@@ -516,6 +547,26 @@ export class HostTaskLedger {
|
|
|
516
547
|
return count
|
|
517
548
|
}
|
|
518
549
|
|
|
550
|
+
/**
|
|
551
|
+
* The earliest armed `nextRunAt` strictly after the supplied Host time, or
|
|
552
|
+
* undefined when no schedule is armed in the future. This is the instant the
|
|
553
|
+
* Host arms its native timer at, so the board wakes exactly when work is due
|
|
554
|
+
* instead of polling a fixed heartbeat.
|
|
555
|
+
* @param now - current Host time in ms epoch.
|
|
556
|
+
* @returns the nearest future trigger, or undefined when none is armed.
|
|
557
|
+
*/
|
|
558
|
+
nextArmedRunAt(now: number): number | undefined {
|
|
559
|
+
let nearest: number | undefined
|
|
560
|
+
for (const task of this.document.tasks) {
|
|
561
|
+
if (task.archivedAt !== undefined) continue
|
|
562
|
+
const schedule = task.schedule
|
|
563
|
+
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined) continue
|
|
564
|
+
if (schedule.nextRunAt <= now) continue
|
|
565
|
+
if (nearest === undefined || schedule.nextRunAt < nearest) nearest = schedule.nextRunAt
|
|
566
|
+
}
|
|
567
|
+
return nearest
|
|
568
|
+
}
|
|
569
|
+
|
|
519
570
|
/** Return value-only references for schedules due at the supplied Host time. */
|
|
520
571
|
dueSchedules(now: number): DueScheduleReference[] {
|
|
521
572
|
const due: DueScheduleReference[] = []
|
|
@@ -523,7 +574,7 @@ export class HostTaskLedger {
|
|
|
523
574
|
if (task.archivedAt !== undefined) continue
|
|
524
575
|
const schedule = task.schedule
|
|
525
576
|
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
|
|
526
|
-
due.push({ taskId: task.id, cron: schedule.cron, nextRunAt: schedule.nextRunAt })
|
|
577
|
+
due.push({ taskId: task.id, cron: schedule.cron, timeZone: scheduleZone(schedule), nextRunAt: schedule.nextRunAt })
|
|
527
578
|
}
|
|
528
579
|
return due
|
|
529
580
|
}
|
|
@@ -606,7 +657,7 @@ export class HostTaskLedger {
|
|
|
606
657
|
const schedule = task.schedule
|
|
607
658
|
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
|
|
608
659
|
changed = true
|
|
609
|
-
return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now) }, updatedAt: now }
|
|
660
|
+
return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now, scheduleZone(schedule)) }, updatedAt: now }
|
|
610
661
|
})
|
|
611
662
|
if (changed) this.commit()
|
|
612
663
|
}
|
|
@@ -661,16 +712,26 @@ export class HostTaskLedger {
|
|
|
661
712
|
})
|
|
662
713
|
changed = true
|
|
663
714
|
}
|
|
715
|
+
// A team run is governed by its Lead: the Lead's own outcome closes whatever
|
|
716
|
+
// the Team still holds open, so the tree can never wait on a teammate that
|
|
717
|
+
// will never report one.
|
|
718
|
+
if (task.teamRun === true
|
|
719
|
+
&& this.closeTeamMembers(taskId, groupId, execution.ownResult ?? outcome, execution.ownError ?? error, now)) changed = true
|
|
664
720
|
if (this.settleCascade(taskId, groupId, now)) changed = true
|
|
665
721
|
if (changed) this.commit()
|
|
666
722
|
}
|
|
667
723
|
|
|
668
724
|
attachSession(taskId: string, executionId: string, sessionId: string): void {
|
|
669
725
|
const now = this.now()
|
|
670
|
-
|
|
671
|
-
|
|
726
|
+
const task = this.document.tasks.find(item => item.id === taskId)
|
|
727
|
+
// A run the Lead's verdict already closed keeps its record: attaching a
|
|
728
|
+
// session to a settled execution would rewrite history the Host no longer
|
|
729
|
+
// observes (a teammate spawn that resolved after its Team was closed).
|
|
730
|
+
if (task?.executions.find(entry => entry.id === executionId)?.endedAt !== undefined) return
|
|
731
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
|
|
732
|
+
...item,
|
|
672
733
|
updatedAt: now,
|
|
673
|
-
executions:
|
|
734
|
+
executions: item.executions.map(entry => entry.id === executionId ? { ...entry, sessionId } : entry),
|
|
674
735
|
})
|
|
675
736
|
this.commit()
|
|
676
737
|
}
|
|
@@ -697,17 +758,22 @@ export class HostTaskLedger {
|
|
|
697
758
|
: `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}`
|
|
698
759
|
this.repairSchedules(true, false)
|
|
699
760
|
this.reconcileInterruptedStarts(false)
|
|
761
|
+
// An imported document may carry a decided-but-open run; fold it in the
|
|
762
|
+
// same action (apply() commits once at the end).
|
|
763
|
+
this.finalizeReadyRuns(false)
|
|
700
764
|
break
|
|
701
765
|
}
|
|
702
766
|
case 'create': {
|
|
703
767
|
if (this.document.tasks.some(task => task.id === action.id)) throw new Error('task id already exists')
|
|
704
|
-
if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron)
|
|
768
|
+
if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron)
|
|
769
|
+
|| !isValidTimeZone(action.input.schedule.timeZone ?? timeZone())
|
|
770
|
+
|| nextRunAtMs(action.input.schedule.cron, now, action.input.schedule.timeZone ?? timeZone()) === undefined)) {
|
|
705
771
|
throw new Error('invalid schedule')
|
|
706
772
|
}
|
|
707
773
|
const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
|
|
708
774
|
? action.input
|
|
709
775
|
: { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
|
|
710
|
-
const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth)
|
|
776
|
+
const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone())
|
|
711
777
|
if (result.task === undefined) throw new Error(result.error ?? 'invalid task')
|
|
712
778
|
this.document.tasks = [...result.tasks]
|
|
713
779
|
break
|
|
@@ -766,6 +832,34 @@ export class HostTaskLedger {
|
|
|
766
832
|
this.document.tasks = [...result.tasks]
|
|
767
833
|
break
|
|
768
834
|
}
|
|
835
|
+
case 'settle': {
|
|
836
|
+
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
837
|
+
if (task === undefined) throw new Error('task not found')
|
|
838
|
+
if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
839
|
+
if (!task.executions.some(entry => entry.endedAt === undefined)) throw new Error('task has no open execution')
|
|
840
|
+
// Force-close the executions this card governs. The verdict is
|
|
841
|
+
// 'cancelled', never 'succeeded': a card whose execution produced no
|
|
842
|
+
// evidence of a completed turn must not be recorded as finished work,
|
|
843
|
+
// and a cancelled settlement returns the card to the todo column so the
|
|
844
|
+
// operator can run it again. Nothing else clears a running card: move,
|
|
845
|
+
// archive and delete all refuse one, which is what made a stuck card
|
|
846
|
+
// unrecoverable from the board.
|
|
847
|
+
const reason = 'execution closed manually by '
|
|
848
|
+
+ (initiator === undefined || initiator === '' ? 'the operator' : initiator)
|
|
849
|
+
+ ': no verdict was recorded'
|
|
850
|
+
for (const member of cascadeTargets(this.document.tasks, action.taskId, this.maxSubtaskDepth)) {
|
|
851
|
+
for (const execution of member.executions) {
|
|
852
|
+
if (execution.endedAt !== undefined) continue
|
|
853
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== member.id
|
|
854
|
+
? item
|
|
855
|
+
: settleExecution(item, execution.id, 'cancelled', now, reason))
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// Fold whatever became ready in the same action: an ancestor whose last
|
|
859
|
+
// member this closed leaves the running column with it.
|
|
860
|
+
this.finalizeReadyRuns(false)
|
|
861
|
+
break
|
|
862
|
+
}
|
|
769
863
|
case 'restore': {
|
|
770
864
|
if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
|
|
771
865
|
const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
|
|
@@ -785,7 +879,7 @@ export class HostTaskLedger {
|
|
|
785
879
|
case 'set-schedule': {
|
|
786
880
|
const task = this.document.tasks.find(task => task.id === action.taskId)
|
|
787
881
|
if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
788
|
-
const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now)
|
|
882
|
+
const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now, timeZone())
|
|
789
883
|
if (!result.applied) throw new Error('invalid schedule')
|
|
790
884
|
this.document.tasks = [...result.tasks]
|
|
791
885
|
break
|
|
@@ -939,6 +1033,95 @@ export class HostTaskLedger {
|
|
|
939
1033
|
return changed
|
|
940
1034
|
}
|
|
941
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* Close every run that is already decided, and fold every lineage whose
|
|
1038
|
+
* members are all settled. The Host poll calls this on every tick, so a run
|
|
1039
|
+
* can no longer be left in the running column by a settle nobody observed:
|
|
1040
|
+
*
|
|
1041
|
+
* - A team run is governed by its Lead. Once the Lead's own outcome is
|
|
1042
|
+
* recorded, its still-open members are settled with that verdict. A
|
|
1043
|
+
* teammate is a durable member of the Team and may never report a turn of
|
|
1044
|
+
* its own, and without this the whole lineage waits on it forever.
|
|
1045
|
+
* - A cascade parent whose members are all settled is finalized here even
|
|
1046
|
+
* when the settle that completed the tree happened in another process, or
|
|
1047
|
+
* was interrupted between its child write and its parent write.
|
|
1048
|
+
*
|
|
1049
|
+
* Idempotent: a run that is already folded is left untouched, so a caller may
|
|
1050
|
+
* invoke it on every tick.
|
|
1051
|
+
* @param persist - false to leave the document for the caller to commit.
|
|
1052
|
+
* @returns whether the document changed.
|
|
1053
|
+
*/
|
|
1054
|
+
finalizeReadyRuns(persist = true): boolean {
|
|
1055
|
+
const now = this.now()
|
|
1056
|
+
let changed = false
|
|
1057
|
+
const groups = new Set<string>()
|
|
1058
|
+
for (const task of this.document.tasks) {
|
|
1059
|
+
for (const execution of task.executions) {
|
|
1060
|
+
if (execution.endedAt === undefined && execution.runGroupId !== undefined) groups.add(execution.runGroupId)
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
for (const groupId of groups) {
|
|
1064
|
+
const lead = this.document.tasks.find(task => task.teamRun === true
|
|
1065
|
+
&& task.executions.some(entry => entry.runGroupId === groupId))
|
|
1066
|
+
const verdict = lead === undefined ? undefined : this.leadVerdict(lead, groupId)
|
|
1067
|
+
if (lead !== undefined && verdict !== undefined
|
|
1068
|
+
&& this.closeTeamMembers(lead.id, groupId, verdict.result, verdict.error, now)) changed = true
|
|
1069
|
+
// Fold the lineage: a member may itself be a parent of a deeper member.
|
|
1070
|
+
for (const task of this.document.tasks) {
|
|
1071
|
+
if (this.settleCascade(task.id, groupId, now)) changed = true
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (changed && persist) this.commit()
|
|
1075
|
+
return changed
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* The verdict a team run's Lead has already recorded: its own turn outcome
|
|
1080
|
+
* once that turn ended, or the folded outcome once its card settled.
|
|
1081
|
+
*/
|
|
1082
|
+
private leadVerdict(lead: TaskRecord, groupId: string): { result: ExecutionOutcome; error: string | undefined } | undefined {
|
|
1083
|
+
const execution = lead.executions.find(entry => entry.runGroupId === groupId)
|
|
1084
|
+
const result = execution?.ownResult ?? execution?.result
|
|
1085
|
+
if (result === undefined) return undefined
|
|
1086
|
+
return { result, error: execution?.ownError ?? execution?.error }
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Settle every still-open member of a team run with the Lead's verdict.
|
|
1091
|
+
* A teammate that already reported its own outcome keeps it: the ordinary
|
|
1092
|
+
* fold still lets a failure dominate.
|
|
1093
|
+
* @param leadId - the Lead card's task id (never settled here).
|
|
1094
|
+
* @param groupId - the team run's group.
|
|
1095
|
+
* @param verdict - the Lead's outcome, inherited by members that never reported.
|
|
1096
|
+
* @param error - the Lead's failure text, inherited with a failed verdict.
|
|
1097
|
+
* @param now - clock instant (ms epoch).
|
|
1098
|
+
* @returns whether the document changed.
|
|
1099
|
+
*/
|
|
1100
|
+
private closeTeamMembers(leadId: string, groupId: string, verdict: ExecutionOutcome, error: string | undefined, now: number): boolean {
|
|
1101
|
+
const members: string[] = []
|
|
1102
|
+
let recorded = false
|
|
1103
|
+
this.document.tasks = this.document.tasks.map(task => {
|
|
1104
|
+
if (task.id === leadId) return task
|
|
1105
|
+
const execution = openGroupExecution(task, groupId)
|
|
1106
|
+
if (execution === undefined) return task
|
|
1107
|
+
members.push(task.id)
|
|
1108
|
+
if (execution.ownResult !== undefined) return task
|
|
1109
|
+
recorded = true
|
|
1110
|
+
return {
|
|
1111
|
+
...task,
|
|
1112
|
+
updatedAt: now,
|
|
1113
|
+
executions: task.executions.map(entry => entry.id === execution.id
|
|
1114
|
+
? { ...entry, ownResult: verdict, ownError: error }
|
|
1115
|
+
: entry),
|
|
1116
|
+
}
|
|
1117
|
+
})
|
|
1118
|
+
let changed = recorded
|
|
1119
|
+
for (const memberId of members) {
|
|
1120
|
+
if (this.settleCascade(memberId, groupId, now)) changed = true
|
|
1121
|
+
}
|
|
1122
|
+
return changed
|
|
1123
|
+
}
|
|
1124
|
+
|
|
942
1125
|
private repairSchedules(skipPast: boolean, persist = true): void {
|
|
943
1126
|
const now = this.now()
|
|
944
1127
|
let changed = false
|
|
@@ -946,7 +1129,7 @@ export class HostTaskLedger {
|
|
|
946
1129
|
const schedule = task.schedule
|
|
947
1130
|
if (schedule === undefined || !schedule.enabled) return task
|
|
948
1131
|
if (!skipPast && schedule.nextRunAt !== undefined) return task
|
|
949
|
-
const next = nextRunAtMs(schedule.cron, now)
|
|
1132
|
+
const next = nextRunAtMs(schedule.cron, now, scheduleZone(schedule))
|
|
950
1133
|
if (next === undefined) {
|
|
951
1134
|
changed = true
|
|
952
1135
|
this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
|
|
@@ -983,35 +1166,52 @@ export class HostTaskLedger {
|
|
|
983
1166
|
}
|
|
984
1167
|
|
|
985
1168
|
/**
|
|
986
|
-
* Field-preserving
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
1169
|
+
* Field-preserving migration of a pre-v4 document. It first proves every task
|
|
1170
|
+
* row is structurally valid, so a document that would silently drop or coerce
|
|
1171
|
+
* rows fails loudly instead (no quarantined-empty restart), and then reuses
|
|
1172
|
+
* the current normalization.
|
|
1173
|
+
*
|
|
1174
|
+
* v4 adds `ScheduleRule.timeZone`. A rule written before v4 was evaluated in
|
|
1175
|
+
* whatever zone the Host process happened to report, so the migration stamps
|
|
1176
|
+
* that zone onto every enabled rule: the trigger instant is preserved exactly
|
|
1177
|
+
* (the stored `nextRunAt` already encodes the old zone), but the rule stops
|
|
1178
|
+
* following a later `TZ` change, which is what made an existing schedule
|
|
1179
|
+
* silently move when the Host's zone changed.
|
|
990
1180
|
*/
|
|
991
1181
|
private migrateLegacyDocument(parsed: ParsedLedgerDocument): LedgerDocument {
|
|
992
1182
|
if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
|
|
993
|
-
throw new Error(
|
|
1183
|
+
throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`)
|
|
994
1184
|
}
|
|
995
|
-
|
|
1185
|
+
// Every row passed `isTaskRecord` above, so the stamped rows are still
|
|
1186
|
+
// task records; the cast only re-narrows the unknown-typed on-disk array.
|
|
1187
|
+
const rows = (parsed.tasks as readonly unknown[]).map((value) => {
|
|
1188
|
+
const row = value as { schedule?: unknown }
|
|
1189
|
+
const schedule = row.schedule
|
|
1190
|
+
if (typeof schedule !== 'object' || schedule === null) return value
|
|
1191
|
+
if (typeof (schedule as { timeZone?: unknown }).timeZone === 'string') return value
|
|
1192
|
+
return { ...(value as object), schedule: { ...(schedule as object), timeZone: timeZone() } }
|
|
1193
|
+
})
|
|
1194
|
+
return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
|
|
996
1195
|
}
|
|
997
1196
|
|
|
998
1197
|
private load(dir: string): LedgerDocument {
|
|
999
1198
|
const existed = existsSync(this.file)
|
|
1000
|
-
// schemaVersion stays unknown-typed here: on-disk documents may be v2
|
|
1001
|
-
// (legacy),
|
|
1199
|
+
// schemaVersion stays unknown-typed here: on-disk documents may be v2 or
|
|
1200
|
+
// v3 (legacy), v4, or any future/invalid value the branches below sort out.
|
|
1002
1201
|
let parsed: ParsedLedgerDocument
|
|
1003
1202
|
try {
|
|
1004
1203
|
parsed = JSON.parse(readFileSync(this.file, 'utf8')) as ParsedLedgerDocument
|
|
1005
1204
|
} catch (error) {
|
|
1006
1205
|
return this.recoverCorrupt(dir, existed, error)
|
|
1007
1206
|
}
|
|
1008
|
-
if (parsed.schemaVersion === TASK_BOARD_LEGACY_SCHEMA_VERSION
|
|
1207
|
+
if (parsed.schemaVersion === TASK_BOARD_LEGACY_SCHEMA_VERSION
|
|
1208
|
+
|| parsed.schemaVersion === TASK_BOARD_OLDER_SCHEMA_VERSION) {
|
|
1009
1209
|
try {
|
|
1010
1210
|
return this.migrateLegacyDocument(parsed)
|
|
1011
1211
|
} catch (error) {
|
|
1012
|
-
// Migration failure is explicit: the original
|
|
1212
|
+
// Migration failure is explicit: the original file stays in place
|
|
1013
1213
|
// for manual recovery and the ledger refuses to start (fail closed).
|
|
1014
|
-
throw new Error(`ledger
|
|
1214
|
+
throw new Error(`ledger v${String(parsed.schemaVersion)} to v${TASK_BOARD_SCHEMA_VERSION} migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`)
|
|
1015
1215
|
}
|
|
1016
1216
|
}
|
|
1017
1217
|
try {
|
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 {
|