@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.
Files changed (73) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -13
  3. package/README.zh.md +21 -13
  4. package/lib/client.js +1566 -929
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +934 -176
  7. package/lib/types/client/board/TaskForm.d.ts +19 -1
  8. package/lib/types/client/board/preset-label.d.ts +22 -0
  9. package/lib/types/client/host-api.d.ts +24 -1
  10. package/lib/types/client/index.d.ts +15 -1
  11. package/lib/types/client/locales.d.ts +38 -2
  12. package/lib/types/client/native-panel.d.ts +62 -0
  13. package/lib/types/client/schedule-zone.d.ts +47 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +42 -2
  16. package/lib/types/core/degraded-reason.d.ts +46 -0
  17. package/lib/types/core/package-name.d.ts +9 -0
  18. package/lib/types/core/schedule.d.ts +95 -20
  19. package/lib/types/core/subtask.d.ts +30 -11
  20. package/lib/types/core/tasks.d.ts +26 -2
  21. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  22. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  23. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  24. package/lib/types/host-ledger.d.ts +65 -4
  25. package/lib/types/host-runner.d.ts +76 -3
  26. package/lib/types/host-service.d.ts +77 -5
  27. package/lib/types/index.d.ts +11 -1
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +9 -3
  30. package/package.json +26 -23
  31. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  32. package/src/client/board/NewTaskModal.tsx +271 -150
  33. package/src/client/board/TaskCard.tsx +24 -5
  34. package/src/client/board/TaskDetail.tsx +83 -33
  35. package/src/client/board/TaskForm.tsx +48 -3
  36. package/src/client/board/preset-label.ts +44 -0
  37. package/src/client/board.module.css +130 -136
  38. package/src/client/host-api.ts +109 -6
  39. package/src/client/index.ts +50 -18
  40. package/src/client/locales.ts +82 -8
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/schedule-zone.ts +135 -0
  43. package/src/client/settings-entry-form.ts +142 -0
  44. package/src/core/controller.ts +66 -4
  45. package/src/core/degraded-reason.ts +81 -0
  46. package/src/core/package-name.ts +9 -0
  47. package/src/core/schedule.ts +211 -55
  48. package/src/core/store.ts +10 -1
  49. package/src/core/subtask.ts +77 -22
  50. package/src/core/tasks.ts +38 -4
  51. package/src/core/use-cases/task-create.ts +14 -2
  52. package/src/core/use-cases/task-schedule.ts +17 -6
  53. package/src/core/use-cases/task-update.ts +4 -1
  54. package/src/host/agent-tools.ts +40 -8
  55. package/src/host-ledger.ts +223 -23
  56. package/src/host-routes.ts +37 -1
  57. package/src/host-runner.ts +164 -14
  58. package/src/host-service.ts +232 -39
  59. package/src/index.ts +23 -1
  60. package/src/mount-once.ts +107 -13
  61. package/src/protocol.ts +27 -8
  62. package/lib/types/client/board-mount.d.ts +0 -23
  63. package/lib/types/client/body-mutations.d.ts +0 -45
  64. package/lib/types/client/panel-mount-core.d.ts +0 -56
  65. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  66. package/lib/types/client/sidebar-entry.d.ts +0 -29
  67. package/lib/types/host/run-guarded.d.ts +0 -39
  68. package/src/client/board-mount.tsx +0 -43
  69. package/src/client/body-mutations.ts +0 -133
  70. package/src/client/panel-mount-core.ts +0 -155
  71. package/src/client/sidebar-entry-core.ts +0 -237
  72. package/src/client/sidebar-entry.ts +0 -53
  73. package/src/host/run-guarded.ts +0 -76
@@ -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 Intl.DateTimeFormat().resolvedOptions().timeZone || 'local'
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
- this.document.tasks = this.document.tasks.map(task => task.id !== taskId ? task : {
671
- ...task,
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: task.executions.map(entry => entry.id === executionId ? { ...entry, sessionId } : entry),
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) || nextRunAtMs(action.input.schedule.cron, now) === undefined)) {
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 v2 to v3 migration. v3 adds no fields yet, so the
987
- * migration reuses the v3 normalization, but it first proves every task
988
- * row is structurally valid: a v2 document that would silently drop or
989
- * coerce rows fails loudly instead (no quarantined-empty restart).
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('v2 document contains structurally invalid task rows')
1183
+ throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`)
994
1184
  }
995
- return this.normalizeDocument(parsed)
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), v3, or any future/invalid value the branches below sort out.
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 v2 file stays in place
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 v2 to v3 migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`)
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 {
@@ -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
- return site === 'same-origin' || typeof req.headers.origin === 'string'
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 {