@linxin666/dsh-client-ui-task-board 0.1.20 → 0.2.1

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 (58) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +83 -105
  3. package/README.zh.md +83 -105
  4. package/lib/client.js +902 -633
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2274 -5
  7. package/lib/types/client/PluginSettingsCard.d.ts +29 -1
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
  9. package/lib/types/client/board/TaskCard.d.ts +5 -2
  10. package/lib/types/client/host-api.d.ts +18 -0
  11. package/lib/types/client/locales.d.ts +10 -0
  12. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  13. package/lib/types/client/sidebar-entry.d.ts +8 -10
  14. package/lib/types/core/controller.d.ts +47 -15
  15. package/lib/types/core/schedule.d.ts +11 -2
  16. package/lib/types/core/tasks.d.ts +6 -6
  17. package/lib/types/core/use-cases/task-archive.d.ts +5 -4
  18. package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
  19. package/lib/types/dsh-home.d.ts +16 -0
  20. package/lib/types/host-ledger.d.ts +52 -0
  21. package/lib/types/host-routes.d.ts +26 -0
  22. package/lib/types/host-runner.d.ts +41 -0
  23. package/lib/types/host-service.d.ts +45 -0
  24. package/lib/types/index.d.ts +17 -9
  25. package/lib/types/loopback.d.ts +24 -0
  26. package/lib/types/power-inhibitor.d.ts +73 -0
  27. package/lib/types/protocol.d.ts +78 -0
  28. package/package.json +12 -9
  29. package/src/client/PluginSettingsCard.tsx +229 -18
  30. package/src/client/TaskBoardSettingsCard.tsx +51 -4
  31. package/src/client/board/NewTaskModal.tsx +9 -6
  32. package/src/client/board/TaskBoard.tsx +24 -7
  33. package/src/client/board/TaskCard.tsx +25 -8
  34. package/src/client/board/TaskDetail.tsx +83 -50
  35. package/src/client/board-mount.tsx +1 -0
  36. package/src/client/host-api.ts +104 -0
  37. package/src/client/index.ts +9 -78
  38. package/src/client/locales.ts +24 -4
  39. package/src/client/settings-card.module.css +90 -1
  40. package/src/client/sidebar-entry-core.ts +208 -0
  41. package/src/client/sidebar-entry.ts +24 -144
  42. package/src/core/controller.ts +222 -24
  43. package/src/core/schedule.ts +77 -13
  44. package/src/core/store.ts +4 -8
  45. package/src/core/tasks.ts +6 -6
  46. package/src/core/use-cases/task-archive.ts +14 -5
  47. package/src/core/use-cases/task-schedule.ts +6 -5
  48. package/src/dsh-home.ts +36 -0
  49. package/src/host-ledger.ts +539 -0
  50. package/src/host-routes.ts +196 -0
  51. package/src/host-runner.ts +152 -0
  52. package/src/host-service.ts +226 -0
  53. package/src/index.ts +54 -11
  54. package/src/loopback.ts +63 -0
  55. package/src/power-inhibitor.ts +321 -0
  56. package/src/protocol.ts +220 -0
  57. package/lib/types/core/scheduler.d.ts +0 -70
  58. package/src/core/scheduler.ts +0 -161
@@ -1,84 +1,26 @@
1
1
  /**
2
- * Sidebar entry injection.
2
+ * Sidebar entry injection — package-specific wiring over the shared core.
3
3
  *
4
4
  * dsh's sidebar shell exposes no slot an external plugin can register into
5
5
  * (`sidebar.workspaces` / `sidebar.settings` are single-occupant and already
6
- * taken), so — following the skin precedent of DOM-level extension — the
7
- * entry row is injected between the shell's New Session button and the
8
- * workspace browser. The injection self-heals: a MutationObserver watches the
9
- * sidebar root and re-inserts the row whenever a React re-render displaces it
10
- * (re-insertion happens in the same frame, before paint, so no flicker).
11
- *
12
- * The row is plain DOM (no React tree) so it can never disturb the shell's
13
- * reconciliation; the board view it toggles is a separate React root mounted
14
- * in the center column (see board-mount.ts).
6
+ * taken), so the entry row is injected between the shell's New Session button
7
+ * and the workspace browser. The DOM injection / self-healing / idempotency
8
+ * logic lives exactly once in shared/client/sidebar-entry-core.ts (synced
9
+ * copy); this wrapper supplies the task-board icon, copy, CSS module, and the
10
+ * board toggle. The row is plain DOM (no React tree); the board view it
11
+ * toggles is a separate React root mounted in the center column
12
+ * (see board-mount.ts).
15
13
  */
