@linxin666/dsh-client-ui-task-board 0.2.8 → 0.3.0

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.
@@ -51,12 +51,13 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
51
51
  /* --- sidebar entry row ------------------------------------------------------- */
52
52
 
53
53
  .entry {
54
+ box-sizing: border-box;
54
55
  display: flex;
55
56
  align-items: center;
56
57
  gap: 8px;
57
58
  width: 100%;
58
- height: 32px;
59
- padding: 0 12px;
59
+ height: 36px;
60
+ padding: 0 10px;
60
61
  background: transparent;
61
62
  border: none;
62
63
  border-radius: 8px;
@@ -67,12 +68,12 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
67
68
  }
68
69
 
69
70
  .entry:hover {
70
- background: var(--dsw-specific-sidebar-nav-item-hover);
71
+ background: var(--dsw-alias-interactive-bg-hover);
71
72
  color: var(--dsw-alias-label-primary);
72
73
  }
73
74
 
74
75
  .entry[data-active] {
75
- background: var(--dsw-specific-sidebar-nav-item-active);
76
+ background: var(--dsw-alias-interactive-bg-active);
76
77
  color: var(--dsw-alias-label-primary);
77
78
  font-weight: 600;
78
79
  }
@@ -81,9 +82,17 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
81
82
  display: inline-flex;
82
83
  align-items: center;
83
84
  justify-content: center;
85
+ width: 24px;
86
+ height: 24px;
84
87
  flex: none;
85
88
  }
86
89
 
