@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.
- package/README.i18n.yaml +2 -2
- package/README.md +83 -105
- package/README.zh.md +83 -105
- package/lib/client.js +902 -633
- package/lib/client.js.map +1 -1
- package/lib/index.js +2274 -5
- package/lib/types/client/PluginSettingsCard.d.ts +29 -1
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- package/lib/types/client/host-api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +8 -10
- package/lib/types/core/controller.d.ts +47 -15
- package/lib/types/core/schedule.d.ts +11 -2
- package/lib/types/core/tasks.d.ts +6 -6
- package/lib/types/core/use-cases/task-archive.d.ts +5 -4
- package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host-ledger.d.ts +52 -0
- package/lib/types/host-routes.d.ts +26 -0
- package/lib/types/host-runner.d.ts +41 -0
- package/lib/types/host-service.d.ts +45 -0
- package/lib/types/index.d.ts +17 -9
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/power-inhibitor.d.ts +73 -0
- package/lib/types/protocol.d.ts +78 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +24 -7
- package/src/client/board/TaskCard.tsx +25 -8
- package/src/client/board/TaskDetail.tsx +83 -50
- package/src/client/board-mount.tsx +1 -0
- package/src/client/host-api.ts +104 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/client/settings-card.module.css +90 -1
- package/src/client/sidebar-entry-core.ts +208 -0
- package/src/client/sidebar-entry.ts +24 -144
- package/src/core/controller.ts +222 -24
- package/src/core/schedule.ts +77 -13
- package/src/core/store.ts +4 -8
- package/src/core/tasks.ts +6 -6
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- package/src/dsh-home.ts +36 -0
- package/src/host-ledger.ts +539 -0
- package/src/host-routes.ts +196 -0
- package/src/host-runner.ts +152 -0
- package/src/host-service.ts +226 -0
- package/src/index.ts +54 -11
- package/src/loopback.ts +63 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +220 -0
- package/lib/types/core/scheduler.d.ts +0 -70
- 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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 =
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
}
|
package/src/core/controller.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Board controller: the single owner of task-ledger state and view state.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
309
|
-
*
|
|
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
|
|
320
|
-
*
|
|
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
|
-
|