16
14
  import type { BoardController } from '../core/controller.ts'
17
15
  import { t } from './locales.ts'
18
16
  import css from './board.module.css'
17
+ import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-core.ts'
19
18
 
20
19
  /** Stable data attribute identifying the injected entry row. */
21
20
  export const ENTRY_SELECTOR = '[data-dsh-taskboard-entry]'
22
21
 
23
22
  /** Inline icon (matches the shell's 16px nav-icon look). */
24
- 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>`
25
-
26
- /** Find the sidebar shell root element, or undefined while not yet mounted. */
27
- function sidebarRoot(): HTMLElement | undefined {
28
- const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
29
- if (column === null) return undefined
30
- // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
31
- // Prefer the element that owns the logo row — the real sidebar UI root —
32
- // and fall back to the column's first child for legacy shells.
33
- const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
34
- return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
35
- }
36
-
37
- /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
38
- function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
39
- const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
40
- if (nested !== null) return nested
41
- for (const child of root.children) {
42
- if (child.tagName === 'BUTTON') return child as HTMLButtonElement
43
- }
44
- return undefined
45
- }
46
-
47
- /** Build the entry row (a detached button; insert once the shell is up). */
48
- function createEntry(controller: BoardController): HTMLButtonElement {
49
- const entry = document.createElement('button')
50
- entry.type = 'button'
51
- entry.dataset.dshTaskboardEntry = ''
52
- entry.className = css.entry
53
- entry.setAttribute('aria-label', t('entry.label'))
54
- entry.innerHTML = `<span class="${css.entryIcon}">${ICON}</span><span class="${css.entryLabel}">${t('entry.label')}</span>`
55
- entry.addEventListener('click', () => { controller.toggleBoard() })
56
- return entry
57
- }
58
-
59
- /** Re-insert the entry after the New Session row (before the browser region). */
60
- function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
61
- const button = newSessionButton(root)
62
- if (button === undefined) return false
63
- if (entry.parentElement !== root) {
64
- // Position relative to the family block (entries injected by sibling
65
- // plugins), never relative to transient logoRow geometry: every family
66
- // plugin that self-heals during a re-render then lands in the same
67
- // relative order, so the entries cannot swap positions regardless of
68
- // observer callback order or of shell wrapper changes. There is no
69
- // append-to-end fallback: appending at the end would randomly reorder
70
- // the block after a shell re-render.
71
- const row = button.closest('[class*="logoRow"]')
72
- const base = (row !== null && row.parentElement === root) ? row : button
73
- const family = Array.from(root.children).filter(
74
- (el): el is HTMLElement => el instanceof HTMLElement && el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry]'),
75
- )
76
- // task board sits before the whole family block.
77
- const anchor = family.length > 0 ? family[0] : base.nextElementSibling
78
- root.insertBefore(entry, anchor)
79
- }
80
- return true
81
- }
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>'
82
24
 
83
25
  /**
84
26
  * Mount the sidebar entry, waiting for the shell to render and self-healing
@@ -87,81 +29,19 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
87
29
  * @returns disposer removing the entry and its observers.
88
30
  */
89
31
  export function mountSidebarEntry(controller: BoardController): () => void {
90
- // DOM-level idempotency: whatever path mounted an entry row before this
91
- // call (a duplicated apply, an HMR re-injection, a stale module still
92
- // alive), never mount a second one. The existing row keeps working; a full
93
- // page reload is the ultimate reset.
94
- if (typeof document !== 'undefined' && document.querySelector('[data-dsh-taskboard-entry]') !== null) {
95
- return () => {}
96
- }
97
- const entry = createEntry(controller)
98
- let root: HTMLElement | undefined
99
- let placed = false
100
-
101
- const tryPlace = (): void => {
102
- if (root !== undefined && !root.isConnected) {
103
- // The shell rebuilt the sidebar pane (whole-tree teardown); the root
104
- // observer is gone with the old tree, so detach it and re-query from
105
- // scratch. The new pane is later noticed by the body-level watcher.
106
- rootObserver.disconnect()
107
- root = undefined
108
- placed = false
109
- }
110
- if (placed) {
111
- // Cheap short-circuit: entry still lives in a mountable subtree.
112
- if (document.body.contains(entry)) return
113
- // Entry was torn down together with the old tree; reset and re-place.
114
- rootObserver.disconnect()
115
- root = undefined
116
- placed = false
117
- }
118
- root ??= sidebarRoot()
119
- if (root === undefined) return
120
- placed = placeEntry(root, entry)
121
- if (placed) {
122
- rootObserver.observe(root, { childList: true, subtree: true })
123
- }
124
- }
125
-
126
- // Body-level watcher retained as the "whole rebuild" fallback: when the shell
127
- // tears down the whole sidebar pane, the root observer is gone with it and
128
- // only this body observation can notice the new pane mounting. It is no
129
- // longer disconnected after placement; the placed-and-still-mounted case
130
- // short-circuits through the cheap document.body.contains(entry) check, so
131
- // unrelated app mutations (e.g. chat streaming) cost one contains check
132
- // instead of churning the full re-query.
133
- const waitObserver = new MutationObserver(() => { tryPlace() })
134
- waitObserver.observe(document.body, { childList: true, subtree: true })
135
-
136
- // Self-heal: if a React re-render displaces the row, re-insert it in the
137
- // same frame (microtask before paint → no visible flicker).
138
- const rootObserver = new MutationObserver(() => {
139
- if (root === undefined || !root.isConnected) {
140
- placed = false
141
- tryPlace()
142
- return
143
- }
144
- if (!root.contains(entry)) {
145
- placed = placeEntry(root, entry)
146
- }
32
+ return mountSharedSidebarEntry({
33
+ rowAttribute: 'data-dsh-taskboard-entry',
34
+ rowSelector: ENTRY_SELECTOR,
35
+ plugin: 'task-board',
36
+ icon: ICON,
37
+ css,
38
+ label: () => t('entry.label'),
39
+ onToggle: () => { controller.toggleBoard() },
40
+ position: 'before',
41
+ familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]'],
42
+ active: {
43
+ subscribe: (listener) => controller.subscribe(listener),
44
+ isOpen: () => controller.getSnapshot().boardOpen,
45
+ },
147
46
  })
148
-
149
- // Reflect the board's open state on the row (active highlight). Note: assigning
150
- // undefined to dataset.active materializes data-active="undefined" and keeps the
151
- // row permanently highlighted — delete the attribute instead.
152
- const syncActive = () => {
153
- if (controller.getSnapshot().boardOpen) entry.dataset.active = 'true'
154
- else delete entry.dataset.active
155
- }
156
- const unsubscribe = controller.subscribe(syncActive)
157
- syncActive()
158
-
159
- tryPlace()
160
-
161
- return () => {
162
- waitObserver.disconnect()
163
- rootObserver.disconnect()
164
- unsubscribe()
165
- entry.remove()
166
- }
167
47
  }
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Board controller: the single owner of task-ledger state and view state.
3
3
  *
4
- * It keeps the ledger in memory, persists every mutation through the
5
- * {@link TaskStore}, drives real executions through the
6
- * {@link ExecutionService}, and closes the board view whenever the user
7
- * navigates to a session (the sessions-list `current` selection changes).
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.
8
7
  * Framework-free (structural runtime faces) so the whole orchestration is
9
8
  * unit-testable with fakes.
10
9
  *
@@ -13,7 +12,7 @@
13
12
  * owns only the orchestration seam (state, persistence, notify, execution,
14
13
  * navigation, reconciliation).
15
14
  */
16
- import { ExecutionService, type ExecutionEvent } from './execution.ts'
15
+ import type { ExecutionEvent, ExecutionService } from './execution.ts'
17
16
  import type { TaskStore } from './store.ts'
18
17
  import {
19
18
  settleExecution, startExecution, withStatus,
@@ -24,6 +23,14 @@ import { applyCreateTask } from './use-cases/task-create.ts'
24
23
  import { applyDeleteTask } from './use-cases/task-delete.ts'
25
24
  import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
26
25
  import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
26
+ import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from '../protocol.ts'
27
+
28
+ export interface TaskBoardTransport {
29
+ bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
30
+ state(): Promise<TaskBoardSnapshot>
31
+ action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
32
+ subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
33
+ }
27
34
 
28
35
  /** The sessions face the controller needs for navigation awareness. */
29
36
  export interface SessionsControllerFace {
@@ -38,7 +45,8 @@ export interface SessionsControllerFace {
38
45
  /** Controller dependencies (all swappable in tests). */
39
46
  export interface ControllerDeps {
40
47
  store: TaskStore
41
- exec: ExecutionService
48
+ /** Legacy browser execution seam used only by isolated v1 tests. */
49
+ exec?: ExecutionService
42
50
  sessions: SessionsControllerFace
43
51
  /** Clock; defaults to Date.now. */
44
52
  now?: () => number
@@ -46,6 +54,8 @@ export interface ControllerDeps {
46
54
  uuid?: () => string
47
55
  /** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
48
56
  reconcileDebounceMs?: number
57
+ /** Host-authoritative transport; absent keeps the legacy in-memory test path. */
58
+ transport?: TaskBoardTransport
49
59
  }
50
60
 
51
61
  /** One workspace option the execution-target pickers offer. */
@@ -80,6 +90,9 @@ export interface ControllerSnapshot {
80
90
  selectedTaskId: string | undefined
81
91
  /** Picker option sets (workspace list + agent-preset roster). */
82
92
  executionOptions: ExecutionOptionsSnapshot
93
+ pendingTaskIds: readonly string[]
94
+ transportError?: string
95
+ host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power'>
83
96
  }
84
97
 
85
98
  /** The selected task (resolved from the ledger), or undefined. */
@@ -109,6 +122,10 @@ function currentOf(sessions: SessionsControllerFace): string | undefined {
109
122
  return sessions.list.getSnapshot().current
110
123
  }
111
124
 
125
+ function messageOf(error: unknown): string {
126
+ return error instanceof Error ? error.message : String(error)
127
+ }
128
+
112
129
  /**
113
130
  * Board controller (see module doc). All mutations bump the snapshot and
114
131
  * persist through the store; UI and DOM mounts subscribe and re-render.
@@ -123,6 +140,12 @@ export class BoardController {
123
140
  private disposers: Array<() => void> = []
124
141
  private readonly now: () => number
125
142
  private readonly uuid: () => string
143
+ private readonly pendingTaskIds = new Set<string>()
144
+ private readonly taskQueues = new Map<string, Promise<void>>()
145
+ private transportError: string | undefined
146
+ private hostState: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power'> | undefined
147
+ private remoteSubscribed = false
148
+ private remoteInitialization: Promise<boolean> | undefined
126
149
 
127
150
  /** @param deps - store, execution service, and the sessions navigation face. */
128
151
  constructor(private readonly deps: ControllerDeps) {
@@ -135,15 +158,19 @@ export class BoardController {
135
158
  /** Load the persisted ledger and start the navigation/status subscriptions. */
136
159
  start(): void {
137
160
  this.tasks = this.deps.store.load()
138
- void this.reconcileRunningTasks()
161
+ if (this.deps.transport !== undefined) {
162
+ void this.initializeRemote()
163
+ } else {
164
+ void this.reconcileRunningTasks()
165
+ }
139
166
  // A sibling tab may have edited or deleted the ledger (same origin,
140
167
  // storage events). Reload on external change so a task deleted in
141
168
  // another tab stops firing here — and is never written back by this
142
169
  // tab's stale copy (scheduler roll-forward, execution settlement).
143
- const unsubscribeExternal = this.deps.store.subscribeExternal?.(() => {
170
+ const unsubscribeExternal = this.deps.transport === undefined ? this.deps.store.subscribeExternal?.(() => {
144
171
  this.tasks = this.deps.store.load()
145
172
  this.notify()
146
- })
173
+ }) : undefined
147
174
  if (unsubscribeExternal !== undefined) this.disposers.push(unsubscribeExternal)
148
175
  this.disposers.push(this.deps.sessions.list.subscribe(() => {
149
176
  this.onSessionsChanged()
@@ -168,6 +195,9 @@ export class BoardController {
168
195
  archiveView: this.archiveView,
169
196
  selectedTaskId: this.selectedTaskId,
170
197
  executionOptions: this.executionOptions,
198
+ pendingTaskIds: [...this.pendingTaskIds],
199
+ ...(this.transportError === undefined ? {} : { transportError: this.transportError }),
200
+ ...(this.hostState === undefined ? {} : { host: this.hostState }),
171
201
  }
172
202
  }
173
203
 
@@ -176,6 +206,16 @@ export class BoardController {
176
206
  return () => { this.listeners.delete(fn) }
177
207
  }
178
208
 
209
+ /** Whether production mutations are confirmed by the Host transport. */
210
+ isHostBacked(): boolean {
211
+ return this.deps.transport !== undefined
212
+ }
213
+
214
+ /** Retry initial migration/state synchronization after an explicit Host error. */
215
+ async retryHostSync(): Promise<boolean> {
216
+ return await this.initializeRemote()
217
+ }
218
+
179
219
  // --- view state -------------------------------------------------------------
180
220
 
181
221
  openBoard(): void {
@@ -229,14 +269,30 @@ export class BoardController {
229
269
  // --- task mutations (use-case transitions in core/use-cases) -----------------
230
270
 
231
271
  createTask(input: NewTaskInput): TaskRecord | undefined {
232
- const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), this.uuid())
272
+ const id = this.uuid()
273
+ const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id)
233
274
  if (task === undefined) return undefined
234
275
  this.tasks = [...tasks]
235
276
  this.persistAndNotify()
236
277
  return task
237
278
  }
238
279
 
280
+ /** Create through the Host and expose the task only after confirmation. */
281
+ async createTaskConfirmed(input: NewTaskInput): Promise<TaskRecord | undefined> {
282
+ if (this.deps.transport === undefined) return this.createTask(input)
283
+ const id = this.uuid()
284
+ const preview = applyCreateTask(this.tasks, input, this.now(), id).task
285
+ if (preview === undefined) return undefined
286
+ return await this.commitRemote({ kind: 'create', id, input }, id)
287
+ ? this.tasks.find(task => task.id === id)
288
+ : undefined
289
+ }
290
+
239
291
  updateTask(id: string, patch: TaskUpdatePatch): void {
292
+ if (this.deps.transport !== undefined) {
293
+ void this.commitRemote({ kind: 'update', taskId: id, patch }, id)
294
+ return
295
+ }
240
296
  this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
241
297
  this.persistAndNotify()
242
298
  }
@@ -251,11 +307,19 @@ export class BoardController {
251
307
  }
252
308
 
253
309
  moveTask(id: string, status: TaskStatus): void {
310
+ if (this.deps.transport !== undefined) {
311
+ void this.commitRemote({ kind: 'move', taskId: id, status }, id)
312
+ return
313
+ }
254
314
  this.tasks = this.tasks.map(task => task.id === id ? withStatus(task, status, this.now()) : task)
255
315
  this.persistAndNotify()
256
316
  }
257
317
 
258
318
  deleteTask(id: string): void {
319
+ if (this.deps.transport !== undefined) {
320
+ void this.commitRemote({ kind: 'delete', taskId: id }, id)
321
+ return
322
+ }
259
323
  const { tasks, selectionCleared } = applyDeleteTask(this.tasks, this.selectedTaskId, id)
260
324
  this.tasks = [...tasks]
261
325
  if (selectionCleared) this.selectedTaskId = undefined
@@ -271,6 +335,10 @@ export class BoardController {
271
335
  archiveTask(id: string): boolean {
272
336
  const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now())
273
337
  if (!archived) return false
338
+ if (this.deps.transport !== undefined) {
339
+ void this.commitRemote({ kind: 'archive', taskId: id }, id)
340
+ return true
341
+ }
274
342
  this.tasks = [...tasks]
275
343
  this.persistAndNotify()
276
344
  return true
@@ -280,7 +348,14 @@ export class BoardController {
280
348
  restoreTask(id: string): boolean {
281
349
  const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now())
282
350
  if (!archived) return false
351
+ if (this.deps.transport !== undefined) {
352
+ void this.commitRemote({ kind: 'restore', taskId: id }, id).then(restored => {
353
+ if (restored && this.selectedTaskId === id) this.closeTask()
354
+ })
355
+ return true
356
+ }
283
357
  this.tasks = [...tasks]
358
+ if (this.selectedTaskId === id) this.selectedTaskId = undefined
284
359
  this.persistAndNotify()
285
360
  return true
286
361
  }
@@ -299,15 +374,18 @@ export class BoardController {
299
374
  setSchedule(id: string, patch: { enabled?: boolean; cron?: string }): boolean {
300
375
  const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now())
301
376
  if (!applied) return false
377
+ if (this.deps.transport !== undefined) {
378
+ void this.commitRemote({ kind: 'set-schedule', taskId: id, patch }, id)
379
+ return true
380
+ }
302
381
  this.tasks = [...tasks]
303
382
  this.persistAndNotify()
304
383
  return true
305
384
  }
306
385
 
307
386
  /**
308
- * Roll a task's schedule forward (scheduler callback): persist the next due
309
- * instant and the trigger instant of this run. No-op when the task has no
310
- * schedule rule (it was deleted mid-tick, for example).
387
+ * Legacy pure-controller seam retained for migration-focused tests. The
388
+ * production browser never rolls schedules; the Host ledger owns them.
311
389
  */
312
390
  applyScheduleNextRun(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void {
313
391
  const next = applyScheduleRollForward(this.tasks, id, nextRunAt, lastTriggeredAt, this.now())
@@ -316,12 +394,8 @@ export class BoardController {
316
394
  }
317
395
 
318
396
  /**
319
- * Reload the ledger from the persisted store without notifying subscribers.
320
- * The scheduler calls this before every tick so a task deleted in another
321
- * tab (or a stale in-memory copy) can never be fired from this tab: the
322
- * fire decision and the subsequent roll-forward both run on the freshest
323
- * persisted truth. Deliberately silent — same-origin external changes still
324
- * reach subscribers through the storage-event subscription.
397
+ * Reload the legacy v1 store without notifying subscribers. Production v2
398
+ * reads Host snapshots instead; this remains only for isolated legacy tests.
325
399
  */
326
400
  reloadFromStore(): void {
327
401
  this.tasks = this.deps.store.load()
@@ -345,7 +419,10 @@ export class BoardController {
345
419
  */
346
420
  async runTask(id: string): Promise<boolean> {
347
421
  const task = this.tasks.find(candidate => candidate.id === id)
348
- if (task === undefined || task.status === 'running') return false
422
+ 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
+ }
349
426
  const { task: next, execution } = startExecution(task, this.now(), this.uuid())
350
427
  this.tasks = this.tasks.map(candidate => candidate.id === id ? next : candidate)
351
428
  this.persistAndNotify()
@@ -354,6 +431,7 @@ export class BoardController {
354
431
  // reconciliation must not pre-empt it with a session that has not
355
432
  // started a turn yet (its list row is idle, not completed).
356
433
  this.activeExecutionIds.add(execution.id)
434
+ if (this.deps.exec === undefined) throw new Error('legacy execution service is unavailable')
357
435
  await this.deps.exec.run(next, execution, (event) => { this.handleExecutionEvent(event) })
358
436
  return true
359
437
  }
@@ -361,7 +439,11 @@ export class BoardController {
361
439
  /** Re-run a settled task: move it back to 'todo' first, then execute. */
362
440
  async rerunTask(id: string): Promise<void> {
363
441
  const task = this.tasks.find(candidate => candidate.id === id)
364
- if (task === undefined) return
442
+ 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
+ }
365
447
  if (task.status !== 'running') {
366
448
  this.tasks = this.tasks.map(candidate => candidate.id === id ? withStatus(candidate, 'todo', this.now()) : candidate)
367
449
  this.persistAndNotify()
@@ -392,7 +474,7 @@ export class BoardController {
392
474
  // conversation snapshots stay cold until opened). Coalesce the burst of
393
475
  // list notifications into one reconcile pass instead of fanning out a
394
476
  // history read per notification; see scheduleReconcile.
395
- this.scheduleReconcile()
477
+ if (this.deps.transport === undefined) this.scheduleReconcile()
396
478
  if (!this.boardOpen) return
397
479
  const current = currentOf(this.deps.sessions)
398
480
  if (current !== this.lastCurrent) this.closeBoard()
@@ -426,6 +508,7 @@ export class BoardController {
426
508
 
427
509
  /** Settle tasks left 'running' whose sessions already finished. */
428
510
  private async reconcileRunningTasks(): Promise<void> {
511
+ if (this.deps.exec === undefined) return
429
512
  if (this.reconcileInFlight) return
430
513
  this.reconcileInFlight = true
431
514
  try {
@@ -461,10 +544,126 @@ export class BoardController {
461
544
  }
462
545
 
463
546
  private persistAndNotify(): void {
464
- this.deps.store.save(this.tasks)
547
+ if (this.deps.transport === undefined) this.deps.store.save(this.tasks)
465
548
  this.notify()
466
549
  }
467
550
 
551
+ private async commitRemote(action: TaskBoardAction, taskId?: string): Promise<boolean> {
552
+ const transport = this.deps.transport
553
+ if (transport === undefined) return true
554
+ if (taskId === undefined) return await this.performRemote(action)
555
+ const previous = this.taskQueues.get(taskId) ?? Promise.resolve()
556
+ const operation = previous.catch(() => {}).then(async () => await this.performRemote(action))
557
+ const tail = operation.then(() => {}, () => {})
558
+ this.taskQueues.set(taskId, tail)
559
+ this.pendingTaskIds.add(taskId)
560
+ this.notify()
561
+ try {
562
+ return await operation
563
+ } finally {
564
+ if (this.taskQueues.get(taskId) === tail) {
565
+ this.taskQueues.delete(taskId)
566
+ this.pendingTaskIds.delete(taskId)
567
+ this.notify()
568
+ }
569
+ }
570
+ }
571
+
572
+ private async performRemote(action: TaskBoardAction): Promise<boolean> {
573
+ const transport = this.deps.transport
574
+ if (transport === undefined) return true
575
+ this.transportError = undefined
576
+ this.notify()
577
+ try {
578
+ const accepted = this.acceptRemote(await transport.action(action))
579
+ return accepted || await this.refreshRemote()
580
+ } catch (error) {
581
+ await this.refreshRemote(messageOf(error))
582
+ return false
583
+ }
584
+ }
585
+
586
+ private async initializeRemote(): Promise<boolean> {
587
+ if (this.remoteInitialization !== undefined) return await this.remoteInitialization
588
+ const initialization = this.doInitializeRemote()
589
+ this.remoteInitialization = initialization
590
+ try {
591
+ return await initialization
592
+ } finally {
593
+ if (this.remoteInitialization === initialization) this.remoteInitialization = undefined
594
+ }
595
+ }
596
+
597
+ private async doInitializeRemote(): Promise<boolean> {
598
+ const transport = this.deps.transport
599
+ if (transport === undefined) return true
600
+ try {
601
+ this.acceptRemote(await transport.bootstrap(this.tasks))
602
+ if (!this.remoteSubscribed) {
603
+ this.remoteSubscribed = true
604
+ this.disposers.push(transport.subscribe((event) => { this.onRemoteEvent(event) }))
605
+ }
606
+ return true
607
+ } catch (error) {
608
+ this.transportError = messageOf(error)
609
+ this.notify()
610
+ return false
611
+ }
612
+ }
613
+
614
+ /**
615
+ * SSE frames carry revision/scheduler/power. When the revision matches the
616
+ * one already applied, apply the frame's scheduler/power in place and skip
617
+ * the full /state fetch; otherwise the 5 s heartbeat would re-clone and
618
+ * re-serialize the whole ledger per tab even while nothing changes.
619
+ */
620
+ private onRemoteEvent(event: TaskBoardEventPayload | undefined): void {
621
+ if (event !== undefined && this.hostState !== undefined && event.revision === this.hostState.revision
622
+ && typeof event.scheduler === 'object' && event.scheduler !== null
623
+ && typeof event.power === 'object' && event.power !== null) {
624
+ this.hostState = { revision: event.revision, scheduler: event.scheduler, power: event.power }
625
+ this.notify()
626
+ return
627
+ }
628
+ void this.refreshRemote()
629
+ }
630
+
631
+ private async refreshRemote(preserveError?: string): Promise<boolean> {
632
+ const transport = this.deps.transport
633
+ if (transport === undefined) return true
634
+ try {
635
+ this.acceptRemote(await transport.state())
636
+ if (preserveError !== undefined) {
637
+ this.transportError = preserveError
638
+ this.notify()
639
+ }
640
+ return true
641
+ } catch (error) {
642
+ this.transportError = preserveError ?? messageOf(error)
643
+ this.notify()
644
+ return false
645
+ }
646
+ }
647
+
648
+ private acceptRemote(snapshot: TaskBoardSnapshot): boolean {
649
+ const currentLedgerId = this.hostState?.scheduler.ledgerId
650
+ const nextLedgerId = snapshot.scheduler.ledgerId
651
+ const sameGeneration = currentLedgerId === nextLedgerId
652
+ if (sameGeneration && this.hostState !== undefined && snapshot.revision < this.hostState.revision) return false
653
+ this.tasks = [...snapshot.tasks]
654
+ this.hostState = { revision: snapshot.revision, scheduler: snapshot.scheduler, power: snapshot.power }
655
+ this.transportError = undefined
656
+ if (this.selectedTaskId !== undefined && !this.tasks.some(task => task.id === this.selectedTaskId)) {
657
+ this.selectedTaskId = undefined
658
+ }
659
+ if (!this.archiveView && this.selectedTaskId !== undefined
660
+ && this.tasks.find(task => task.id === this.selectedTaskId)?.archivedAt !== undefined) {
661
+ this.selectedTaskId = undefined
662
+ }
663
+ this.notify()
664
+ return true
665
+ }
666
+
468
667
  private notify(): void {
469
668
  for (const fn of [...this.listeners]) fn()
470
669
  }
@@ -484,4 +683,3 @@ function attachSessionId(
484
683
  execution.id === executionId ? { ...execution, sessionId } : execution),
485
684
  }
486
685
  }
487
-