90
+ .entryIcon svg {
91
+ display: block;
92
+ width: 18px;
93
+ height: 18px;
94
+ }
95
+
87
96
  .entryLabel {
88
97
  overflow: hidden;
89
98
  text-overflow: ellipsis;
@@ -93,7 +102,10 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
93
102
  [data-dsh-frame][data-sidebar-collapsed] .entry {
94
103
  justify-content: center;
95
104
  padding: 0;
96
- width: 100%;
105
+ width: 36px;
106
+ height: 36px;
107
+ margin: 0 auto 12px;
108
+ border-radius: 50%;
97
109
  }
98
110
 
99
111
  [data-dsh-frame][data-sidebar-collapsed] .entryLabel {
@@ -809,31 +821,191 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
809
821
 
810
822
  /* --- constrained board containers ------------------------------------------ */
811
823
 
812
- @container task-board-view (max-width: 720px) {
824
+ @container task-board-view (max-width: 768px) {
813
825
  .board {
814
826
  gap: 10px;
815
827
  padding: 10px;
816
828
  }
817
829
 
830
+ .boardHeader {
831
+ align-items: center;
832
+ flex-wrap: wrap;
833
+ gap: 8px;
834
+ }
835
+
836
+ .backButton {
837
+ flex: 0 0 auto;
838
+ order: 1;
839
+ }
840
+
841
+ .boardTitle {
842
+ flex: 1 1 auto;
843
+ order: 2;
844
+ }
845
+
846
+ .boardHeader > .detailMeta {
847
+ flex: 1 0 100%;
848
+ margin-left: 0;
849
+ order: 3;
850
+ }
851
+
852
+ .search {
853
+ flex: 1 0 100%;
854
+ min-width: 0;
855
+ order: 4;
856
+ }
857
+
858
+ .boardHeader > button:not(.backButton) {
859
+ flex: 1 1 0;
860
+ min-width: 0;
861
+ order: 5;
862
+ }
863
+
818
864
  .columns {
865
+ grid-auto-columns: 86cqw;
819
866
  gap: 10px;
867
+ padding-inline: 2px 14cqw;
868
+ scroll-padding-inline: 2px;
869
+ scroll-snap-type: inline mandatory;
870
+ scrollbar-width: none;
871
+ -webkit-overflow-scrolling: touch;
872
+ }
873
+
874
+ .columns::-webkit-scrollbar {
875
+ display: none;
876
+ }
877
+
878
+ .column {
879
+ scroll-snap-align: start;
880
+ scroll-snap-stop: always;
881
+ }
882
+ }
883
+
884
+ @container task-board-view (max-width: 720px) {
885
+ .boardHeader > .detailMeta {
886
+ overflow: hidden;
887
+ text-overflow: ellipsis;
888
+ white-space: nowrap;
820
889
  }
821
890
  }
822
891
 
823
892
  @container task-board-view (max-width: 600px) {
824
- .boardHeader {
893
+ .board {
894
+ padding-inline: 8px;
895
+ }
896
+ }
897
+
898
+ /* Viewport-specific mobile treatment: overlays occupy the dynamic viewport,
899
+ preserve notches/home indicators, and keep their action rows reachable. */
900
+ @media (max-width: 768px) {
901
+ [data-dsh-taskboard-view] {
902
+ height: 100vh;
903
+ height: 100dvh;
904
+ }
905
+
906
+ .entry,
907
+ .card,
908
+ .primaryButton,
909
+ .ghostButton,
910
+ .dangerButton,
911
+ .iconButton,
912
+ .linkButton,
913
+ .search,
914
+ .input,
915
+ .select,
916
+ .schedulePreset,
917
+ .scheduleToggle {
918
+ min-height: 44px;
919
+ }
920
+
921
+ .search,
922
+ .input,
923
+ .select,
924
+ .schedulePreset {
925
+ box-sizing: border-box;
926
+ font-size: 16px;
927
+ }
928
+
929
+ .modalBackdrop {
930
+ align-items: stretch;
931
+ justify-content: stretch;
932
+ width: 100vw;
933
+ height: 100vh;
934
+ height: 100dvh;
935
+ }
936
+
937
+ .modal,
938
+ .detail {
939
+ box-sizing: border-box;
940
+ width: 100vw;
941
+ height: 100vh;
942
+ height: 100dvh;
943
+ max-height: none;
944
+ border: 0;
945
+ border-radius: 0;
946
+ }
947
+
948
+ .modal {
949
+ padding-top: max(16px, env(safe-area-inset-top));
950
+ padding-right: max(16px, env(safe-area-inset-right));
951
+ padding-bottom: max(16px, env(safe-area-inset-bottom));
952
+ padding-left: max(16px, env(safe-area-inset-left));
953
+ }
954
+
955
+ .modalFooter {
956
+ position: sticky;
957
+ bottom: 0;
958
+ z-index: 1;
825
959
  flex-wrap: wrap;
826
- gap: 8px;
960
+ padding-top: 8px;
961
+ background: var(--dsw-alias-bg-base);
827
962
  }
828
963
 
829
- .search {
830
- flex: 1 1 calc(100% - 72px);
964
+ .modalFooter > button {
965
+ flex: 1 1 120px;
966
+ }
967
+
968
+ .detailHeader {
969
+ flex-wrap: wrap;
970
+ padding-top: max(12px, env(safe-area-inset-top));
971
+ padding-right: max(14px, env(safe-area-inset-right));
972
+ padding-left: max(14px, env(safe-area-inset-left));
973
+ }
974
+
975
+ .detailTitle {
831
976
  min-width: 0;
832
977
  }
833
978
 
834
- .boardHeader > button {
835
- flex: 1 1 0;
836
- min-width: max-content;
979
+ .detailBody {
980
+ overscroll-behavior-y: contain;
981
+ padding-right: max(14px, env(safe-area-inset-right));
982
+ padding-left: max(14px, env(safe-area-inset-left));
983
+ }
984
+
985
+ .detailFooter {
986
+ flex-wrap: wrap;
987
+ padding-right: max(14px, env(safe-area-inset-right));
988
+ padding-bottom: max(12px, env(safe-area-inset-bottom));
989
+ padding-left: max(14px, env(safe-area-inset-left));
990
+ }
991
+
992
+ .detailFooter > button {
993
+ flex: 1 1 96px;
994
+ }
995
+
996
+ .detailFooter > .detailMeta {
997
+ flex: 1 0 100%;
998
+ margin-left: 0;
999
+ text-align: end;
1000
+ }
1001
+
1002
+ .scheduleRow {
1003
+ align-items: stretch;
1004
+ flex-direction: column;
1005
+ }
1006
+
1007
+ .schedulePreset {
1008
+ width: 100%;
837
1009
  }
838
1010
  }
839
1011
 
@@ -19,8 +19,8 @@ import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-co
19
19
  /** Stable data attribute identifying the injected entry row. */
20
20
  export const ENTRY_SELECTOR = '[data-dsh-taskboard-entry]'
21
21
 
22
- /** Inline icon (matches the shell's 16px nav-icon look). */
23
- const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>'
22
+ /** Inline icon normalized to the shell's 18px navigation glyph size. */
23
+ const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>'
24
24
 
25
25
  /**
26
26
  * Mount the sidebar entry, waiting for the shell to render and self-healing
@@ -2,22 +2,17 @@
2
2
  * Board controller: the single owner of task-ledger state and view state.
3
3
  *
4
4
  * In production it projects the Host ledger and submits confirmed actions;
5
- * the legacy store/execution seams remain for v1 migration tests. It also
6
- * closes the board whenever the user navigates to a session.
5
+ * the legacy store seam remains for v1 migration tests. It also closes the
6
+ * board whenever the user navigates to a session.
7
7
  * Framework-free (structural runtime faces) so the whole orchestration is
8
8
  * unit-testable with fakes.
9
9
  *
10
10
  * The per use-case domain transitions (create/update/delete/schedule) live in
11
11
  * dedicated modules under core/use-cases and are applied here; the controller
12
- * owns only the orchestration seam (state, persistence, notify, execution,
13
- * navigation, reconciliation).
12
+ * owns only the orchestration seam (state, persistence, notify, navigation).
14
13
  */
15
- import type { ExecutionEvent, ExecutionService } from './execution.ts'
16
14
  import type { TaskStore } from './store.ts'
17
- import {
18
- settleExecution, startExecution, withStatus,
19
- type NewTaskInput, type TaskRecord, type TaskStatus,
20
- } from './tasks.ts'
15
+ import { withStatus, type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts'
21
16
  import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
22
17
  import { applyCreateTask } from './use-cases/task-create.ts'
23
18
  import { applyDeleteTask } from './use-cases/task-delete.ts'
@@ -45,15 +40,11 @@ export interface SessionsControllerFace {
45
40
  /** Controller dependencies (all swappable in tests). */
46
41
  export interface ControllerDeps {
47
42
  store: TaskStore
48
- /** Legacy browser execution seam used only by isolated v1 tests. */
49
- exec?: ExecutionService
50
43
  sessions: SessionsControllerFace
51
44
  /** Clock; defaults to Date.now. */
52
45
  now?: () => number
53
46
  /** Id minting; defaults to a random-uuid. */
54
47
  uuid?: () => string
55
- /** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
56
- reconcileDebounceMs?: number
57
48
  /** Host-authoritative transport; absent keeps the legacy in-memory test path. */
58
49
  transport?: TaskBoardTransport
59
50
  }
@@ -147,7 +138,7 @@ export class BoardController {
147
138
  private remoteSubscribed = false
148
139
  private remoteInitialization: Promise<boolean> | undefined
149
140
 
150
- /** @param deps - store, execution service, and the sessions navigation face. */
141
+ /** @param deps - store and the sessions navigation face. */
151
142
  constructor(private readonly deps: ControllerDeps) {
152
143
  this.now = deps.now ?? (() => Date.now())
153
144
  this.uuid = deps.uuid ?? randomUuid
@@ -158,11 +149,7 @@ export class BoardController {
158
149
  /** Load the persisted ledger and start the navigation/status subscriptions. */
159
150
  start(): void {
160
151
  this.tasks = this.deps.store.load()
161
- if (this.deps.transport !== undefined) {
162
- void this.initializeRemote()
163
- } else {
164
- void this.reconcileRunningTasks()
165
- }
152
+ if (this.deps.transport !== undefined) void this.initializeRemote()
166
153
  // A sibling tab may have edited or deleted the ledger (same origin,
167
154
  // storage events). Reload on external change so a task deleted in
168
155
  // another tab stops firing here — and is never written back by this
@@ -182,8 +169,6 @@ export class BoardController {
182
169
  dispose(): void {
183
170
  for (const dispose of this.disposers.splice(0)) dispose()
184
171
  this.listeners.clear()
185
- if (this.reconcileTimer !== undefined) clearTimeout(this.reconcileTimer)
186
- this.reconcileTimer = undefined
187
172
  }
188
173
 
189
174
  // --- snapshot / subscription ------------------------------------------------
@@ -413,68 +398,30 @@ export class BoardController {
413
398
  // --- execution ---------------------------------------------------------------
414
399
 
415
400
  /**
416
- * Execute a task for real: move it to 'running', open an execution record,
417
- * and hand off to the ExecutionService. A second call while the task is
418
- * already running is ignored.
401
+ * Request a Host execution for a task: the Host ledger owns the running
402
+ * transition, the execution record, and the settlement. A second call
403
+ * while the task is already running is ignored; without a Host transport
404
+ * the run is refused (returns false).
419
405
  */
420
406
  async runTask(id: string): Promise<boolean> {
421
407
  const task = this.tasks.find(candidate => candidate.id === id)
422
408
  if (task === undefined || task.archivedAt !== undefined || task.status === 'running') return false
423
- if (this.deps.transport !== undefined) {
424
- return await this.commitRemote({ kind: 'run', taskId: id }, id)
425
- }
426
- const { task: next, execution } = startExecution(task, this.now(), this.uuid())
427
- this.tasks = this.tasks.map(candidate => candidate.id === id ? next : candidate)
428
- this.persistAndNotify()
429
- // This page owns the settlement of its own launches: the live watch
430
- // (ExecutionService.run) settles on the turn boundary, and list
431
- // reconciliation must not pre-empt it with a session that has not
432
- // started a turn yet (its list row is idle, not completed).
433
- this.activeExecutionIds.add(execution.id)
434
- if (this.deps.exec === undefined) throw new Error('legacy execution service is unavailable')
435
- await this.deps.exec.run(next, execution, (event) => { this.handleExecutionEvent(event) })
436
- return true
409
+ if (this.deps.transport === undefined) return false
410
+ return await this.commitRemote({ kind: 'run', taskId: id }, id)
437
411
  }
438
412
 
439
- /** Re-run a settled task: move it back to 'todo' first, then execute. */
413
+ /** Re-run a settled task through the Host (the Host replans and executes). */
440
414
  async rerunTask(id: string): Promise<void> {
441
415
  const task = this.tasks.find(candidate => candidate.id === id)
442
416
  if (task === undefined || task.archivedAt !== undefined) return
443
- if (this.deps.transport !== undefined) {
444
- await this.commitRemote({ kind: 'rerun', taskId: id }, id)
445
- return
446
- }
447
- if (task.status !== 'running') {
448
- this.tasks = this.tasks.map(candidate => candidate.id === id ? withStatus(candidate, 'todo', this.now()) : candidate)
449
- this.persistAndNotify()
450
- }
451
- await this.runTask(id)
452
- }
453
-
454
- private handleExecutionEvent(event: ExecutionEvent): void {
455
- if (event.kind === 'started') {
456
- this.tasks = this.tasks.map(task => task.id === event.taskId
457
- ? attachSessionId(task, event.executionId, event.sessionId, this.now())
458
- : task)
459
- this.persistAndNotify()
460
- return
461
- }
462
- this.activeExecutionIds.delete(event.executionId)
463
- this.tasks = this.tasks.map(task => task.id === event.taskId
464
- ? settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
465
- : task)
466
- this.persistAndNotify()
417
+ if (this.deps.transport === undefined) return
418
+ await this.commitRemote({ kind: 'rerun', taskId: id }, id)
467
419
  }
468
420
 
469
421
  // --- internals ---------------------------------------------------------------
470
422
 
471
- /** Reconcile running tasks and close the board when the user navigates. */
423
+ /** Close the board when the user navigates to another session. */
472
424
  private onSessionsChanged(): void {
473
- // Background/leftover executions settle through the session list (their
474
- // conversation snapshots stay cold until opened). Coalesce the burst of
475
- // list notifications into one reconcile pass instead of fanning out a
476
- // history read per notification; see scheduleReconcile.
477
- if (this.deps.transport === undefined) this.scheduleReconcile()
478
425
  if (!this.boardOpen) return
479
426
  const current = currentOf(this.deps.sessions)
480
427
  if (current !== this.lastCurrent) this.closeBoard()
@@ -483,74 +430,6 @@ export class BoardController {
483
430
 
484
431
  private lastCurrent: string | undefined = undefined
485
432
 
486
- /** Execution ids launched on this page; they settle via their live watch, never list reconciliation. */
487
- private readonly activeExecutionIds = new Set<string>()
488
-
489
- /** Debounce timer for {@link reconcileRunningTasks}. */
490
- private reconcileTimer: ReturnType<typeof setTimeout> | undefined = undefined
491
-
492
- /** Whether a reconcile pass is underway (single-flight guard). */
493
- private reconcileInFlight = false
494
-
495
- /**
496
- * Debounce + single-flight trigger for the running-task reconciliation.
497
- * Session-list notifications arrive in bursts (one per session status
498
- * change); both guards together keep a burst from reading the history API
499
- * once per running task.
500
- */
501
- private scheduleReconcile(): void {
502
- if (this.reconcileTimer !== undefined) return
503
- this.reconcileTimer = setTimeout(() => {
504
- this.reconcileTimer = undefined
505
- void this.reconcileRunningTasks()
506
- }, this.deps.reconcileDebounceMs ?? 350)
507
- }
508
-
509
- /** Settle tasks left 'running' whose sessions already finished. */
510
- private async reconcileRunningTasks(): Promise<void> {
511
- if (this.deps.exec === undefined) return
512
- if (this.reconcileInFlight) {
513
- // A session change arrived while a pass was in flight. The in-flight
514
- // pass already captured the task list it iterates and will not revisit
515
- // this task, so dropping the notification would leave it stuck
516
- // 'running'. Re-arm the debounce so the change is reconciled once the
517
- // current pass settles.
518
- this.scheduleReconcile()
519
- return
520
- }
521
- this.reconcileInFlight = true
522
- try {
523
- type Settled = Extract<ExecutionEvent, { kind: 'settled' }>
524
- const events: Array<{ taskId: string; event: Settled }> = []
525
- for (const task of this.tasks) {
526
- if (task.status !== 'running') continue
527
- const execution = task.executions[task.executions.length - 1]
528
- // Runs launched on this page settle through their live watch (turn
529
- // boundary); reconciliation exists for background/leftover runs.
530
- if (execution !== undefined && this.activeExecutionIds.has(execution.id)) continue
531
- const event = await this.deps.exec.reconcile(task)
532
- if (event !== undefined && event.kind === 'settled') events.push({ taskId: task.id, event })
533
- }
534
- if (events.length === 0) return
535
- let changed = false
536
- for (const { taskId, event } of events) {
537
- // The reconcile call above awaited: a sibling tab may have rewritten
538
- // the ledger (storage event reload) meanwhile. Re-read the freshest
539
- // record now so the stale task captured before the await can never
540
- // overwrite fields the sibling wrote.
541
- const task = this.tasks.find(candidate => candidate.id === taskId)
542
- if (task === undefined) continue
543
- const next = settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
544
- if (next === task) continue
545
- this.tasks = this.tasks.map(candidate => candidate.id === taskId ? next : candidate)
546
- changed = true
547
- }
548
- if (changed) this.persistAndNotify()
549
- } finally {
550
- this.reconcileInFlight = false
551
- }
552
- }
553
-
554
433
  private persistAndNotify(): void {
555
434
  if (this.deps.transport === undefined) this.deps.store.save(this.tasks)
556
435
  this.notify()
@@ -676,18 +555,3 @@ export class BoardController {
676
555
  for (const fn of [...this.listeners]) fn()
677
556
  }
678
557
  }
679
-
680
- /** Record which session ran an execution (once the execution service reports it). */
681
- function attachSessionId(
682
- task: TaskRecord,
683
- executionId: string,
684
- sessionId: string,
685
- now: number,
686
- ): TaskRecord {
687
- return {
688
- ...task,
689
- updatedAt: now,
690
- executions: task.executions.map(execution =>
691
- execution.id === executionId ? { ...execution, sessionId } : execution),
692
- }
693
- }
package/src/core/tasks.ts CHANGED
@@ -28,6 +28,27 @@ export interface ExecutionRecord {
28
28
  error: string | undefined
29
29
  }
30
30
 
31
+ /**
32
+ * Maximum number of execution records retained per task. Older settled runs
33
+ * are trimmed when a new execution starts so per-action ledger cost stays
34
+ * bounded regardless of how often a task ran before.
35
+ */
36
+ export const EXECUTION_HISTORY_LIMIT = 20
37
+
38
+ /**
39
+ * Trim an execution list to at most {@link EXECUTION_HISTORY_LIMIT} records,
40
+ * most recent last. A running (unsettled) execution is never trimmed: the
41
+ * Host monitor and restart recovery depend on the active record, and a task
42
+ * cannot start a new run while one is still open.
43
+ */
44
+ export function retainRecentExecutions(executions: readonly ExecutionRecord[]): ExecutionRecord[] {
45
+ if (executions.length <= EXECUTION_HISTORY_LIMIT) return [...executions]
46
+ const open = executions.filter(execution => execution.endedAt === undefined)
47
+ const settled = executions.filter(execution => execution.endedAt !== undefined)
48
+ const keepSettled = Math.max(EXECUTION_HISTORY_LIMIT - open.length, 0)
49
+ return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open]
50
+ }
51
+
31
52
  /**
32
53
  * A scheduled-run rule attached to a task. The Host scheduler triggers the
33
54
  * task when `nextRunAt` is due and persists the rule in the Host ledger.
@@ -59,7 +80,10 @@ export interface TaskRecord {
59
80
  createdAt: number
60
81
  /** Last mutation instant (ms epoch). */
61
82
  updatedAt: number
62
- /** Every execution attempt, most recent last. */
83
+ /**
84
+ * Execution history retained on the task, most recent last: the latest
85
+ * {@link EXECUTION_HISTORY_LIMIT} attempts, oldest trimmed on append.
86
+ */
63
87
  executions: ExecutionRecord[]
64
88
  /** Optional scheduled-run rule (absent on tasks without a schedule). */
65
89
  schedule?: ScheduleRule
@@ -221,7 +245,12 @@ export function startExecution(
221
245
  error: undefined,
222
246
  }
223
247
  return {
224
- task: { ...task, status: 'running', updatedAt: now, executions: [...task.executions, execution] },
248
+ task: {
249
+ ...task,
250
+ status: 'running',
251
+ updatedAt: now,
252
+ executions: retainRecentExecutions([...task.executions, execution]),
253
+ },
225
254
  execution,
226
255
  }
227
256
  }
@@ -5,7 +5,7 @@ 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 { parseLedger } from './core/store.ts'
8
- import { canMoveManually, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
8
+ import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
9
9
  import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
10
10
  import { applyCreateTask } from './core/use-cases/task-create.ts'
11
11
  import { applyDeleteTask } from './core/use-cases/task-delete.ts'
@@ -41,6 +41,27 @@ export interface OpenedRun {
41
41
  execution: ExecutionRecord
42
42
  }
43
43
 
44
+ /** Minimal value copy used by the Host session monitor. */
45
+ export interface OpenExecutionReference {
46
+ readonly taskId: string
47
+ readonly executionId: string
48
+ readonly sessionId: string | undefined
49
+ readonly startedAt: number
50
+ }
51
+
52
+ /** Minimal value copy used by the Host scheduler. */
53
+ export interface DueScheduleReference {
54
+ readonly taskId: string
55
+ readonly cron: string
56
+ readonly nextRunAt: number
57
+ }
58
+
59
+ /** Derived runtime data for one session-poll pass. */
60
+ export interface LedgerRuntimeView {
61
+ readonly armedSchedules: number
62
+ readonly openExecutions: readonly OpenExecutionReference[]
63
+ }
64
+
44
65
  const MAX_REQUEST_CACHE = 256
45
66
 
46
67
  interface CachedRequest {
@@ -211,7 +232,8 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
211
232
  const previous = byId.get(entry.id)
212
233
  byId.set(entry.id, previous === undefined ? entry : betterExecution(previous, entry))
213
234
  }
214
- return { ...newer, executions: [...byId.values()].sort((x, y) => x.startedAt - y.startedAt) }
235
+ const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
236
+ return { ...newer, executions: retainRecentExecutions(executions) }
215
237
  }
216
238
 
217
239
  function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
@@ -284,6 +306,50 @@ export class HostTaskLedger {
284
306
  return { revision, tasks: cloneTasks(this.document.tasks), scheduler }
285
307
  }
286
308
 
309
+ /**
310
+ * Runtime-only projection for the 5 s Host poll. It copies just primitive
311
+ * identifiers and timestamps, never the complete task/execution history or
312
+ * an authoritative mutable object from the ledger.
313
+ */
314
+ runtimeView(): LedgerRuntimeView {
315
+ let armedSchedules = 0
316
+ const openExecutions: OpenExecutionReference[] = []
317
+ for (const task of this.document.tasks) {
318
+ if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
319
+ for (const execution of task.executions) {
320
+ if (execution.endedAt !== undefined) continue
321
+ openExecutions.push({
322
+ taskId: task.id,
323
+ executionId: execution.id,
324
+ sessionId: execution.sessionId,
325
+ startedAt: execution.startedAt,
326
+ })
327
+ }
328
+ }
329
+ return { armedSchedules, openExecutions }
330
+ }
331
+
332
+ /** Count armed, non-archived schedules without cloning task histories. */
333
+ armedScheduleCount(): number {
334
+ let count = 0
335
+ for (const task of this.document.tasks) {
336
+ if (task.archivedAt === undefined && task.schedule?.enabled === true) count += 1
337
+ }
338
+ return count
339
+ }
340
+
341
+ /** Return value-only references for schedules due at the supplied Host time. */
342
+ dueSchedules(now: number): DueScheduleReference[] {
343
+ const due: DueScheduleReference[] = []
344
+ for (const task of this.document.tasks) {
345
+ if (task.archivedAt !== undefined) continue
346
+ const schedule = task.schedule
347
+ if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
348
+ due.push({ taskId: task.id, cron: schedule.cron, nextRunAt: schedule.nextRunAt })
349
+ }
350
+ return due
351
+ }
352
+
287
353
  subscribe(listener: () => void): () => void {
288
354
  this.listeners.add(listener)
289
355
  return () => { this.listeners.delete(listener) }
@@ -509,7 +575,7 @@ export class HostTaskLedger {
509
575
  try {
510
576
  const parsed = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<LedgerDocument>
511
577
  if (parsed.schemaVersion !== TASK_BOARD_SCHEMA_VERSION || !Array.isArray(parsed.tasks)) throw new Error('unsupported ledger schema')
512
- const tasks = parseHostTasks(parsed.tasks)
578
+ const tasks = parseHostTasks(parsed.tasks).map(task => ({ ...task, executions: retainRecentExecutions(task.executions) }))
513
579
  const invalidScheduleIds = (parsed.tasks as unknown[]).flatMap(value => {
514
580
  if (typeof value !== 'object' || value === null) return []
515
581
  const row = value as { id?: unknown; schedule?: unknown }